From 00c86ee50730fc4b70a74da26e1fa7b9234769c4 Mon Sep 17 00:00:00 2001 From: Moe Dashti Date: Wed, 28 Oct 2026 00:43:43 -0700 Subject: [PATCH 1/4] Added tests that decode an unknown enum number, with today's result. Proto3 enums are open. Python fails the whole message on a Stream status that it doesn't know. TypeScript and .NET pass the number through, and their tests pin it. --- .../dotnet/tests/StreamServiceChecks.cs | 19 +++++++++++++++++++ .../python/tests/test_stream_service.py | 12 ++++++++++++ .../typescript/tests/stream-service.test.ts | 15 +++++++++++++++ 3 files changed, 46 insertions(+) diff --git a/advanced/samples/dotnet/tests/StreamServiceChecks.cs b/advanced/samples/dotnet/tests/StreamServiceChecks.cs index a7f3ef5c7..3f836429f 100644 --- a/advanced/samples/dotnet/tests/StreamServiceChecks.cs +++ b/advanced/samples/dotnet/tests/StreamServiceChecks.cs @@ -5,6 +5,7 @@ using Xunit; using static Nexgen.DotNetExamples.Tests.TransferTypeTestSupport; using StreamServiceExample = Streams::Temporalio.Workflows; +using WireDescribeStreamResponse = Streams::Temporalio.Api.StreamService.V1.DescribeStreamResponse; using WireReadRecordsResponse = Streams::Temporalio.Api.StreamService.V1.ReadRecordsResponse; using WireStreamRecord = Streams::Temporalio.Api.Stream.V1.StreamRecord; @@ -71,5 +72,23 @@ public void ReadItemsRoundTripAsTheirRecords() close.Value.Status); Assert.Equal(3, decoded.Records[1].Offset); } + + // A newer server can send a Stream status that this code doesn't know. + [Fact] + public void UnknownEnumNumberPassesThrough() + { + var converter = CreateSdkConverter(typeof(StreamServiceExample.DescribeStreamResponse)); + var wire = new WireDescribeStreamResponse + { + Status = (Streams::Temporalio.Api.Enums.V1.StreamStatus)9, + }; + + var decoded = Assert.IsType( + converter.FromTransferType(wire)); + Assert.Equal((StreamServiceExample.StreamStatus)9, decoded.Status); + var encoded = Assert.IsType( + converter.ToTransferType(decoded)); + Assert.Equal((Streams::Temporalio.Api.Enums.V1.StreamStatus)9, encoded.Status); + } } } diff --git a/advanced/samples/python/tests/test_stream_service.py b/advanced/samples/python/tests/test_stream_service.py index 06a8f5f95..0e5c3e380 100644 --- a/advanced/samples/python/tests/test_stream_service.py +++ b/advanced/samples/python/tests/test_stream_service.py @@ -1,6 +1,7 @@ from __future__ import annotations import pytest +from temporalio.api.enums.v1 import stream_pb2 from temporalio.api.stream.v1 import message_pb2 from temporalio.api.streamservice.v1 import request_response_pb2 from temporalio.converter import PayloadConverter @@ -90,3 +91,14 @@ def test_a_request_without_its_stream_is_refused() -> None: ValueError, match="missing required field AppendRecordsRequest.stream" ): _ = converter.from_transfer_type(wire, AppendRecordsRequest) + + +def test_an_unknown_enum_number_fails_the_message() -> None: + converter = models._DescribeStreamResponseTransferTypeConverter() + # A newer server can send a Stream status that this code doesn't know. + wire = request_response_pb2.DescribeStreamResponse( + status=stream_pb2.StreamStatus.ValueType(9) + ) + + with pytest.raises(ValueError, match="9 is not a valid StreamStatus"): + _ = converter.from_transfer_type(wire, models.DescribeStreamResponse) diff --git a/advanced/samples/typescript/tests/stream-service.test.ts b/advanced/samples/typescript/tests/stream-service.test.ts index 680ca8050..c112e94f5 100644 --- a/advanced/samples/typescript/tests/stream-service.test.ts +++ b/advanced/samples/typescript/tests/stream-service.test.ts @@ -6,6 +6,7 @@ import { describe, expect, test } from "vitest"; import { type ReadRecordsResponse, StreamCloseStatus, + describeStreamResponseTransferTypeConverter, readRecordsRequestTransferTypeConverter, readRecordsResponseTransferTypeConverter, } from "../wit/stream-service/models.ts"; @@ -86,4 +87,18 @@ describe("stream-service generated output", () => { readRecordsRequestTransferTypeConverter.toTransferType(model).stream?.id, ).toBe("stream-1"); }); + + test("passes an enum number that it doesn't know through", () => { + // A newer server can send a Stream status that this code doesn't know. + const proto: temporal.api.streamservice.v1.IDescribeStreamResponse = { + status: 9 as temporal.api.enums.v1.StreamStatus, + }; + + const model = describeStreamResponseTransferTypeConverter.fromTransferType(proto); + + expect(model.status).toBe(9); + expect( + describeStreamResponseTransferTypeConverter.toTransferType(model).status, + ).toBe(9); + }); }); From 1f75afb30e51c9baa5f99455e82969caa6d91551 Mon Sep 17 00:00:00 2001 From: Moe Dashti Date: Wed, 28 Oct 2026 00:44:54 -0700 Subject: [PATCH 2/4] feat: decode an unknown proto enum number as a marked fallback case Proto3 enums are open, so a newer server can send a case that an older reader doesn't know. `@nexus.fallback` on one case of a proto-backed enum makes Python, TypeScript and .NET decode such a number as that case. An enum without it keeps its behavior, so Python still raises. --- GUIDE.md | 31 ++++- .../dotnet/tests/StreamServiceChecks.cs | 7 +- .../dotnet/wit/stream-service/Models.cs | 10 +- advanced/samples/inputs/stream-service.wit | 5 + .../fallback-arguments/expected.toml | 5 + .../wit-parser/fallback-arguments/input.wit | 14 +++ .../wit-parser/fallback-on-enum/expected.toml | 5 + .../wit-parser/fallback-on-enum/input.wit | 14 +++ .../fallback-on-record-field/expected.toml | 3 + .../fallback-on-record-field/input.wit | 10 ++ .../fallback-on-two-cases/expected.toml | 9 ++ .../fallback-on-two-cases/input.wit | 15 +++ .../fallback-without-proto/expected.toml | 3 + .../fallback-without-proto/input.wit | 12 ++ .../python/tests/test_stream_service.py | 18 ++- .../python/wit/stream_service/models.py | 48 +++++++- .../typescript/tests/stream-service.test.ts | 8 +- .../typescript/wit/stream-service/models.ts | 49 +++++++- changelog/added/catching-cormorant.md | 1 + src/generator/dotnet.rs | 2 +- src/generator/proto/dotnet.rs | 16 ++- src/generator/proto/python.rs | 12 +- src/generator/proto/typescript.rs | 15 ++- src/generator/python.rs | 20 ++++ src/generator/typescript.rs | 20 ++++ src/parser/wit.rs | 108 +++++++++++++++++- src/spec.rs | 2 + 27 files changed, 426 insertions(+), 36 deletions(-) create mode 100644 advanced/samples/invalid/wit-parser/fallback-arguments/expected.toml create mode 100644 advanced/samples/invalid/wit-parser/fallback-arguments/input.wit create mode 100644 advanced/samples/invalid/wit-parser/fallback-on-enum/expected.toml create mode 100644 advanced/samples/invalid/wit-parser/fallback-on-enum/input.wit create mode 100644 advanced/samples/invalid/wit-parser/fallback-on-record-field/expected.toml create mode 100644 advanced/samples/invalid/wit-parser/fallback-on-record-field/input.wit create mode 100644 advanced/samples/invalid/wit-parser/fallback-on-two-cases/expected.toml create mode 100644 advanced/samples/invalid/wit-parser/fallback-on-two-cases/input.wit create mode 100644 advanced/samples/invalid/wit-parser/fallback-without-proto/expected.toml create mode 100644 advanced/samples/invalid/wit-parser/fallback-without-proto/input.wit create mode 100644 changelog/added/catching-cormorant.md diff --git a/GUIDE.md b/GUIDE.md index 6c1b164be..12894e35c 100644 --- a/GUIDE.md +++ b/GUIDE.md @@ -825,7 +825,9 @@ by the target SDK or generated operation helpers. An enum annotated with `@nexus.proto` likewise remains a generated public enum unless a type replacement selects an external model. Fields use the authored enum's name and members, even when its name differs from the protobuf enum's. -Converters bridge the model and protobuf enum by number. +Converters bridge the model and protobuf enum by number. A case with +[`@nexus.fallback`](#nexusfallback) takes a protobuf number that the enum +doesn't know. ### Transfer-Type Conversion @@ -1791,6 +1793,33 @@ A default cannot be combined with `@nexus.source`. --- +### @nexus.fallback + +**Placement:** Case of an enum with `@nexus.proto` **Syntax:** `@nexus.fallback` + +A protobuf enum is open, so a newer peer can send a number that the generated +enum doesn't know. When a protobuf value converts to the model, such a number +becomes the marked case. An enum has at most one fallback case, and the +directive takes no arguments. + +```wit +/// @nexus.proto "temporal.api.enums.v1.StreamStatus" +enum stream-status { + /// @nexus.fallback + stream-status-unspecified, + stream-status-open, + stream-status-closed, + stream-status-expired, +} +``` + +Python, TypeScript, and .NET decode an unknown number as the case. Without the +directive, Python raises `ValueError`, and TypeScript and .NET keep the number. +Go and Java ignore the directive. nexgen refuses it on an enum without +`@nexus.proto`, and anywhere other than an enum case. + +--- + ### @nexus.experimental **Placement:** Interface, record, variant, or operation (function) **Syntax:** diff --git a/advanced/samples/dotnet/tests/StreamServiceChecks.cs b/advanced/samples/dotnet/tests/StreamServiceChecks.cs index 3f836429f..c17a41144 100644 --- a/advanced/samples/dotnet/tests/StreamServiceChecks.cs +++ b/advanced/samples/dotnet/tests/StreamServiceChecks.cs @@ -75,7 +75,7 @@ public void ReadItemsRoundTripAsTheirRecords() // A newer server can send a Stream status that this code doesn't know. [Fact] - public void UnknownEnumNumberPassesThrough() + public void UnknownEnumNumberDecodesAsTheFallbackCase() { var converter = CreateSdkConverter(typeof(StreamServiceExample.DescribeStreamResponse)); var wire = new WireDescribeStreamResponse @@ -85,10 +85,7 @@ public void UnknownEnumNumberPassesThrough() var decoded = Assert.IsType( converter.FromTransferType(wire)); - Assert.Equal((StreamServiceExample.StreamStatus)9, decoded.Status); - var encoded = Assert.IsType( - converter.ToTransferType(decoded)); - Assert.Equal((Streams::Temporalio.Api.Enums.V1.StreamStatus)9, encoded.Status); + Assert.Equal(StreamServiceExample.StreamStatus.StreamStatusUnspecified, decoded.Status); } } } diff --git a/advanced/samples/dotnet/wit/stream-service/Models.cs b/advanced/samples/dotnet/wit/stream-service/Models.cs index f07253c58..07b3f0603 100644 --- a/advanced/samples/dotnet/wit/stream-service/Models.cs +++ b/advanced/samples/dotnet/wit/stream-service/Models.cs @@ -522,7 +522,7 @@ internal static DescribeStreamResponse FromTransferType(Temporalio.Api.StreamSer { return new DescribeStreamResponse() { - Status = (int)wire.Status == 0 ? null : (StreamStatus)wire.Status, + Status = (int)wire.Status == 0 ? null : (System.Enum.IsDefined(typeof(StreamStatus), (int)wire.Status) ? (StreamStatus)wire.Status : StreamStatus.StreamStatusUnspecified), LastOffset = wire.LastOffset == 0 ? null : wire.LastOffset, Retention = wire.Retention == null ? null : Nexgen.Support.ProtoExtensions.FromDurationProto(wire.Retention), }; @@ -584,7 +584,7 @@ internal static Execution FromTransferType(Temporalio.Api.Common.V1.Execution wi { return new Execution() { - Type = (int)wire.Type == 0 ? null : (ExecutionType)wire.Type, + Type = (int)wire.Type == 0 ? null : (System.Enum.IsDefined(typeof(ExecutionType), (int)wire.Type) ? (ExecutionType)wire.Type : ExecutionType.ExecutionTypeUnspecified), BusinessId = string.IsNullOrEmpty(wire.BusinessId) ? null : wire.BusinessId, RunId = string.IsNullOrEmpty(wire.RunId) ? null : wire.RunId, }; @@ -655,7 +655,7 @@ internal static Producer FromTransferType(Temporalio.Api.Stream.V1.Producer wire { return new Producer(wire.Id) { - Kind = (int)wire.Kind == 0 ? null : (StreamProducerKind)wire.Kind, + Kind = (int)wire.Kind == 0 ? null : (System.Enum.IsDefined(typeof(StreamProducerKind), (int)wire.Kind) ? (StreamProducerKind)wire.Kind : StreamProducerKind.StreamProducerKindUnspecified), Epoch = wire.Epoch == null ? null : ProducerEpoch.FromTransferType(wire.Epoch), RunId = string.IsNullOrEmpty(wire.RunId) ? null : wire.RunId, }; @@ -979,7 +979,7 @@ internal static StreamClose FromTransferType(Temporalio.Api.Stream.V1.StreamClos Producer = wire.Producer == null ? null : Producer.FromTransferType(wire.Producer), Result = wire.Result == null ? null : Nexgen.Support.ProtoExtensions.FromPayload(wire.Result), Failure = wire.Failure == null ? null : Nexgen.Support.ProtoExtensions.FromFailureProto(wire.Failure), - Status = (int)wire.Status == 0 ? null : (StreamCloseStatus)wire.Status, + Status = (int)wire.Status == 0 ? null : (System.Enum.IsDefined(typeof(StreamCloseStatus), (int)wire.Status) ? (StreamCloseStatus)wire.Status : StreamCloseStatus.StreamCloseStatusUnspecified), }; } @@ -1266,7 +1266,7 @@ internal static Supersession FromTransferType(Temporalio.Api.Stream.V1.Supersess { return new Supersession(wire.ProducerId, System.Linq.Enumerable.ToArray(System.Linq.Enumerable.Select(wire.FailedTasks, item => item))) { - ProducerKind = (int)wire.ProducerKind == 0 ? null : (StreamProducerKind)wire.ProducerKind, + ProducerKind = (int)wire.ProducerKind == 0 ? null : (System.Enum.IsDefined(typeof(StreamProducerKind), (int)wire.ProducerKind) ? (StreamProducerKind)wire.ProducerKind : StreamProducerKind.StreamProducerKindUnspecified), Epoch = wire.Epoch == null ? null : ProducerEpoch.FromTransferType(wire.Epoch), PreviousCompletedTask = wire.PreviousCompletedTask == null ? null : WorkflowTaskEpoch.FromTransferType(wire.PreviousCompletedTask), WithdrawRun = string.IsNullOrEmpty(wire.WithdrawRun) ? null : wire.WithdrawRun, diff --git a/advanced/samples/inputs/stream-service.wit b/advanced/samples/inputs/stream-service.wit index 71fa1df89..85ebb41e4 100644 --- a/advanced/samples/inputs/stream-service.wit +++ b/advanced/samples/inputs/stream-service.wit @@ -43,6 +43,7 @@ interface stream-service { /// @nexus.proto "temporal.api.enums.v1.ExecutionType" enum execution-type { + /// @nexus.fallback execution-type-unspecified, execution-type-workflow, execution-type-activity, @@ -61,6 +62,7 @@ interface stream-service { /// @nexus.proto "temporal.api.enums.v1.StreamProducerKind" enum stream-producer-kind { + /// @nexus.fallback stream-producer-kind-unspecified, stream-producer-kind-workflow, stream-producer-kind-local-activity, @@ -140,6 +142,7 @@ interface stream-service { /// @nexus.doc "How a Stream closed. Only the owner's end on the server terminates or times out." /// @nexus.proto "temporal.api.enums.v1.StreamCloseStatus" enum stream-close-status { + /// @nexus.fallback stream-close-status-unspecified, stream-close-status-completed, stream-close-status-failed, @@ -214,6 +217,7 @@ interface stream-service { /// @nexus.proto "temporal.api.enums.v1.StreamStatus" enum stream-status { + /// @nexus.fallback stream-status-unspecified, stream-status-open, stream-status-closed, @@ -422,6 +426,7 @@ interface stream-service { /// typescript-extends="ApplicationFailure" typescript-extends-import="@temporalio/common" /// dotnet-extends="Temporalio.Exceptions.ApplicationFailureException" enum stream-error-reason { + /// @nexus.fallback stream-error-reason-unspecified, /// @nexus.error stream-error-reason-not-found, diff --git a/advanced/samples/invalid/wit-parser/fallback-arguments/expected.toml b/advanced/samples/invalid/wit-parser/fallback-arguments/expected.toml new file mode 100644 index 000000000..d486fbfef --- /dev/null +++ b/advanced/samples/invalid/wit-parser/fallback-arguments/expected.toml @@ -0,0 +1,5 @@ +descriptors = "temporal" + +[error] +kind = "invalid-wit-directive" +contains = ["@nexus.fallback", "case `severity-unspecified`", "does not take arguments"] diff --git a/advanced/samples/invalid/wit-parser/fallback-arguments/input.wit b/advanced/samples/invalid/wit-parser/fallback-arguments/input.wit new file mode 100644 index 000000000..f8c895f16 --- /dev/null +++ b/advanced/samples/invalid/wit-parser/fallback-arguments/input.wit @@ -0,0 +1,14 @@ +package test:fallback-arguments; + +world system { export models; } + +interface models { + /// @nexus.proto "temporal.api.enums.v1.Severity" + enum alert-level { + /// @nexus.fallback "severity-low" + severity-unspecified, + severity-high, + severity-medium, + severity-low, + } +} diff --git a/advanced/samples/invalid/wit-parser/fallback-on-enum/expected.toml b/advanced/samples/invalid/wit-parser/fallback-on-enum/expected.toml new file mode 100644 index 000000000..94e661d29 --- /dev/null +++ b/advanced/samples/invalid/wit-parser/fallback-on-enum/expected.toml @@ -0,0 +1,5 @@ +descriptors = "temporal" + +[error] +kind = "invalid-wit-directive" +contains = ["@nexus.fallback", "models.alert-level", "is only supported on enum cases"] diff --git a/advanced/samples/invalid/wit-parser/fallback-on-enum/input.wit b/advanced/samples/invalid/wit-parser/fallback-on-enum/input.wit new file mode 100644 index 000000000..4eb4a8c64 --- /dev/null +++ b/advanced/samples/invalid/wit-parser/fallback-on-enum/input.wit @@ -0,0 +1,14 @@ +package test:fallback-on-enum; + +world system { export models; } + +interface models { + /// @nexus.fallback + /// @nexus.proto "temporal.api.enums.v1.Severity" + enum alert-level { + severity-unspecified, + severity-high, + severity-medium, + severity-low, + } +} diff --git a/advanced/samples/invalid/wit-parser/fallback-on-record-field/expected.toml b/advanced/samples/invalid/wit-parser/fallback-on-record-field/expected.toml new file mode 100644 index 000000000..755823363 --- /dev/null +++ b/advanced/samples/invalid/wit-parser/fallback-on-record-field/expected.toml @@ -0,0 +1,3 @@ +[error] +kind = "invalid-wit-directive" +contains = ["@nexus.fallback", "field `text`", "is only supported on enum cases"] diff --git a/advanced/samples/invalid/wit-parser/fallback-on-record-field/input.wit b/advanced/samples/invalid/wit-parser/fallback-on-record-field/input.wit new file mode 100644 index 000000000..449f5d1ef --- /dev/null +++ b/advanced/samples/invalid/wit-parser/fallback-on-record-field/input.wit @@ -0,0 +1,10 @@ +package test:fallback-on-record-field; + +world system { export models; } + +interface models { + record value { + /// @nexus.fallback + text: string, + } +} diff --git a/advanced/samples/invalid/wit-parser/fallback-on-two-cases/expected.toml b/advanced/samples/invalid/wit-parser/fallback-on-two-cases/expected.toml new file mode 100644 index 000000000..2e3403082 --- /dev/null +++ b/advanced/samples/invalid/wit-parser/fallback-on-two-cases/expected.toml @@ -0,0 +1,9 @@ +descriptors = "temporal" + +[error] +kind = "invalid-wit-directive" +contains = [ + "@nexus.fallback", + "models.alert-level", + "is on `severity-unspecified` and `severity-medium`, but an enum has one fallback case", +] diff --git a/advanced/samples/invalid/wit-parser/fallback-on-two-cases/input.wit b/advanced/samples/invalid/wit-parser/fallback-on-two-cases/input.wit new file mode 100644 index 000000000..b2b84e0c1 --- /dev/null +++ b/advanced/samples/invalid/wit-parser/fallback-on-two-cases/input.wit @@ -0,0 +1,15 @@ +package test:fallback-on-two-cases; + +world system { export models; } + +interface models { + /// @nexus.proto "temporal.api.enums.v1.Severity" + enum alert-level { + /// @nexus.fallback + severity-unspecified, + severity-high, + /// @nexus.fallback + severity-medium, + severity-low, + } +} diff --git a/advanced/samples/invalid/wit-parser/fallback-without-proto/expected.toml b/advanced/samples/invalid/wit-parser/fallback-without-proto/expected.toml new file mode 100644 index 000000000..46fa27d12 --- /dev/null +++ b/advanced/samples/invalid/wit-parser/fallback-without-proto/expected.toml @@ -0,0 +1,3 @@ +[error] +kind = "invalid-wit-directive" +contains = ["@nexus.fallback", "models.level", "is only supported on an enum with `@nexus.proto`"] diff --git a/advanced/samples/invalid/wit-parser/fallback-without-proto/input.wit b/advanced/samples/invalid/wit-parser/fallback-without-proto/input.wit new file mode 100644 index 000000000..74291ead3 --- /dev/null +++ b/advanced/samples/invalid/wit-parser/fallback-without-proto/input.wit @@ -0,0 +1,12 @@ +package test:fallback-without-proto; + +world system { export models; } + +interface models { + enum level { + /// @nexus.fallback + unknown, + low, + high, + } +} diff --git a/advanced/samples/python/tests/test_stream_service.py b/advanced/samples/python/tests/test_stream_service.py index 0e5c3e380..f16b90081 100644 --- a/advanced/samples/python/tests/test_stream_service.py +++ b/advanced/samples/python/tests/test_stream_service.py @@ -93,12 +93,24 @@ def test_a_request_without_its_stream_is_refused() -> None: _ = converter.from_transfer_type(wire, AppendRecordsRequest) -def test_an_unknown_enum_number_fails_the_message() -> None: +def test_an_unknown_enum_number_decodes_as_the_fallback_case() -> None: converter = models._DescribeStreamResponseTransferTypeConverter() # A newer server can send a Stream status that this code doesn't know. wire = request_response_pb2.DescribeStreamResponse( status=stream_pb2.StreamStatus.ValueType(9) ) - with pytest.raises(ValueError, match="9 is not a valid StreamStatus"): - _ = converter.from_transfer_type(wire, models.DescribeStreamResponse) + decoded = converter.from_transfer_type(wire, models.DescribeStreamResponse) + + assert decoded.status is models.StreamStatus.StreamStatusUnspecified + + +def test_an_unknown_number_of_an_enum_with_no_fallback_fails_the_message() -> None: + converter = models._ReadRecordsRequestTransferTypeConverter() + wire = request_response_pb2.ReadRecordsRequest( + stream=message_pb2.StreamRef(id="s-1"), + start=stream_pb2.StreamReadStart.ValueType(9), + ) + + with pytest.raises(ValueError, match="9 is not a valid StreamReadStart"): + _ = converter.from_transfer_type(wire, models.ReadRecordsRequest) diff --git a/advanced/samples/python/wit/stream_service/models.py b/advanced/samples/python/wit/stream_service/models.py index 4afe7aa2d..f0cf19838 100644 --- a/advanced/samples/python/wit/stream_service/models.py +++ b/advanced/samples/python/wit/stream_service/models.py @@ -27,6 +27,14 @@ class StreamProducerKind(enum.IntEnum): StreamProducerKindStandaloneActivity = 5 +def _stream_producer_kind_from_number(value: int) -> StreamProducerKind: + try: + return StreamProducerKind(value) + except ValueError: + # A newer peer can send a case that this enum doesn't know. + return StreamProducerKind.StreamProducerKindUnspecified + + class ExecutionType(enum.IntEnum): ExecutionTypeUnspecified = 0 ExecutionTypeWorkflow = 1 @@ -34,6 +42,14 @@ class ExecutionType(enum.IntEnum): ExecutionTypeNexusOperation = 3 +def _execution_type_from_number(value: int) -> ExecutionType: + try: + return ExecutionType(value) + except ValueError: + # A newer peer can send a case that this enum doesn't know. + return ExecutionType.ExecutionTypeUnspecified + + class StreamReadStart(enum.IntEnum): StreamReadStartUnspecified = 0 StreamReadStartBeginning = 1 @@ -49,6 +65,14 @@ class StreamCloseStatus(enum.IntEnum): StreamCloseStatusTimedOut = 5 +def _stream_close_status_from_number(value: int) -> StreamCloseStatus: + try: + return StreamCloseStatus(value) + except ValueError: + # A newer peer can send a case that this enum doesn't know. + return StreamCloseStatus.StreamCloseStatusUnspecified + + class StreamStatus(enum.IntEnum): StreamStatusUnspecified = 0 StreamStatusOpen = 1 @@ -56,6 +80,14 @@ class StreamStatus(enum.IntEnum): StreamStatusExpired = 3 +def _stream_status_from_number(value: int) -> StreamStatus: + try: + return StreamStatus(value) + except ValueError: + # A newer peer can send a case that this enum doesn't know. + return StreamStatus.StreamStatusUnspecified + + @dataclasses.dataclass(slots=True) class CreateStreamRequest: """Create a Stream, or get the open one. A closed, expired or another owner's Stream @@ -166,7 +198,9 @@ def from_transfer_type( id = value.id return Producer( id=id, - kind=StreamProducerKind(value.kind) if value.kind != 0 else None, + kind=_stream_producer_kind_from_number(value.kind) + if value.kind != 0 + else None, epoch=_ProducerEpochTransferTypeConverter().from_transfer_type( value.epoch, ProducerEpoch ) @@ -403,7 +437,7 @@ def from_transfer_type( type_hint: type[Execution], ) -> Execution: return Execution( - type=ExecutionType(value.type) if value.type != 0 else None, + type=_execution_type_from_number(value.type) if value.type != 0 else None, business_id=value.business_id if bool(value.business_id) else None, run_id=value.run_id if bool(value.run_id) else None, ) @@ -1090,7 +1124,7 @@ def from_transfer_type( return Supersession( producer_id=producer_id, failed_tasks=list(value.failed_tasks), - producer_kind=StreamProducerKind(value.producer_kind) + producer_kind=_stream_producer_kind_from_number(value.producer_kind) if value.producer_kind != 0 else None, epoch=_ProducerEpochTransferTypeConverter().from_transfer_type( @@ -1239,7 +1273,9 @@ def from_transfer_type( failure=_support.failure_from_proto(value.failure) if value.HasField("failure") else None, - status=StreamCloseStatus(value.status) if value.status != 0 else None, + status=_stream_close_status_from_number(value.status) + if value.status != 0 + else None, ) @typing_extensions.override @@ -1347,7 +1383,9 @@ def from_transfer_type( type_hint: type[DescribeStreamResponse], ) -> DescribeStreamResponse: return DescribeStreamResponse( - status=StreamStatus(value.status) if value.status != 0 else None, + status=_stream_status_from_number(value.status) + if value.status != 0 + else None, last_offset=value.last_offset if value.last_offset != 0 else None, retention=_support.duration_from_proto(value.retention) if value.HasField("retention") diff --git a/advanced/samples/typescript/tests/stream-service.test.ts b/advanced/samples/typescript/tests/stream-service.test.ts index c112e94f5..f201f513d 100644 --- a/advanced/samples/typescript/tests/stream-service.test.ts +++ b/advanced/samples/typescript/tests/stream-service.test.ts @@ -6,6 +6,7 @@ import { describe, expect, test } from "vitest"; import { type ReadRecordsResponse, StreamCloseStatus, + StreamStatus, describeStreamResponseTransferTypeConverter, readRecordsRequestTransferTypeConverter, readRecordsResponseTransferTypeConverter, @@ -88,7 +89,7 @@ describe("stream-service generated output", () => { ).toBe("stream-1"); }); - test("passes an enum number that it doesn't know through", () => { + test("decodes an enum number that it doesn't know as the fallback case", () => { // A newer server can send a Stream status that this code doesn't know. const proto: temporal.api.streamservice.v1.IDescribeStreamResponse = { status: 9 as temporal.api.enums.v1.StreamStatus, @@ -96,9 +97,6 @@ describe("stream-service generated output", () => { const model = describeStreamResponseTransferTypeConverter.fromTransferType(proto); - expect(model.status).toBe(9); - expect( - describeStreamResponseTransferTypeConverter.toTransferType(model).status, - ).toBe(9); + expect(model.status).toBe(StreamStatus.StreamStatusUnspecified); }); }); diff --git a/advanced/samples/typescript/wit/stream-service/models.ts b/advanced/samples/typescript/wit/stream-service/models.ts index 826a3fd50..be0014fe8 100644 --- a/advanced/samples/typescript/wit/stream-service/models.ts +++ b/advanced/samples/typescript/wit/stream-service/models.ts @@ -23,6 +23,14 @@ export enum ExecutionType { ExecutionTypeNexusOperation = 3, } +function executionTypeFromNumber(value: number): ExecutionType { + if (value in ExecutionType) { + return value as ExecutionType; + } + // A newer peer can send a case that this enum doesn't know. + return ExecutionType.ExecutionTypeUnspecified; +} + export enum StreamProducerKind { StreamProducerKindUnspecified = 0, StreamProducerKindWorkflow = 1, @@ -32,6 +40,14 @@ export enum StreamProducerKind { StreamProducerKindStandaloneActivity = 5, } +function streamProducerKindFromNumber(value: number): StreamProducerKind { + if (value in StreamProducerKind) { + return value as StreamProducerKind; + } + // A newer peer can send a case that this enum doesn't know. + return StreamProducerKind.StreamProducerKindUnspecified; +} + export enum StreamCloseStatus { StreamCloseStatusUnspecified = 0, StreamCloseStatusCompleted = 1, @@ -41,6 +57,14 @@ export enum StreamCloseStatus { StreamCloseStatusTimedOut = 5, } +function streamCloseStatusFromNumber(value: number): StreamCloseStatus { + if (value in StreamCloseStatus) { + return value as StreamCloseStatus; + } + // A newer peer can send a case that this enum doesn't know. + return StreamCloseStatus.StreamCloseStatusUnspecified; +} + export enum StreamReadStart { StreamReadStartUnspecified = 0, StreamReadStartBeginning = 1, @@ -54,6 +78,14 @@ export enum StreamStatus { StreamStatusExpired = 3, } +function streamStatusFromNumber(value: number): StreamStatus { + if (value in StreamStatus) { + return value as StreamStatus; + } + // A newer peer can send a case that this enum doesn't know. + return StreamStatus.StreamStatusUnspecified; +} + /** * A read item: a Record, a Superseded item or the close. */ @@ -152,7 +184,8 @@ export interface Execution { function executionFromProto(proto: temporal.api.common.v1.IExecution): Execution { return { - type: proto.type == null ? undefined : (proto.type as number as ExecutionType), + type: + proto.type == null ? undefined : executionTypeFromNumber(proto.type as number), businessId: proto.businessId ?? undefined, runId: proto.runId ?? undefined, }; @@ -260,7 +293,10 @@ export interface Producer { function producerFromProto(proto: temporal.api.stream.v1.IProducer): Producer { return { id: requiredField(proto.id === "" ? undefined : proto.id, "Producer", "id"), - kind: proto.kind == null ? undefined : (proto.kind as number as StreamProducerKind), + kind: + proto.kind == null + ? undefined + : streamProducerKindFromNumber(proto.kind as number), epoch: proto.epoch == null ? undefined : producerEpochFromProto(proto.epoch), runId: proto.runId ?? undefined, }; @@ -775,7 +811,7 @@ function supersessionFromProto( producerKind: proto.producerKind == null ? undefined - : (proto.producerKind as number as StreamProducerKind), + : streamProducerKindFromNumber(proto.producerKind as number), epoch: proto.epoch == null ? undefined : producerEpochFromProto(proto.epoch), previousCompletedTask: proto.previousCompletedTask == null @@ -874,7 +910,9 @@ function streamCloseFromProto(proto: temporal.api.stream.v1.IStreamClose): Strea failure: proto.failure == null ? undefined : support.errorFromFailure(proto.failure), status: - proto.status == null ? undefined : (proto.status as number as StreamCloseStatus), + proto.status == null + ? undefined + : streamCloseStatusFromNumber(proto.status as number), }; } @@ -998,7 +1036,8 @@ function describeStreamResponseFromProto( proto: temporal.api.streamservice.v1.IDescribeStreamResponse, ): DescribeStreamResponse { return { - status: proto.status == null ? undefined : (proto.status as number as StreamStatus), + status: + proto.status == null ? undefined : streamStatusFromNumber(proto.status as number), lastOffset: proto.lastOffset ?? undefined, retention: proto.retention == null ? undefined : support.durationFromProto(proto.retention), diff --git a/changelog/added/catching-cormorant.md b/changelog/added/catching-cormorant.md new file mode 100644 index 000000000..d1fa1391b --- /dev/null +++ b/changelog/added/catching-cormorant.md @@ -0,0 +1 @@ +`@nexus.fallback` on a case of an enum with `@nexus.proto` decodes a protobuf number that the enum doesn't know as that case, in Python, TypeScript and .NET. Without it, Python still raises on such a number. diff --git a/src/generator/dotnet.rs b/src/generator/dotnet.rs index ef39da3b0..f1e70973e 100644 --- a/src/generator/dotnet.rs +++ b/src/generator/dotnet.rs @@ -4359,7 +4359,7 @@ pub(in crate::generator) fn field_property_name(field: &RecordFieldSpec String { +pub(in crate::generator) fn enum_value_name(name: &str) -> String { name.trim_start_matches(|ch: char| ch == '_' || ch.is_ascii_digit()) .trim_start_matches("UNSPECIFIED") .trim_start_matches('_') diff --git a/src/generator/proto/dotnet.rs b/src/generator/proto/dotnet.rs index 1baa3a7eb..5466e2c2d 100644 --- a/src/generator/proto/dotnet.rs +++ b/src/generator/proto/dotnet.rs @@ -3,7 +3,8 @@ use heck::ToLowerCamelCase; use crate::error::{Error, Result}; use crate::generator::dotnet::{ WireValueConversion, csharp_parameter_name, csharp_type_name, csharp_type_parameter_name, - field_property_name, function_args_parameter_type, qualify_dotnet_support_reference, + enum_value_name, field_property_name, function_args_parameter_type, + qualify_dotnet_support_reference, }; use crate::planning::{ PlannedFamily, PlannedProtoMessageType, PlannedProtoType, PlannedProtoTypeInfo, PlannedSpec, @@ -1106,7 +1107,18 @@ fn value_from_wire_expr( PlannedType::Enum(enumeration) if enum_proto(api_plan, &enumeration.full_name).is_some() => { - format!("({}){source_expr}", csharp_type_name(&enumeration.name)) + let model = csharp_type_name(&enumeration.name); + let fallback = api_plan + .enum_decl(&enumeration.full_name) + .and_then(|decl| decl.values.iter().find(|value| value.fallback)); + match fallback { + // A newer peer can send a case that the enum doesn't know. + Some(fallback) => format!( + "(System.Enum.IsDefined(typeof({model}), (int){source_expr}) ? ({model}){source_expr} : {model}.{})", + csharp_type_name(&enum_value_name(&fallback.name)) + ), + None => format!("({model}){source_expr}"), + } } _ => source_expr.to_string(), }; diff --git a/src/generator/proto/python.rs b/src/generator/proto/python.rs index 27411a1d9..319d444c0 100644 --- a/src/generator/proto/python.rs +++ b/src/generator/proto/python.rs @@ -580,13 +580,18 @@ fn generated_enum_field_type(model: &EnumSpec) -> Option) -> Option String { + format!("_{}_from_number", enum_name.to_snake_case()) +} + pub(in crate::generator) fn enum_value(value_type: &PlannedType) -> Option { let PlannedType::External(ExternalTypeSpec::Proto(PlannedProtoType::Enum(enum_type))) = value_type diff --git a/src/generator/proto/typescript.rs b/src/generator/proto/typescript.rs index c365af2cb..477d7af0e 100644 --- a/src/generator/proto/typescript.rs +++ b/src/generator/proto/typescript.rs @@ -841,14 +841,27 @@ pub(crate) fn field_name(field: &FieldDescriptorProto, explicit_name: Option<&st } } +/// The models module function that decodes a proto number as an enum with a fallback case. +pub(crate) fn enum_from_number_function_name(enum_name: &str) -> String { + format!("{}FromNumber", enum_name.to_lower_camel_case()) +} + fn generated_enum_wire_conversion(model: &EnumSpec) -> Option { let source = model.source.as_ref()?.proto_type()?; let PlannedProtoType::Enum(proto) = source else { return None; }; + let from_wire = if model.values.iter().any(|value| value.fallback) { + format!( + "{}({{wire}} as number)", + enum_from_number_function_name(&model.name) + ) + } else { + format!("{{wire}} as number as {}", model.name) + }; Some(WireValueConversion { annotation: model.name.clone(), - from_wire: format!("{{wire}} as number as {}", model.name), + from_wire, to_wire: format!("{{value}} as number as {}", proto.proto.full_name), function_name_to_wire: None, wire_function_names: None, diff --git a/src/generator/python.rs b/src/generator/python.rs index 628a551b0..e9429e27f 100644 --- a/src/generator/python.rs +++ b/src/generator/python.rs @@ -1684,6 +1684,11 @@ impl<'a> ApiPlanner<'a> { number: value.number, }) .collect(), + fallback: enum_spec + .values + .iter() + .find(|value| value.fallback) + .map(|value| value.name.clone()), }); } @@ -2691,6 +2696,8 @@ struct ResourceBoundOperation<'a> { struct RenderedEnum { name: String, values: Vec, + /// The member that an unknown proto number decodes as. + fallback: Option, } #[derive(Debug)] @@ -5362,6 +5369,19 @@ fn render_enum(output: &mut String, enumeration: &RenderedEnum) { output.push_str(&value.number.to_string()); output.push('\n'); } + + if let Some(fallback) = &enumeration.fallback { + let name = &enumeration.name; + output.push_str(&format!( + "\n\ndef {}(value: int) -> {name}:\n", + python_proto::enum_from_number_function_name(name) + )); + output.push_str(" try:\n"); + output.push_str(&format!(" return {name}(value)\n")); + output.push_str(" except ValueError:\n"); + output.push_str(" # A newer peer can send a case that this enum doesn't know.\n"); + output.push_str(&format!(" return {name}.{fallback}\n")); + } } fn render_flags(output: &mut String, flags: &RenderedFlags) { diff --git a/src/generator/typescript.rs b/src/generator/typescript.rs index f6e3d7bda..c9c94cc2e 100644 --- a/src/generator/typescript.rs +++ b/src/generator/typescript.rs @@ -780,6 +780,11 @@ impl<'a> ApiPlanner<'a> { number: value.number, }) .collect(), + fallback: planned_enum + .values + .iter() + .find(|value| value.fallback) + .map(|value| value.name.clone()), }); } @@ -2947,6 +2952,8 @@ struct RenderedOperationInput { struct RenderedEnum { name: String, values: Vec, + /// The member that an unknown proto number decodes as. + fallback: Option, } #[derive(Debug)] @@ -4788,6 +4795,19 @@ fn render_enum(output: &mut String, enumeration: &RenderedEnum) { output.push_str(",\n"); } output.push_str("}\n"); + if let Some(fallback) = &enumeration.fallback { + let name = &enumeration.name; + output.push_str(&format!( + "\nfunction {}(value: number): {name} {{\n", + typescript_proto::enum_from_number_function_name(name) + )); + output.push_str(&format!(" if (value in {name}) {{\n")); + output.push_str(&format!(" return value as {name};\n")); + output.push_str(" }\n"); + output.push_str(" // A newer peer can send a case that this enum doesn't know.\n"); + output.push_str(&format!(" return {name}.{fallback};\n")); + output.push_str("}\n"); + } } fn render_flags(output: &mut String, flags: &RenderedFlags) { diff --git a/src/parser/wit.rs b/src/parser/wit.rs index 893753749..f7c472597 100644 --- a/src/parser/wit.rs +++ b/src/parser/wit.rs @@ -6,8 +6,8 @@ use std::path::{Path, PathBuf}; use heck::{ToSnakeCase, ToUpperCamelCase}; use tempfile::TempDir; use wit_parser_crate::{ - Function, FunctionKind, Handle, Interface, PackageId, PackageSourceMap, Record, Resolve, Type, - TypeDef, TypeDefKind, TypeId, TypeOwner, WorldId, WorldItem, WorldKey, + Enum, Function, FunctionKind, Handle, Interface, PackageId, PackageSourceMap, Record, Resolve, + Type, TypeDef, TypeDefKind, TypeId, TypeOwner, WorldId, WorldItem, WorldKey, }; use crate::error::{Error, Result}; @@ -565,6 +565,7 @@ fn collect_interface_types( for type_id in interface.types.values() { let type_def = &resolve.types[*type_id]; validate_type_parameter_directive(resolve, *type_id, type_def, path)?; + reject_misplaced_fallback_in_type(resolve, *type_id, type_def, path)?; if let Some(record) = build_wit_record_spec(resolve, *type_id, type_def, path, &interface_name, language)? { @@ -723,6 +724,8 @@ fn build_wit_enum_spec( }; let type_name = type_def.name.as_deref().unwrap_or("unnamed-type"); + let context = format!("type `{}`", wit_type_full_name(resolve, type_id)); + let fallback = enum_fallback_case(enumeration, type_def, path, &context)?; Ok(Some(EnumSpec { name: type_name.to_upper_camel_case(), full_name: wit_type_full_name(resolve, type_id), @@ -734,12 +737,108 @@ fn build_wit_enum_spec( wire_name: value.name.clone(), name: value.name.to_upper_camel_case(), number: i32::try_from(index).expect("WIT enum case index should fit in i32"), + fallback: fallback == Some(index), }) .collect(), source: build_type_source(type_def, path, interface_name)?, })) } +/// The index of the case with `@nexus.fallback`, if the enum has one. +fn enum_fallback_case( + enumeration: &Enum, + type_def: &TypeDef, + path: &Path, + context: &str, +) -> Result> { + let mut fallback = None::<(usize, &str)>; + for (index, case) in enumeration.cases.iter().enumerate() { + let case_context = format!("{context} case `{}`", case.name); + let directives = parse_directives(case.docs.contents.as_deref(), path, &case_context)?; + let Some(directive) = directive(&directives, "fallback", path, &case_context)? else { + continue; + }; + if !directive.args.is_empty() { + return Err(fallback_error( + path, + &case_context, + "does not take arguments", + )); + } + if let Some((_, first)) = fallback { + return Err(fallback_error( + path, + context, + &format!( + "is on `{first}` and `{}`, but an enum has one fallback case", + case.name + ), + )); + } + fallback = Some((index, case.name.as_str())); + } + // Only a proto number can be one that the enum doesn't know. + if fallback.is_some() && find_proto_name_for_type_def(type_def, path, context)?.is_none() { + return Err(fallback_error( + path, + context, + "is only supported on an enum with `@nexus.proto`", + )); + } + Ok(fallback.map(|(index, _)| index)) +} + +/// Refuses `@nexus.fallback` on a type, and on the cases of a variant or flags. +fn reject_misplaced_fallback_in_type( + resolve: &Resolve, + type_id: TypeId, + type_def: &TypeDef, + path: &Path, +) -> Result<()> { + let context = format!("type `{}`", wit_type_full_name(resolve, type_id)); + let directives = parse_directives(type_def.docs.contents.as_deref(), path, &context)?; + reject_misplaced_fallback(&directives, path, &context)?; + let cases = match &type_def.kind { + TypeDefKind::Variant(variant) => variant + .cases + .iter() + .map(|case| (case.name.as_str(), case.docs.contents.as_deref())) + .collect(), + TypeDefKind::Flags(flags) => flags + .flags + .iter() + .map(|flag| (flag.name.as_str(), flag.docs.contents.as_deref())) + .collect(), + _ => Vec::new(), + }; + for (name, docs) in cases { + let case_context = format!("{context} case `{name}`"); + let directives = parse_directives(docs, path, &case_context)?; + reject_misplaced_fallback(&directives, path, &case_context)?; + } + Ok(()) +} + +fn reject_misplaced_fallback(directives: &[Directive], path: &Path, context: &str) -> Result<()> { + if directive(directives, "fallback", path, context)?.is_some() { + return Err(fallback_error( + path, + context, + "is only supported on enum cases", + )); + } + Ok(()) +} + +fn fallback_error(path: &Path, context: &str, reason: &str) -> Error { + Error::InvalidWitDirective { + path: path.to_path_buf(), + context: context.to_string(), + directive: "@nexus.fallback".to_string(), + reason: reason.to_string(), + } +} + fn build_wit_flags_spec( resolve: &Resolve, type_id: TypeId, @@ -1112,6 +1211,7 @@ fn build_fields_from_record( let field_context = format!("{context} field `{}`", field.name); let directives = parse_directives(field.docs.contents.as_deref(), path, &field_context)?; reject_misplaced_type_parameter(&directives, path, &field_context)?; + reject_misplaced_fallback(&directives, path, &field_context)?; let generated_field_name = directive(&directives, "name", path, &field_context)? .and_then(|directive| directive_language_value(directive, language)) .unwrap_or(&field.name) @@ -2557,6 +2657,7 @@ fn build_service( let context = format!("interface `{interface_name}`"); let directives = parse_directives(interface.docs.contents.as_deref(), path, &context)?; reject_misplaced_type_parameter(&directives, path, &context)?; + reject_misplaced_fallback(&directives, path, &context)?; let endpoint = directive_value_for_language(&directives, "endpoint", path, &context, language)?; let service_name = interface_name.to_upper_camel_case(); let wire_service_name = build_wire_service_name(&directives, path, &context, &service_name)?; @@ -2696,6 +2797,7 @@ fn build_resource( &constructor_context, )?; reject_misplaced_type_parameter(&directives, path, &constructor_context)?; + reject_misplaced_fallback(&directives, path, &constructor_context)?; } let fields = match constructor { Some(constructor) => constructor @@ -2771,6 +2873,7 @@ fn build_resource_method( ); let directives = parse_directives(function.docs.contents.as_deref(), path, &context)?; reject_misplaced_type_parameter(&directives, path, &context)?; + reject_misplaced_fallback(&directives, path, &context)?; let params = function .params .iter() @@ -2880,6 +2983,7 @@ fn build_operation( let context = format!("{service_context} operation `{operation_name}`"); let directives = parse_directives(function.docs.contents.as_deref(), path, &context)?; reject_misplaced_type_parameter(&directives, path, &context)?; + reject_misplaced_fallback(&directives, path, &context)?; let wire_operation_name = build_wire_operation_name(&directives, path, &context, &operation_name)?; let experimental = experimental_directive(&directives, path, &context)?; diff --git a/src/spec.rs b/src/spec.rs index 4ec5a3d58..e980f0b9a 100644 --- a/src/spec.rs +++ b/src/spec.rs @@ -864,6 +864,8 @@ pub struct EnumValueSpec { pub wire_name: String, pub name: String, pub number: i32, + /// Marked with `@nexus.fallback`, so an unknown proto number decodes as this case. + pub fallback: bool, } #[derive(Debug, Clone, PartialEq)] From 5828c6a50ccbd136ba7cf7e112c210bfa8b94e5c Mon Sep 17 00:00:00 2001 From: Moe Dashti Date: Fri, 30 Oct 2026 19:43:38 -0700 Subject: [PATCH 3/4] Dropped the fallback placement fixtures and asserted full clauses. The generic annotation check will cover placement. The kept fixtures assert the directive clause and the reason, since the input path sits between them. --- .../wit-parser/fallback-arguments/expected.toml | 5 ++++- .../wit-parser/fallback-on-enum/expected.toml | 5 ----- .../invalid/wit-parser/fallback-on-enum/input.wit | 14 -------------- .../fallback-on-record-field/expected.toml | 3 --- .../wit-parser/fallback-on-record-field/input.wit | 10 ---------- .../wit-parser/fallback-on-two-cases/expected.toml | 3 +-- .../fallback-without-proto/expected.toml | 5 ++++- src/parser/wit.rs | 3 +++ 8 files changed, 12 insertions(+), 36 deletions(-) delete mode 100644 advanced/samples/invalid/wit-parser/fallback-on-enum/expected.toml delete mode 100644 advanced/samples/invalid/wit-parser/fallback-on-enum/input.wit delete mode 100644 advanced/samples/invalid/wit-parser/fallback-on-record-field/expected.toml delete mode 100644 advanced/samples/invalid/wit-parser/fallback-on-record-field/input.wit diff --git a/advanced/samples/invalid/wit-parser/fallback-arguments/expected.toml b/advanced/samples/invalid/wit-parser/fallback-arguments/expected.toml index d486fbfef..7bd633f0d 100644 --- a/advanced/samples/invalid/wit-parser/fallback-arguments/expected.toml +++ b/advanced/samples/invalid/wit-parser/fallback-arguments/expected.toml @@ -2,4 +2,7 @@ descriptors = "temporal" [error] kind = "invalid-wit-directive" -contains = ["@nexus.fallback", "case `severity-unspecified`", "does not take arguments"] +contains = [ + "invalid WIT directive `@nexus.fallback` on type `models.alert-level` case `severity-unspecified`", + "does not take arguments", +] diff --git a/advanced/samples/invalid/wit-parser/fallback-on-enum/expected.toml b/advanced/samples/invalid/wit-parser/fallback-on-enum/expected.toml deleted file mode 100644 index 94e661d29..000000000 --- a/advanced/samples/invalid/wit-parser/fallback-on-enum/expected.toml +++ /dev/null @@ -1,5 +0,0 @@ -descriptors = "temporal" - -[error] -kind = "invalid-wit-directive" -contains = ["@nexus.fallback", "models.alert-level", "is only supported on enum cases"] diff --git a/advanced/samples/invalid/wit-parser/fallback-on-enum/input.wit b/advanced/samples/invalid/wit-parser/fallback-on-enum/input.wit deleted file mode 100644 index 4eb4a8c64..000000000 --- a/advanced/samples/invalid/wit-parser/fallback-on-enum/input.wit +++ /dev/null @@ -1,14 +0,0 @@ -package test:fallback-on-enum; - -world system { export models; } - -interface models { - /// @nexus.fallback - /// @nexus.proto "temporal.api.enums.v1.Severity" - enum alert-level { - severity-unspecified, - severity-high, - severity-medium, - severity-low, - } -} diff --git a/advanced/samples/invalid/wit-parser/fallback-on-record-field/expected.toml b/advanced/samples/invalid/wit-parser/fallback-on-record-field/expected.toml deleted file mode 100644 index 755823363..000000000 --- a/advanced/samples/invalid/wit-parser/fallback-on-record-field/expected.toml +++ /dev/null @@ -1,3 +0,0 @@ -[error] -kind = "invalid-wit-directive" -contains = ["@nexus.fallback", "field `text`", "is only supported on enum cases"] diff --git a/advanced/samples/invalid/wit-parser/fallback-on-record-field/input.wit b/advanced/samples/invalid/wit-parser/fallback-on-record-field/input.wit deleted file mode 100644 index 449f5d1ef..000000000 --- a/advanced/samples/invalid/wit-parser/fallback-on-record-field/input.wit +++ /dev/null @@ -1,10 +0,0 @@ -package test:fallback-on-record-field; - -world system { export models; } - -interface models { - record value { - /// @nexus.fallback - text: string, - } -} diff --git a/advanced/samples/invalid/wit-parser/fallback-on-two-cases/expected.toml b/advanced/samples/invalid/wit-parser/fallback-on-two-cases/expected.toml index 2e3403082..8bcb34198 100644 --- a/advanced/samples/invalid/wit-parser/fallback-on-two-cases/expected.toml +++ b/advanced/samples/invalid/wit-parser/fallback-on-two-cases/expected.toml @@ -3,7 +3,6 @@ descriptors = "temporal" [error] kind = "invalid-wit-directive" contains = [ - "@nexus.fallback", - "models.alert-level", + "invalid WIT directive `@nexus.fallback` on type `models.alert-level`", "is on `severity-unspecified` and `severity-medium`, but an enum has one fallback case", ] diff --git a/advanced/samples/invalid/wit-parser/fallback-without-proto/expected.toml b/advanced/samples/invalid/wit-parser/fallback-without-proto/expected.toml index 46fa27d12..cd32a79c5 100644 --- a/advanced/samples/invalid/wit-parser/fallback-without-proto/expected.toml +++ b/advanced/samples/invalid/wit-parser/fallback-without-proto/expected.toml @@ -1,3 +1,6 @@ [error] kind = "invalid-wit-directive" -contains = ["@nexus.fallback", "models.level", "is only supported on an enum with `@nexus.proto`"] +contains = [ + "invalid WIT directive `@nexus.fallback` on type `models.level`", + "is only supported on an enum with `@nexus.proto`", +] diff --git a/src/parser/wit.rs b/src/parser/wit.rs index f7c472597..3b4863f23 100644 --- a/src/parser/wit.rs +++ b/src/parser/wit.rs @@ -789,6 +789,9 @@ fn enum_fallback_case( } /// Refuses `@nexus.fallback` on a type, and on the cases of a variant or flags. +/// +/// A misplaced fallback would have no effect, so the enum would still fail on a case that a newer +/// peer sends. The author learns that at run time, not at generation time. fn reject_misplaced_fallback_in_type( resolve: &Resolve, type_id: TypeId, From 3a6fc3645544730a22d752d773e53b24b81fcfc0 Mon Sep 17 00:00:00 2001 From: Moe Dashti Date: Fri, 30 Oct 2026 19:43:38 -0700 Subject: [PATCH 4/4] Named the Python fallback decoder `__from_proto`. It matches `from_transfer_type`. The fallback applies to decoded values only, so building the enum from an int stays strict. Co-authored-by: Tim Conley --- .../python/wit/stream_service/models.py | 18 +++++++++--------- src/generator/proto/python.rs | 15 +++++++++------ src/generator/python.rs | 2 +- 3 files changed, 19 insertions(+), 16 deletions(-) diff --git a/advanced/samples/python/wit/stream_service/models.py b/advanced/samples/python/wit/stream_service/models.py index f0cf19838..4cd9dd557 100644 --- a/advanced/samples/python/wit/stream_service/models.py +++ b/advanced/samples/python/wit/stream_service/models.py @@ -27,7 +27,7 @@ class StreamProducerKind(enum.IntEnum): StreamProducerKindStandaloneActivity = 5 -def _stream_producer_kind_from_number(value: int) -> StreamProducerKind: +def _stream_producer_kind_from_proto(value: int) -> StreamProducerKind: try: return StreamProducerKind(value) except ValueError: @@ -42,7 +42,7 @@ class ExecutionType(enum.IntEnum): ExecutionTypeNexusOperation = 3 -def _execution_type_from_number(value: int) -> ExecutionType: +def _execution_type_from_proto(value: int) -> ExecutionType: try: return ExecutionType(value) except ValueError: @@ -65,7 +65,7 @@ class StreamCloseStatus(enum.IntEnum): StreamCloseStatusTimedOut = 5 -def _stream_close_status_from_number(value: int) -> StreamCloseStatus: +def _stream_close_status_from_proto(value: int) -> StreamCloseStatus: try: return StreamCloseStatus(value) except ValueError: @@ -80,7 +80,7 @@ class StreamStatus(enum.IntEnum): StreamStatusExpired = 3 -def _stream_status_from_number(value: int) -> StreamStatus: +def _stream_status_from_proto(value: int) -> StreamStatus: try: return StreamStatus(value) except ValueError: @@ -198,7 +198,7 @@ def from_transfer_type( id = value.id return Producer( id=id, - kind=_stream_producer_kind_from_number(value.kind) + kind=_stream_producer_kind_from_proto(value.kind) if value.kind != 0 else None, epoch=_ProducerEpochTransferTypeConverter().from_transfer_type( @@ -437,7 +437,7 @@ def from_transfer_type( type_hint: type[Execution], ) -> Execution: return Execution( - type=_execution_type_from_number(value.type) if value.type != 0 else None, + type=_execution_type_from_proto(value.type) if value.type != 0 else None, business_id=value.business_id if bool(value.business_id) else None, run_id=value.run_id if bool(value.run_id) else None, ) @@ -1124,7 +1124,7 @@ def from_transfer_type( return Supersession( producer_id=producer_id, failed_tasks=list(value.failed_tasks), - producer_kind=_stream_producer_kind_from_number(value.producer_kind) + producer_kind=_stream_producer_kind_from_proto(value.producer_kind) if value.producer_kind != 0 else None, epoch=_ProducerEpochTransferTypeConverter().from_transfer_type( @@ -1273,7 +1273,7 @@ def from_transfer_type( failure=_support.failure_from_proto(value.failure) if value.HasField("failure") else None, - status=_stream_close_status_from_number(value.status) + status=_stream_close_status_from_proto(value.status) if value.status != 0 else None, ) @@ -1383,7 +1383,7 @@ def from_transfer_type( type_hint: type[DescribeStreamResponse], ) -> DescribeStreamResponse: return DescribeStreamResponse( - status=_stream_status_from_number(value.status) + status=_stream_status_from_proto(value.status) if value.status != 0 else None, last_offset=value.last_offset if value.last_offset != 0 else None, diff --git a/src/generator/proto/python.rs b/src/generator/proto/python.rs index 319d444c0..a2a233b7d 100644 --- a/src/generator/proto/python.rs +++ b/src/generator/proto/python.rs @@ -580,8 +580,8 @@ fn generated_enum_field_type(model: &EnumSpec) -> Option) -> Option) -> Option String { - format!("_{}_from_number", enum_name.to_snake_case()) +/// The models module function that decodes a proto value as an enum with a fallback case. +/// +/// The fallback applies to decoded wire values only. Code that builds the enum from an `int` +/// still gets a `ValueError` for a value that the enum doesn't know. +pub(in crate::generator) fn enum_from_proto_function_name(enum_name: &str) -> String { + format!("_{}_from_proto", enum_name.to_snake_case()) } pub(in crate::generator) fn enum_value(value_type: &PlannedType) -> Option { diff --git a/src/generator/python.rs b/src/generator/python.rs index e9429e27f..1e3ac74a3 100644 --- a/src/generator/python.rs +++ b/src/generator/python.rs @@ -5374,7 +5374,7 @@ fn render_enum(output: &mut String, enumeration: &RenderedEnum) { let name = &enumeration.name; output.push_str(&format!( "\n\ndef {}(value: int) -> {name}:\n", - python_proto::enum_from_number_function_name(name) + python_proto::enum_from_proto_function_name(name) )); output.push_str(" try:\n"); output.push_str(&format!(" return {name}(value)\n"));