From 788369da29225b1b615305f751409814431670d4 Mon Sep 17 00:00:00 2001 From: Moe Dashti Date: Wed, 28 Oct 2026 13:22:59 -0700 Subject: [PATCH] feat(python): add a resource factory, and pass a resource as data Each resource gets a factory on the client and in the Workflow package, which builds a handle with no call. A resource with one proto-backed record field passes as that record, so a handle can be an argument or a result. --- GUIDE.md | 55 +- advanced/samples/README.md | 4 +- advanced/samples/inputs/stream-service.wit | 1 + .../factory-name-clash/expected.toml | 6 + .../factory-name-clash/input.wit | 21 + .../flatten/resource-fill/expected.toml | 3 + .../invalid/flatten/resource-fill/input.wit | 17 + .../resource-keep-not-a-bool/expected.toml | 3 + .../resource-keep-not-a-bool/input.wit | 17 + .../flatten/resource-name-clash/expected.toml | 5 + .../flatten/resource-name-clash/input.wit | 18 + .../resource-not-proto-backed/expected.toml | 3 + .../resource-not-proto-backed/input.wit | 17 + .../expected.toml | 5 + .../input.wit | 18 + .../resource-unknown-parameter/expected.toml | 3 + .../resource-unknown-parameter/input.wit | 17 + .../python-workflow-factory/expected.toml | 5 + .../python-workflow-factory/input.wit | 17 + .../python/tests/test_client_target.py | 39 ++ .../python/tests/test_stream_sources.py | 14 + .../wit/poll_service/_resources/feed.py | 2 +- .../samples/python/wit/poll_service/client.py | 14 + .../python/wit/start_workflow/__init__.py | 4 + .../_resources/signal_result.py | 14 +- .../_resources/started_workflow.py | 16 +- .../python/wit/stream_service/__init__.py | 2 + .../wit/stream_service/_resources/stream.py | 73 ++- .../python/wit/stream_service/client.py | 28 + .../python/wit/stream_writer/client.py | 14 + .../python/wit/type_showcase/__init__.py | 2 + .../wit/type_showcase/_resources/user.py | 18 + .../_resources/workflow_handle.py | 2 +- .../python/wit/workflow_handle/client.py | 20 +- changelog/added/factory-falcon.md | 1 + src/generator/python.rs | 518 +++++++++++++++++- src/parser/wit.rs | 139 ++++- src/planning/mod.rs | 2 + src/planning/resource_resolution.rs | 2 + src/planning/selected_validation.rs | 95 +++- src/planning/type_planning.rs | 2 + src/spec.rs | 16 + tests/generate_python.rs | 64 +++ 43 files changed, 1275 insertions(+), 61 deletions(-) create mode 100644 advanced/samples/invalid/client-target/factory-name-clash/expected.toml create mode 100644 advanced/samples/invalid/client-target/factory-name-clash/input.wit create mode 100644 advanced/samples/invalid/flatten/resource-fill/expected.toml create mode 100644 advanced/samples/invalid/flatten/resource-fill/input.wit create mode 100644 advanced/samples/invalid/flatten/resource-keep-not-a-bool/expected.toml create mode 100644 advanced/samples/invalid/flatten/resource-keep-not-a-bool/input.wit create mode 100644 advanced/samples/invalid/flatten/resource-name-clash/expected.toml create mode 100644 advanced/samples/invalid/flatten/resource-name-clash/input.wit create mode 100644 advanced/samples/invalid/flatten/resource-not-proto-backed/expected.toml create mode 100644 advanced/samples/invalid/flatten/resource-not-proto-backed/input.wit create mode 100644 advanced/samples/invalid/flatten/resource-required-field-not-promoted/expected.toml create mode 100644 advanced/samples/invalid/flatten/resource-required-field-not-promoted/input.wit create mode 100644 advanced/samples/invalid/flatten/resource-unknown-parameter/expected.toml create mode 100644 advanced/samples/invalid/flatten/resource-unknown-parameter/input.wit create mode 100644 advanced/samples/invalid/name-collisions/python-workflow-factory/expected.toml create mode 100644 advanced/samples/invalid/name-collisions/python-workflow-factory/input.wit create mode 100644 changelog/added/factory-falcon.md diff --git a/GUIDE.md b/GUIDE.md index 30b44b307..accac9848 100644 --- a/GUIDE.md +++ b/GUIDE.md @@ -22,6 +22,7 @@ of `@nexus` directives. - [Resource Declaration](#resource-declaration) - [Name-Binding: How Resource Methods Find Operations](#name-binding-how-resource-methods-find-operations) - [Resource Return Binding](#resource-return-binding) + - [Resource Factories](#resource-factories) - [Edge Cases and Error Conditions](#edge-cases-and-error-conditions) - [Proto-Backed Models](#proto-backed-models) - [Transfer-Type Conversion](#transfer-type-conversion) @@ -741,6 +742,47 @@ result = await handle # CreateJobResponse return CreateJobResult(job=Job(job_id=result.job_id), created=result.created) ``` +### Resource Factories + +Python gives each resource a factory, which builds a handle on an existing +resource with no call. The client extension has it as a method, and the Workflow +package has it as a function. It takes the definition of a resource with +`@nexus.definition`, or its runtime types, and then the constructor fields by +keyword. The client's handle calls through that client, and a Workflow's handle +calls the operations of the code that runs it. + +`@nexus.flatten-in-api` on the resource makes the factory take some fields of +one constructor parameter's record in place of the parameter. With `keep=true`, +the factory also takes the whole value, and a call gives either the value or the +required promoted fields. + +```wit +/// @nexus.flatten-in-api param="stream" fields="id backend" keep=true +resource %stream { + constructor(%stream: stream-ref, created: option, status: option); +} +``` + +```python +stream = client.stream(Turns, id="chat-1:turns", backend="redis") +same = client.stream(Turns, stream=StreamRef(id="chat-1:turns", backend="redis")) +``` + +The factory has no sibling fields, so it takes no `fill`. It must promote every +required field of the record. The other refusals of a field's flatten apply too. +The factory's name must not be the name of another client method, or of another +function or module of the Workflow package. An optional resource field defaults +to `None`. A dataclass with a required field after an optional one takes its +fields by keyword. + +**A resource as data.** A resource with one required field of a proto-backed +record, and no other required field, gets a transfer converter. Its wire form is +that record, so a handle can be an argument or a result. In Python the parameter +type gives the type arguments of a decoded handle, as in `Stream[Turn]`, and the +handle takes the operations of the code that runs it. TypeScript, .NET and Go +keep their constructors, and .NET passes a handle in the JSON form of its +properties. + ### Edge Cases and Error Conditions #### Same field name in constructor and method parameter @@ -2218,15 +2260,20 @@ The referenced helper must be available from the package specified by ### @nexus.flatten-in-api -**Placement:** Record type (with `@nexus.proto`), or a field whose type is such -a record **Syntax:** `@nexus.flatten-in-api` (no arguments) on a record, +**Placement:** Record type (with `@nexus.proto`), a field whose type is such a +record, or a resource **Syntax:** `@nexus.flatten-in-api` (no arguments) on a +record, `@nexus.flatten-in-api fields=" ..." fill="= ..."` on a -field +field, +`@nexus.flatten-in-api param="" fields=" ..." keep=true` on a +resource Flattens the record's fields into the parent record's generated API surface instead of creating a nested model. On a field, it flattens only the fields that `fields` names, and `fill` copies sibling fields into the record. See -[Flattening some fields of one field](#flattening-some-fields-of-one-field). +[Flattening some fields of one field](#flattening-some-fields-of-one-field). On +a resource, it flattens one constructor parameter in the factory. See +[Resource Factories](#resource-factories). ```wit /// @nexus.proto "temporal.api.sdk.v1.UserMetadata" diff --git a/advanced/samples/README.md b/advanced/samples/README.md index b147d4131..782150ff1 100644 --- a/advanced/samples/README.md +++ b/advanced/samples/README.md @@ -195,7 +195,9 @@ Local Activity. The Stream and the writer are generic in the record body type create of a Stream takes a definition, which is a type of the sample support code, since no SDK has `StreamDefinition` yet. The requests take a source for each target, which the sample support code gives in place of Brook's runtime. -The create takes the backend of the Stream's ref, and the ref copies the id. +The create takes the backend of the Stream's ref, and the ref copies the id. In +Python, the factory `stream` builds a handle from an id or a ref with no call, +and a handle passes as its ref. - WIT: [`inputs/stream-service.wit`](inputs/stream-service.wit), [`inputs/stream-writer.wit`](inputs/stream-writer.wit) diff --git a/advanced/samples/inputs/stream-service.wit b/advanced/samples/inputs/stream-service.wit index 35fb500ea..14d406f84 100644 --- a/advanced/samples/inputs/stream-service.wit +++ b/advanced/samples/inputs/stream-service.wit @@ -378,6 +378,7 @@ interface stream-service { /// @nexus.definition python="temporal_support.StreamDefinition" /// typescript="StreamDefinition" typescript-import="../../support/temporal_model_converters" /// dotnet="Nexgen.Support.StreamDefinition" + /// @nexus.flatten-in-api param="stream" fields="id backend" keep=true resource %stream { constructor(%stream: stream-ref, created: option, status: option); diff --git a/advanced/samples/invalid/client-target/factory-name-clash/expected.toml b/advanced/samples/invalid/client-target/factory-name-clash/expected.toml new file mode 100644 index 000000000..2b8c9d411 --- /dev/null +++ b/advanced/samples/invalid/client-target/factory-name-clash/expected.toml @@ -0,0 +1,6 @@ +languages = ["python"] +targets = ["client"] + +[error] +kind = "client-target-name-conflict" +contains = ["`start_job` is a client method of both"] diff --git a/advanced/samples/invalid/client-target/factory-name-clash/input.wit b/advanced/samples/invalid/client-target/factory-name-clash/input.wit new file mode 100644 index 000000000..9debb0681 --- /dev/null +++ b/advanced/samples/invalid/client-target/factory-name-clash/input.wit @@ -0,0 +1,21 @@ +package test:factory-name-clash@1.0.0; + +world system { export job-service; } + +/// @nexus.endpoint "jobs" +interface job-service { + // The client's factory of `start-job` has the name of the `start_job` of `job`. + resource start-job { + constructor(id: string); + } + + record job-request { + id: string, + } + + record job-response { + id: string, + } + + job: async func(request: job-request) -> job-response; +} diff --git a/advanced/samples/invalid/flatten/resource-fill/expected.toml b/advanced/samples/invalid/flatten/resource-fill/expected.toml new file mode 100644 index 000000000..58b3db818 --- /dev/null +++ b/advanced/samples/invalid/flatten/resource-fill/expected.toml @@ -0,0 +1,3 @@ +[error] +kind = "invalid-wit-directive" +contains = ["on a resource, takes `param`, `fields` and `keep`, not `fill`"] diff --git a/advanced/samples/invalid/flatten/resource-fill/input.wit b/advanced/samples/invalid/flatten/resource-fill/input.wit new file mode 100644 index 000000000..a14578611 --- /dev/null +++ b/advanced/samples/invalid/flatten/resource-fill/input.wit @@ -0,0 +1,17 @@ +package test:resource-fill@1.0.0; + +world system { export history-service; } + +/// @nexus.endpoint "history" +interface history-service { + record workflow-execution { + workflow-id: string, + run-id: option, + } + + // A factory has no sibling fields, so it takes no fill. + /// @nexus.flatten-in-api param="execution" fields="workflow-id" fill="run-id=workflow-id" + resource run { + constructor(execution: workflow-execution); + } +} diff --git a/advanced/samples/invalid/flatten/resource-keep-not-a-bool/expected.toml b/advanced/samples/invalid/flatten/resource-keep-not-a-bool/expected.toml new file mode 100644 index 000000000..12f5e6230 --- /dev/null +++ b/advanced/samples/invalid/flatten/resource-keep-not-a-bool/expected.toml @@ -0,0 +1,3 @@ +[error] +kind = "invalid-wit-directive" +contains = ["`keep=yes` isn't `true` or `false`"] diff --git a/advanced/samples/invalid/flatten/resource-keep-not-a-bool/input.wit b/advanced/samples/invalid/flatten/resource-keep-not-a-bool/input.wit new file mode 100644 index 000000000..625419d53 --- /dev/null +++ b/advanced/samples/invalid/flatten/resource-keep-not-a-bool/input.wit @@ -0,0 +1,17 @@ +package test:resource-keep-not-a-bool@1.0.0; + +world system { export history-service; } + +/// @nexus.endpoint "history" +interface history-service { + record workflow-execution { + workflow-id: string, + run-id: option, + } + + // `keep` is `true` or `false`. + /// @nexus.flatten-in-api param="execution" fields="workflow-id" keep=yes + resource run { + constructor(execution: workflow-execution); + } +} diff --git a/advanced/samples/invalid/flatten/resource-name-clash/expected.toml b/advanced/samples/invalid/flatten/resource-name-clash/expected.toml new file mode 100644 index 000000000..9d6d1bf10 --- /dev/null +++ b/advanced/samples/invalid/flatten/resource-name-clash/expected.toml @@ -0,0 +1,5 @@ +descriptors = "temporal" + +[error] +kind = "invalid-wit" +contains = ["promotes `run_id`, whose name `run-id` is also the name of parameter `run-id`"] diff --git a/advanced/samples/invalid/flatten/resource-name-clash/input.wit b/advanced/samples/invalid/flatten/resource-name-clash/input.wit new file mode 100644 index 000000000..356d65ad9 --- /dev/null +++ b/advanced/samples/invalid/flatten/resource-name-clash/input.wit @@ -0,0 +1,18 @@ +package temporal:nexus@1.0.0; + +world system { export history-service; } + +/// @nexus.endpoint "history" +interface history-service { + /// @nexus.proto "temporal.api.common.v1.WorkflowExecution" + record workflow-execution { + workflow-id: string, + run-id: option, + } + + // The promoted `run-id` has the name of another parameter. + /// @nexus.flatten-in-api param="execution" fields="workflow-id run-id" + resource run { + constructor(execution: workflow-execution, run-id: option); + } +} diff --git a/advanced/samples/invalid/flatten/resource-not-proto-backed/expected.toml b/advanced/samples/invalid/flatten/resource-not-proto-backed/expected.toml new file mode 100644 index 000000000..cfd3ab6fe --- /dev/null +++ b/advanced/samples/invalid/flatten/resource-not-proto-backed/expected.toml @@ -0,0 +1,3 @@ +[error] +kind = "invalid-wit" +contains = ["resource `run` parameter `execution` flattens fields", "isn't a proto-backed record"] diff --git a/advanced/samples/invalid/flatten/resource-not-proto-backed/input.wit b/advanced/samples/invalid/flatten/resource-not-proto-backed/input.wit new file mode 100644 index 000000000..abe4bda1d --- /dev/null +++ b/advanced/samples/invalid/flatten/resource-not-proto-backed/input.wit @@ -0,0 +1,17 @@ +package test:resource-not-proto-backed@1.0.0; + +world system { export history-service; } + +/// @nexus.endpoint "history" +interface history-service { + record workflow-execution { + workflow-id: string, + run-id: option, + } + + // The factory can't flatten a record that no proto backs. + /// @nexus.flatten-in-api param="execution" fields="workflow-id" + resource run { + constructor(execution: workflow-execution); + } +} diff --git a/advanced/samples/invalid/flatten/resource-required-field-not-promoted/expected.toml b/advanced/samples/invalid/flatten/resource-required-field-not-promoted/expected.toml new file mode 100644 index 000000000..0b903672d --- /dev/null +++ b/advanced/samples/invalid/flatten/resource-required-field-not-promoted/expected.toml @@ -0,0 +1,5 @@ +descriptors = "temporal" + +[error] +kind = "invalid-wit" +contains = ["doesn't promote its required field `workflow_id`"] diff --git a/advanced/samples/invalid/flatten/resource-required-field-not-promoted/input.wit b/advanced/samples/invalid/flatten/resource-required-field-not-promoted/input.wit new file mode 100644 index 000000000..2c970dbdb --- /dev/null +++ b/advanced/samples/invalid/flatten/resource-required-field-not-promoted/input.wit @@ -0,0 +1,18 @@ +package temporal:nexus@1.0.0; + +world system { export history-service; } + +/// @nexus.endpoint "history" +interface history-service { + /// @nexus.proto "temporal.api.common.v1.WorkflowExecution" + record workflow-execution { + workflow-id: string, + run-id: option, + } + + // The factory has no fill, so it must take the required `workflow-id`. + /// @nexus.flatten-in-api param="execution" fields="run-id" + resource run { + constructor(execution: workflow-execution); + } +} diff --git a/advanced/samples/invalid/flatten/resource-unknown-parameter/expected.toml b/advanced/samples/invalid/flatten/resource-unknown-parameter/expected.toml new file mode 100644 index 000000000..f1e855922 --- /dev/null +++ b/advanced/samples/invalid/flatten/resource-unknown-parameter/expected.toml @@ -0,0 +1,3 @@ +[error] +kind = "invalid-wit-directive" +contains = ["`ref` isn't a parameter of the constructor"] diff --git a/advanced/samples/invalid/flatten/resource-unknown-parameter/input.wit b/advanced/samples/invalid/flatten/resource-unknown-parameter/input.wit new file mode 100644 index 000000000..13858ecb4 --- /dev/null +++ b/advanced/samples/invalid/flatten/resource-unknown-parameter/input.wit @@ -0,0 +1,17 @@ +package test:resource-unknown-parameter@1.0.0; + +world system { export history-service; } + +/// @nexus.endpoint "history" +interface history-service { + record workflow-execution { + workflow-id: string, + run-id: option, + } + + // `ref` isn't a parameter of the constructor. + /// @nexus.flatten-in-api param="ref" fields="workflow-id" + resource run { + constructor(execution: workflow-execution); + } +} diff --git a/advanced/samples/invalid/name-collisions/python-workflow-factory/expected.toml b/advanced/samples/invalid/name-collisions/python-workflow-factory/expected.toml new file mode 100644 index 000000000..a642b6167 --- /dev/null +++ b/advanced/samples/invalid/name-collisions/python-workflow-factory/expected.toml @@ -0,0 +1,5 @@ +languages = ["python"] + +[error] +kind = "python-generated-name-conflict" +contains = ["the factory of resource `Models`", "the module `models` of the package"] diff --git a/advanced/samples/invalid/name-collisions/python-workflow-factory/input.wit b/advanced/samples/invalid/name-collisions/python-workflow-factory/input.wit new file mode 100644 index 000000000..bec60dcb0 --- /dev/null +++ b/advanced/samples/invalid/name-collisions/python-workflow-factory/input.wit @@ -0,0 +1,17 @@ +package test:python-workflow-factory@1.0.0; + +world system { export job-service; } + +/// @nexus.endpoint "jobs" +interface job-service { + // The Workflow factory of `models` has the name of the module of the models. + resource models { + constructor(id: string); + } + + record job-request { + id: string, + } + + job: func(request: job-request); +} diff --git a/advanced/samples/python/tests/test_client_target.py b/advanced/samples/python/tests/test_client_target.py index aef36e183..a73a88803 100644 --- a/advanced/samples/python/tests/test_client_target.py +++ b/advanced/samples/python/tests/test_client_target.py @@ -203,6 +203,45 @@ async def test_a_create_takes_the_ref_fields_as_parameters() -> None: assert (wire.ref.id, wire.ref.backend) == ("s1", "redis") +async def test_a_factory_builds_a_handle_with_no_call() -> None: + client = FakeClient({"DescribeStream": [DescribeStreamResponse(last_offset=5)]}) + + by_id = client.stream(Turns, id="s1", backend="redis") + by_ref = client.stream(Turns, stream=StreamRef(id="s2")) + + _ = assert_type(by_id, Stream[Turn]) + assert by_id == Stream(body_type=Turn, stream=StreamRef(id="s1", backend="redis")) + assert by_id.body_type is Turn + assert by_ref.stream == StreamRef(id="s2") + assert client.calls == [] + # The handle calls through the client that built it. + _ = await by_id.describe() + assert [call.operation for call in client.calls] == ["DescribeStream"] + + +def test_a_factory_takes_the_ref_fields_or_the_ref() -> None: + client = FakeClient({}) + + with pytest.raises(TypeError, match="pass id or stream"): + _ = client.stream(Turns) + with pytest.raises(TypeError, match="not both"): + _ = client.stream(Turns, id="s1", stream=StreamRef(id="s1")) + + +async def test_a_handle_passes_as_its_ref() -> None: + converter = temporalio.converter.DataConverter.default + stream = Stream(body_type=Turn, stream=StreamRef(id="s1"), created=True) + + (payload,) = await converter.encode([stream]) + (decoded,) = await converter.decode([payload], [Stream[Turn]]) + + # The wire form is the ref, and the parameter type gives the body type. Only a create's + # handle has `created`. + assert payload.metadata["messageType"] == b"temporal.api.stream.v1.StreamRef" + assert decoded == Stream(body_type=Turn, stream=StreamRef(id="s1")) + assert decoded.body_type is Turn + + async def test_a_client_call_returns_the_result() -> None: client = FakeClient({"DescribeStream": [DescribeStreamResponse(last_offset=5)]}) diff --git a/advanced/samples/python/tests/test_stream_sources.py b/advanced/samples/python/tests/test_stream_sources.py index db812d070..f76332278 100644 --- a/advanced/samples/python/tests/test_stream_sources.py +++ b/advanced/samples/python/tests/test_stream_sources.py @@ -9,6 +9,9 @@ import temporalio.workflow import temporal_support +from typing_extensions import assert_type +import wit.stream_service as stream_service +from wit.stream_service._resources import Stream from wit.stream_service.models import ( CreateStreamRequest, CreateStreamResult, @@ -71,3 +74,14 @@ def create_nexus_client(**_: object) -> FakeNexusClient: ) ] assert stream.body_type is str + + +def test_a_workflow_factory_builds_a_handle_on_the_run_operations() -> None: + handle = stream_service.stream(Turns, id="chat-1:r1:turns", backend="redis") + + _ = assert_type(handle, Stream[str]) + assert handle == Stream( + body_type=str, stream=StreamRef(id="chat-1:r1:turns", backend="redis") + ) + # A handle with no operations of its own calls those of the code that runs it. + assert handle._operations is None diff --git a/advanced/samples/python/wit/poll_service/_resources/feed.py b/advanced/samples/python/wit/poll_service/_resources/feed.py index 303a105f3..2d3d8449f 100644 --- a/advanced/samples/python/wit/poll_service/_resources/feed.py +++ b/advanced/samples/python/wit/poll_service/_resources/feed.py @@ -19,7 +19,7 @@ class Feed: """The Workflows of one query.""" namespace: str - query: str | None + query: str | None = None _operations: _FeedServiceOperations | None = dataclasses.field( default=None, repr=False, compare=False, kw_only=True ) diff --git a/advanced/samples/python/wit/poll_service/client.py b/advanced/samples/python/wit/poll_service/client.py index 3c5a16d78..e2495cdb5 100644 --- a/advanced/samples/python/wit/poll_service/client.py +++ b/advanced/samples/python/wit/poll_service/client.py @@ -159,6 +159,7 @@ def __init_subclass__(cls, **kwargs: typing.Any) -> None: if FeedServiceClientExtension in base.__mro__: continue for name in ( + "feed", "open_feed", "list_feed", ): @@ -290,3 +291,16 @@ async def iter_list_feed( if next_request is None: return request = next_request + + def feed( + self, + *, + namespace: str, + query: str | None = None, + ) -> Feed: + """A handle on an existing ``Feed``, with no call.""" + return Feed( + namespace=namespace, + query=query, + _operations=self, + ) diff --git a/advanced/samples/python/wit/start_workflow/__init__.py b/advanced/samples/python/wit/start_workflow/__init__.py index 2a5feae14..710c69740 100644 --- a/advanced/samples/python/wit/start_workflow/__init__.py +++ b/advanced/samples/python/wit/start_workflow/__init__.py @@ -12,12 +12,16 @@ from ._resources.started_workflow import restart_workflow from ._resources.started_workflow import cancel_workflow from .operations.signal_with_start import signal_with_start +from ._resources.started_workflow import started_workflow +from ._resources.signal_result import signal_result __all__ = [ "cancel_workflow", "restart_workflow", + "signal_result", "signal_with_start", "start_workflow", + "started_workflow", ] diff --git a/advanced/samples/python/wit/start_workflow/_resources/signal_result.py b/advanced/samples/python/wit/start_workflow/_resources/signal_result.py index 1b39000b0..d4bef2e0c 100644 --- a/advanced/samples/python/wit/start_workflow/_resources/signal_result.py +++ b/advanced/samples/python/wit/start_workflow/_resources/signal_result.py @@ -8,6 +8,18 @@ @dataclasses.dataclass class SignalResult: namespace: str - started: bool | None + started: bool | None = None pass + + +def signal_result( + *, + namespace: str, + started: bool | None = None, +) -> SignalResult: + """A handle on an existing ``SignalResult``, with no call.""" + return SignalResult( + namespace=namespace, + started=started, + ) diff --git a/advanced/samples/python/wit/start_workflow/_resources/started_workflow.py b/advanced/samples/python/wit/start_workflow/_resources/started_workflow.py index 5e45fc529..7f90a4df5 100644 --- a/advanced/samples/python/wit/start_workflow/_resources/started_workflow.py +++ b/advanced/samples/python/wit/start_workflow/_resources/started_workflow.py @@ -19,7 +19,7 @@ class StartedWorkflow: namespace: str workflow_id: str - run_id: str | None + run_id: str | None = None async def cancel( self, @@ -52,6 +52,20 @@ async def get_result( raise NotImplementedError("started-workflow.get_result is not yet implemented") +def started_workflow( + *, + namespace: str, + workflow_id: str, + run_id: str | None = None, +) -> StartedWorkflow: + """A handle on an existing ``StartedWorkflow``, with no call.""" + return StartedWorkflow( + namespace=namespace, + workflow_id=workflow_id, + run_id=run_id, + ) + + async def _cancel_workflow( request: CancelWorkflowRequest, ) -> temporalio.workflow.NexusOperationHandle[CancelWorkflowResponse,]: diff --git a/advanced/samples/python/wit/stream_service/__init__.py b/advanced/samples/python/wit/stream_service/__init__.py index a1ed1a074..0b08abe71 100644 --- a/advanced/samples/python/wit/stream_service/__init__.py +++ b/advanced/samples/python/wit/stream_service/__init__.py @@ -13,6 +13,7 @@ from ._resources.stream import close_stream from ._resources.stream import read_records from ._resources.stream import describe_stream +from ._resources.stream import stream __all__ = [ "append_records", @@ -20,6 +21,7 @@ "create_stream", "describe_stream", "read_records", + "stream", ] diff --git a/advanced/samples/python/wit/stream_service/_resources/stream.py b/advanced/samples/python/wit/stream_service/_resources/stream.py index 469c1ce43..a2622a6b3 100644 --- a/advanced/samples/python/wit/stream_service/_resources/stream.py +++ b/advanced/samples/python/wit/stream_service/_resources/stream.py @@ -5,7 +5,10 @@ import collections.abc import dataclasses import typing +import typing_extensions import datetime +import streamprotos.temporal.api.stream.v1.message_pb2 +import temporalio.converter import temporalio.workflow from ..models import ( @@ -21,9 +24,13 @@ StreamReadStart, StreamRef, StreamStatus, + _StreamRefTransferTypeConverter, ) import temporal_support as _support +if typing.TYPE_CHECKING: + import temporal_support + BodyT = typing.TypeVar("BodyT") @@ -34,8 +41,8 @@ class Stream(typing.Generic[BodyT]): body_type: type[BodyT] = dataclasses.field(repr=False, compare=False) stream: StreamRef - created: bool | None - status: StreamStatus | None + created: bool | None = None + status: StreamStatus | None = None _operations: _StreamServiceOperations | None = dataclasses.field( default=None, repr=False, compare=False, kw_only=True ) @@ -155,6 +162,68 @@ async def describe( ) +def stream( + definition: type[temporal_support.StreamDefinition[BodyT]], + *, + id: str | None = None, + stream: StreamRef | None = None, + backend: str | None = None, + created: bool | None = None, + status: StreamStatus | None = None, +) -> Stream[BodyT]: + """A handle on an existing ``Stream``, with no call. Pass ``id`` or ``stream``.""" + (body_type,) = _support.definition_type_arguments(definition) + if stream is None: + if id is None: + raise TypeError("pass id or stream") + stream = StreamRef(id=id, backend=backend) + elif id is not None or backend is not None: + raise TypeError("pass id and backend, or stream, not both") + return Stream( + body_type=body_type, + stream=stream, + created=created, + status=status, + ) + + +class _StreamTransferTypeConverter( + temporalio.converter.TransferTypeConverter[ + Stream[BodyT], streamprotos.temporal.api.stream.v1.message_pb2.StreamRef + ] +): + """Passes a ``Stream`` as its ``stream``. A decoded handle takes the operations of the + code that runs it. + """ + + transfer_type: ( + type[streamprotos.temporal.api.stream.v1.message_pb2.StreamRef] | None + ) = streamprotos.temporal.api.stream.v1.message_pb2.StreamRef + + @typing_extensions.override + def from_transfer_type( + self, + value: streamprotos.temporal.api.stream.v1.message_pb2.StreamRef, + type_hint: type[Stream[BodyT]], + ) -> Stream[BodyT]: + (body_type,) = typing.get_args(type_hint) or (typing.Any,) + return Stream( + body_type=typing.cast(type[BodyT], body_type), + stream=_StreamRefTransferTypeConverter().from_transfer_type( + value, StreamRef + ), + ) + + @typing_extensions.override + def to_transfer_type( + self, value: Stream[BodyT] + ) -> streamprotos.temporal.api.stream.v1.message_pb2.StreamRef: + return _StreamRefTransferTypeConverter().to_transfer_type(value.stream) + + +temporalio.converter.transfer_type_convertible(_StreamTransferTypeConverter)(Stream) # pyright: ignore[reportUnusedCallResult] + + class _StreamServiceOperations(typing.Protocol): """The operations of ``StreamService`` that ``Stream`` calls.""" diff --git a/advanced/samples/python/wit/stream_service/client.py b/advanced/samples/python/wit/stream_service/client.py index 03ac11785..24361b33a 100644 --- a/advanced/samples/python/wit/stream_service/client.py +++ b/advanced/samples/python/wit/stream_service/client.py @@ -22,6 +22,7 @@ ReadRecordsResponse, StreamReadStart, StreamRef, + StreamStatus, ) from ._resources import Stream import temporal_support as _support @@ -72,6 +73,7 @@ def __init_subclass__(cls, **kwargs: typing.Any) -> None: if StreamServiceClientExtension in base.__mro__: continue for name in ( + "stream", "create_stream", "append_records", "close_stream", @@ -392,3 +394,29 @@ async def describe_stream( return await self._stream_service_describe_stream( request, rpc_metadata=rpc_metadata, rpc_timeout=rpc_timeout ) + + def stream( + self, + definition: type[temporal_support.StreamDefinition[BodyT]], + *, + id: str | None = None, + stream: StreamRef | None = None, + backend: str | None = None, + created: bool | None = None, + status: StreamStatus | None = None, + ) -> Stream[BodyT]: + """A handle on an existing ``Stream``, with no call. Pass ``id`` or ``stream``.""" + (body_type,) = _support.definition_type_arguments(definition) + if stream is None: + if id is None: + raise TypeError("pass id or stream") + stream = StreamRef(id=id, backend=backend) + elif id is not None or backend is not None: + raise TypeError("pass id and backend, or stream, not both") + return Stream( + body_type=body_type, + stream=stream, + created=created, + status=status, + _operations=self, + ) diff --git a/advanced/samples/python/wit/stream_writer/client.py b/advanced/samples/python/wit/stream_writer/client.py index b007197e1..adaf9343d 100644 --- a/advanced/samples/python/wit/stream_writer/client.py +++ b/advanced/samples/python/wit/stream_writer/client.py @@ -68,6 +68,7 @@ def __init_subclass__(cls, **kwargs: typing.Any) -> None: if StreamWriterClientExtension in base.__mro__: continue for name in ( + "writer", "open_writer", "write_records", "close_writer", @@ -293,3 +294,16 @@ async def close_writers( return await self._stream_writer_close_writers( request, rpc_metadata=rpc_metadata, rpc_timeout=rpc_timeout ) + + def writer( + self, + body_type: type[BodyT], + *, + writer_id: int, + ) -> Writer[BodyT]: + """A handle on an existing ``Writer``, with no call.""" + return Writer( + body_type=body_type, + writer_id=writer_id, + _operations=self, + ) diff --git a/advanced/samples/python/wit/type_showcase/__init__.py b/advanced/samples/python/wit/type_showcase/__init__.py index 0c4a6fd1f..35bd53aaf 100644 --- a/advanced/samples/python/wit/type_showcase/__init__.py +++ b/advanced/samples/python/wit/type_showcase/__init__.py @@ -14,6 +14,7 @@ from .operations.set_profile import set_profile from .operations.record_sync import record_sync from ._resources.user import deactivate +from ._resources.user import user __all__ = [ "deactivate", @@ -22,6 +23,7 @@ "rename", "set_profile", "update_email", + "user", ] diff --git a/advanced/samples/python/wit/type_showcase/_resources/user.py b/advanced/samples/python/wit/type_showcase/_resources/user.py index c71aa69b0..8789206fd 100644 --- a/advanced/samples/python/wit/type_showcase/_resources/user.py +++ b/advanced/samples/python/wit/type_showcase/_resources/user.py @@ -57,6 +57,24 @@ async def deactivate( await handle +def user( + *, + user_id: str, + email: str, + display_name: str, + status: UserStatus, + profile: UserProfile, +) -> User: + """A handle on an existing ``User``, with no call.""" + return User( + user_id=user_id, + email=email, + display_name=display_name, + status=status, + profile=profile, + ) + + async def _update_email( request: UpdateEmailRequest, ) -> User: diff --git a/advanced/samples/python/wit/workflow_handle/_resources/workflow_handle.py b/advanced/samples/python/wit/workflow_handle/_resources/workflow_handle.py index c00e220db..0a6b1ebca 100644 --- a/advanced/samples/python/wit/workflow_handle/_resources/workflow_handle.py +++ b/advanced/samples/python/wit/workflow_handle/_resources/workflow_handle.py @@ -9,7 +9,7 @@ class WorkflowHandle: namespace: str workflow_id: str - run_id: str | None + run_id: str | None = None _operations: object | None = dataclasses.field( default=None, repr=False, compare=False, kw_only=True ) diff --git a/advanced/samples/python/wit/workflow_handle/client.py b/advanced/samples/python/wit/workflow_handle/client.py index c990b4975..2468bb9ab 100644 --- a/advanced/samples/python/wit/workflow_handle/client.py +++ b/advanced/samples/python/wit/workflow_handle/client.py @@ -49,7 +49,10 @@ def __init_subclass__(cls, **kwargs: typing.Any) -> None: for base in cls.__mro__: if WorkflowHandleServiceClientExtension in base.__mro__: continue - for name in ("start_workflow",): + for name in ( + "workflow_handle", + "start_workflow", + ): if name in base.__dict__: raise TypeError( f"{cls.__qualname__} gets {name} from {base.__qualname__} and from WorkflowHandleServiceClientExtension" @@ -119,3 +122,18 @@ async def start_workflow( return await self._workflow_handle_service_start_workflow( request, rpc_metadata=rpc_metadata, rpc_timeout=rpc_timeout ) + + def workflow_handle( + self, + *, + namespace: str, + workflow_id: str, + run_id: str | None = None, + ) -> WorkflowHandle: + """A handle on an existing ``WorkflowHandle``, with no call.""" + return WorkflowHandle( + namespace=namespace, + workflow_id=workflow_id, + run_id=run_id, + _operations=self, + ) diff --git a/changelog/added/factory-falcon.md b/changelog/added/factory-falcon.md new file mode 100644 index 000000000..fcf832131 --- /dev/null +++ b/changelog/added/factory-falcon.md @@ -0,0 +1 @@ +Python gives each resource a factory, on the client and in the Workflow package, which builds a handle with no call. `@nexus.flatten-in-api param="..." fields="..." keep=true` on a resource makes the factory take fields of one constructor parameter. An optional resource field defaults to `None`, and a resource with one proto-backed record field passes as that record. diff --git a/src/generator/python.rs b/src/generator/python.rs index 55eba9b68..95385bc18 100644 --- a/src/generator/python.rs +++ b/src/generator/python.rs @@ -639,6 +639,9 @@ impl<'a> ApiPlanner<'a> { ); let mut package_model_names = package_model_names.into_iter().collect::>(); sort_python_model_names(&mut package_model_names, &self.variants, model_fragments); + if mode == GenerationMode::NativeApi && targets.workflow { + validate_workflow_factory_names(services)?; + } let render_init = |root_package_imports: &RootPackageImports| { if mode == GenerationMode::NativeApi { render_package_init( @@ -849,6 +852,9 @@ impl<'a> ApiPlanner<'a> { for service in services { body.push_str("\n\n"); render_client_extension(&mut body, service); + for resource in &service.resources { + self.render_resource_factory(&mut body, resource, true); + } } let module_imports = operations @@ -895,7 +901,7 @@ impl<'a> ApiPlanner<'a> { render_definition_type_checking_imports( &mut output, &body, - services.iter().flat_map(|service| &service.operations), + services.iter().flat_map(service_definitions), ); output.push_str("\n\n"); output.push_str(&body); @@ -957,6 +963,23 @@ impl<'a> ApiPlanner<'a> { } let targets = crate::nexgen_config::current().targets; self.render_resource(&mut body, service, resource, bound_operations, targets); + let native = crate::nexgen_config::current().mode == GenerationMode::NativeApi; + if native && targets.workflow && service.endpoint.is_some() { + body.push('\n'); + self.render_resource_factory(&mut body, resource, false); + } + let data_field = self.resource_data_field(resource); + if let Some((field, model_name, proto)) = &data_field { + self.render_resource_transfer_converter( + &mut body, + resource, + field, + model_name, + &proto.type_ref, + ); + module_imports.insert(proto.module_path.clone()); + module_imports.insert("temporalio.converter".to_string()); + } for (full_name, (_, resource_type)) in resource_holders(self.api_plan) { if resource_type != resource.type_name { continue; @@ -1036,14 +1059,26 @@ impl<'a> ApiPlanner<'a> { } let used_model_names = used_python_symbol_imports(&body, model_names); if !used_model_names.is_empty() { - render_model_name_imports( - &mut output, + let mut groups = model_name_import_groups( "..models", &self.api_plan.module_path.child("_resources"), self.api_plan, &used_model_names, self.model_hoists, ); + // The converter of a resource that is data uses the converter of its field's model. + if let Some((_, model_name, _)) = &data_field + && let Some(names) = groups.values_mut().find(|names| names.contains(model_name)) + { + names.insert(format!("_{model_name}TransferTypeConverter")); + } + for (module, names) in groups { + render_named_python_import( + &mut output, + &module, + &names.into_iter().collect::>(), + ); + } } let own_names = resource_holders(self.api_plan) .into_values() @@ -1059,6 +1094,7 @@ impl<'a> ApiPlanner<'a> { render_named_python_import(&mut output, ".", &used_resource_names); } render_support_import(&mut output, &body, support_package, false); + render_definition_type_checking_imports(&mut output, &body, resource.definition.value()); if !body.is_empty() { output.push('\n'); output.push('\n'); @@ -1075,7 +1111,17 @@ impl<'a> ApiPlanner<'a> { bound_operations: &[ResourceBoundOperation<'_>], targets: GenerationTargets, ) { - output.push_str("@dataclasses.dataclass\n"); + // A dataclass field with no default can't follow one with a default. + let kw_only = resource + .fields + .iter() + .skip_while(|field| !field.optional) + .any(|field| !field.optional); + output.push_str(if kw_only { + "@dataclasses.dataclass(kw_only=True)\n" + } else { + "@dataclasses.dataclass\n" + }); output.push_str("class "); output.push_str(&resource.type_name); if !resource.type_parameters.is_empty() { @@ -1102,6 +1148,9 @@ impl<'a> ApiPlanner<'a> { field.optional, field.function.as_ref(), )); + if field.optional { + output.push_str(" = None"); + } output.push('\n'); } // With the client target, one class serves both targets, and each target supplies the @@ -1130,6 +1179,325 @@ impl<'a> ApiPlanner<'a> { } } + /// Renders the factory of a resource, which builds a handle with no call. The client's factory + /// binds the handle to the client, and a Workflow's handle takes the current operations. + fn render_resource_factory( + &self, + output: &mut String, + resource: &PlannedResource, + client: bool, + ) { + let indent = if client { " " } else { "" }; + let type_variables = resource_type_variable_names(resource); + let result = if type_variables.is_empty() { + resource.type_name.clone() + } else { + format!("{}[{}]", resource.type_name, type_variables.join(", ")) + }; + output.push_str(&format!( + "\n{indent}def {}(\n", + resource_factory_name(resource) + )); + if client { + output.push_str(&format!("{indent} self,\n")); + } + let definition = resource.definition.value(); + if let Some(definition) = definition { + output.push_str(&format!( + "{indent} definition: type[{definition}[{}]],\n", + type_variables.join(", ") + )); + } else { + for name in &type_variables { + output.push_str(&format!( + "{indent} {}: type[{name}],\n", + python_runtime_type_argument_name(name) + )); + } + } + if !resource.fields.is_empty() { + output.push_str(&format!("{indent} *,\n")); + } + let flatten = self.resource_factory_flatten(resource); + for field in &resource.fields { + let name = python_field_name(&field.name); + let annotation = self.python_resource_field_annotation( + &field.kind, + field.optional, + field.function.as_ref(), + ); + let Some(flatten) = flatten + .as_ref() + .filter(|flatten| flatten.param == field.name) + else { + let default = if field.optional { " = None" } else { "" }; + output.push_str(&format!("{indent} {name}: {annotation}{default},\n")); + continue; + }; + // With `keep`, the call takes either the whole value or the promoted fields. + let promoted = |required: bool| { + flatten + .fields + .iter() + .filter(move |(_, field_required, _)| *field_required == required) + }; + for (attr_name, _, annotation) in promoted(true) { + if flatten.keep { + output.push_str(&format!( + "{indent} {attr_name}: {annotation} | None = None,\n" + )); + } else { + output.push_str(&format!("{indent} {attr_name}: {annotation},\n")); + } + } + if flatten.keep { + let annotation = annotation.strip_suffix(" | None").unwrap_or(&annotation); + output.push_str(&format!( + "{indent} {name}: {annotation} | None = None,\n" + )); + } + for (attr_name, _, annotation) in promoted(false) { + output.push_str(&format!( + "{indent} {attr_name}: {annotation} | None = None,\n" + )); + } + } + output.push_str(&format!("{indent}) -> {result}:\n")); + let body_indent = format!("{indent} "); + let summary = match flatten.as_ref().filter(|flatten| flatten.keep) { + Some(flatten) => format!( + "A handle on an existing ``{}``, with no call. Pass {} ``{}``.", + resource.type_name, + flatten.required_names("``"), + python_field_name(&flatten.param) + ), + None => format!( + "A handle on an existing ``{}``, with no call.", + resource.type_name + ), + }; + render_python_docstring(output, &body_indent, Some(&summary), &[], None, false); + if definition.is_some() { + output.push_str(&body_indent); + output.push('('); + for name in &type_variables { + output.push_str(&python_runtime_type_argument_name(name)); + output.push(','); + } + output.push_str(") = _support.definition_type_arguments(definition)\n"); + } + if let Some(flatten) = &flatten { + let param = python_field_name(&flatten.param); + let value = format!( + "{}({})", + flatten.model_name, + flatten + .fields + .iter() + .map(|(attr_name, _, _)| format!("{attr_name}={attr_name}")) + .collect::>() + .join(", ") + ); + if flatten.keep { + let missing = flatten + .fields + .iter() + .filter(|(_, required, _)| *required) + .map(|(attr_name, _, _)| format!("{attr_name} is None")) + .collect::>(); + let given = flatten + .fields + .iter() + .map(|(attr_name, _, _)| format!("{attr_name} is not None")) + .collect::>(); + output.push_str(&format!("{body_indent}if {param} is None:\n")); + if !missing.is_empty() { + output.push_str(&format!( + "{body_indent} if {}:\n{body_indent} raise TypeError(\"pass {} {param}\")\n", + missing.join(" or "), + flatten.required_names("") + )); + } + output.push_str(&format!("{body_indent} {param} = {value}\n")); + output.push_str(&format!( + "{body_indent}elif {}:\n{body_indent} raise TypeError(\"pass {}, or {param}, not both\")\n", + given.join(" or "), + flatten + .fields + .iter() + .map(|(attr_name, _, _)| attr_name.as_str()) + .collect::>() + .join(" and ") + )); + } else { + output.push_str(&format!("{body_indent}{param} = {value}\n")); + } + } + output.push_str(&format!("{body_indent}return {}(\n", resource.type_name)); + for name in &type_variables { + let argument = python_runtime_type_argument_name(name); + output.push_str(&format!("{body_indent} {argument}={argument},\n")); + } + for field in &resource.fields { + let name = python_field_name(&field.name); + output.push_str(&format!("{body_indent} {name}={name},\n")); + } + if client { + output.push_str(&format!("{body_indent} _operations=self,\n")); + } + output.push_str(&format!("{body_indent})\n")); + } + + /// The constructor field that a resource passes as data, with the field's model and proto + /// reference. A resource with one required field of a proto-backed record, and no other + /// required field, is data. + fn resource_data_field<'r>( + &self, + resource: &'r PlannedResource, + ) -> Option<( + &'r PlannedResourceField, + String, + python_proto::PythonReference, + )> { + let mut records = resource.fields.iter().filter_map(|field| { + let PlannedType::Record(record) = &field.kind else { + return None; + }; + let planned = self.api_plan.record(&record.full_name)?; + let proto = planned.data.proto.as_ref()?; + self.api_plan + .record_type_parameters(&record.full_name) + .is_empty() + .then(|| python_proto::message_python_ref(proto)) + .flatten() + .map(|proto| (field, self.models[&record.full_name].name.clone(), proto)) + }); + let data = records.next().filter(|_| records.next().is_none())?; + let others_optional = resource + .fields + .iter() + .all(|field| field.optional || field.name == data.0.name); + (!data.0.optional && others_optional).then_some(data) + } + + /// Renders the transfer converter of a resource that is data. Its wire form is the record of + /// its data field, so a handle can be an argument or a result. + fn render_resource_transfer_converter( + &self, + output: &mut String, + resource: &PlannedResource, + field: &PlannedResourceField, + model_name: &str, + proto: &str, + ) { + let type_variables = resource_type_variable_names(resource); + let annotation = if type_variables.is_empty() { + resource.type_name.clone() + } else { + format!("{}[{}]", resource.type_name, type_variables.join(", ")) + }; + let converter = format!("_{}TransferTypeConverter", resource.type_name); + let field_name = python_field_name(&field.name); + output.push_str(&format!( + "\n\nclass {converter}(\n temporalio.converter.TransferTypeConverter[{annotation}, {proto}]\n):\n" + )); + let summary = format!( + "Passes a ``{}`` as its ``{field_name}``. A decoded handle takes the operations of \ + the code that runs it.", + resource.type_name + ); + render_python_docstring(output, " ", Some(&summary), &[], None, false); + output.push_str(&format!( + "\n transfer_type: type[{proto}] | None = {proto}\n" + )); + output.push_str("\n @typing_extensions.override\n def from_transfer_type(\n"); + output.push_str(&format!( + " self,\n value: {proto},\n type_hint: type[{annotation}],\n ) -> {annotation}:\n" + )); + if !type_variables.is_empty() { + let arguments = type_variables + .iter() + .map(|name| format!("{},", python_runtime_type_argument_name(name))) + .collect::(); + let defaults = type_variables + .iter() + .map(|_| "typing.Any,") + .collect::(); + output.push_str(&format!( + " ({arguments}) = typing.get_args(type_hint) or ({defaults})\n" + )); + } + output.push_str(&format!(" return {}(\n", resource.type_name)); + for name in &type_variables { + let argument = python_runtime_type_argument_name(name); + output.push_str(&format!( + " {argument}=typing.cast(type[{name}], {argument}),\n" + )); + } + output.push_str(&format!( + " {field_name}=_{model_name}TransferTypeConverter().from_transfer_type(\n value, {model_name}\n ),\n )\n" + )); + output.push_str(&format!( + "\n @typing_extensions.override\n def to_transfer_type(self, value: {annotation}) -> {proto}:\n" + )); + output.push_str(&format!( + " return _{model_name}TransferTypeConverter().to_transfer_type(value.{field_name})\n" + )); + output.push_str(&format!( + "\n\ntemporalio.converter.transfer_type_convertible({converter})({}) # pyright: ignore[reportUnusedCallResult]\n", + resource.type_name + )); + } + + /// The fields that a resource's factory takes in place of its flattened constructor parameter. + fn resource_factory_flatten( + &self, + resource: &PlannedResource, + ) -> Option { + let flatten = resource.flatten.as_ref()?; + let param = resource + .fields + .iter() + .find(|field| field.name == flatten.param)?; + let kind = match ¶m.kind { + PlannedType::Option(inner) => inner.as_ref(), + kind => kind, + }; + let PlannedType::Record(record) = kind else { + return None; + }; + let planned = self.api_plan.record(&record.full_name)?; + let rendered = self.models.get(&record.full_name)?; + let fields = planned + .fields + .iter() + .filter(|(_, field)| field.visibility != RecordFieldVisibility::Omitted) + .zip(&rendered.fields) + .collect::>(); + Some(ResourceFactoryFlatten { + param: flatten.param.clone(), + keep: flatten.keep, + model_name: rendered.name.clone(), + fields: flatten + .fields + .iter() + .filter_map(|key| { + let ((_, planned), rendered) = + fields.iter().find(|((name, _), _)| *name == key)?; + let annotation = rendered + .annotation + .strip_suffix(" | None") + .unwrap_or(&rendered.annotation); + Some(( + rendered.attr_name.clone(), + planned.required, + annotation.to_string(), + )) + }) + .collect(), + }) + } + fn render_resource_class_method( &self, output: &mut String, @@ -3348,6 +3716,33 @@ pub(in crate::generator) enum PythonFieldDefaultKind { Expression(String), } +/// The promoted fields of a resource's flattened constructor parameter, each with its attribute +/// name, whether its record requires it, and its annotation with no `None`. +struct ResourceFactoryFlatten { + param: String, + keep: bool, + model_name: String, + fields: Vec<(String, bool, String)>, +} + +impl ResourceFactoryFlatten { + /// The names of the required promoted fields with the word before the whole value, as in + /// "id or" and "id and key, or". + fn required_names(&self, quote: &str) -> String { + let names = self + .fields + .iter() + .filter(|(_, required, _)| *required) + .map(|(attr_name, _, _)| format!("{quote}{attr_name}{quote}")) + .collect::>(); + if names.len() > 1 { + format!("{}, or", names.join(" and ")) + } else { + format!("{} or", names.join("")) + } + } +} + #[derive(Debug)] struct RenderedUnpackedInput { model_name: String, @@ -4118,6 +4513,11 @@ fn resource_type_variable_names(resource: &PlannedResource) -> Vec { .collect() } +/// The name of a resource's factory, on the client and in the Workflow package. +fn resource_factory_name(resource: &PlannedResource) -> String { + python_field_name(&resource.name.to_snake_case()) +} + fn resource_module_name(resource: &PlannedResource) -> String { python_ident(&resource.name.to_snake_case()) } @@ -5044,7 +5444,7 @@ fn render_service_module( &endpoint_client_body, endpoint_services .iter() - .flat_map(|service| &service.operations), + .flat_map(|service| service_definitions(service)), ); if needs_temporalio_workflow_type_checking { output.push_str("if typing.TYPE_CHECKING:\n"); @@ -5191,23 +5591,40 @@ fn render_operation_module( render_named_python_import(&mut output, ".._resources", &used_resource_names); } render_support_import(&mut output, &body, support_package, false); - render_definition_type_checking_imports(&mut output, &body, [operation]); + render_definition_type_checking_imports( + &mut output, + &body, + operation.runtime_type_definition.as_deref(), + ); output.push('\n'); output.push('\n'); output.push_str(&body); output } +/// The definition types of a service's operations and resources. +fn service_definitions<'a>(service: &'a RenderedService<'a>) -> impl Iterator { + service + .operations + .iter() + .filter_map(|operation| operation.runtime_type_definition.as_deref()) + .chain( + service + .resources + .iter() + .filter_map(|resource| resource.definition.value()), + ) +} + /// Imports the module of each definition type that the body names, for type checking only. The /// SDK module that defines the type can import the generated package. fn render_definition_type_checking_imports<'a>( output: &mut String, body: &str, - operations: impl IntoIterator>, + definitions: impl IntoIterator, ) { - let modules = operations + let modules = definitions .into_iter() - .filter_map(|operation| operation.runtime_type_definition.as_deref()) .filter(|definition| body.contains(&format!("{definition}["))) .filter_map(|definition| definition.rsplit_once('.').map(|(module, _)| module)) .collect::>(); @@ -5595,7 +6012,13 @@ fn render_package_init( let operation_function_names = services_with_functions .iter() .filter(|service| service.endpoint.is_some()) - .flat_map(|service| service.operations.iter().flat_map(workflow_function_names)) + .flat_map(|service| { + service + .operations + .iter() + .flat_map(workflow_function_names) + .chain(service.resources.iter().map(resource_factory_name)) + }) .collect::>() .into_iter() .collect::>(); @@ -5649,6 +6072,15 @@ fn render_package_init( ); } } + if service.endpoint.is_some() { + for resource in &service.resources { + render_named_python_import( + &mut output, + &format!("._resources.{}", resource_module_name(resource)), + &[resource_factory_name(resource)], + ); + } + } } } render_support_import( @@ -6028,22 +6460,66 @@ fn client_extension_class_name(service: &RenderedService<'_>) -> String { format!("{}ClientExtension", service.name) } +/// The package exports the Workflow factory of each resource next to the operation functions and +/// the modules of the package. +fn validate_workflow_factory_names(services: &[RenderedService<'_>]) -> Result<()> { + let mut names = ["client", "models", "operations", "services"] + .map(|module| { + ( + module.to_string(), + format!("the module `{module}` of the package"), + ) + }) + .into_iter() + .collect::>(); + for service in services.iter().filter(|service| service.endpoint.is_some()) { + for operation in &service.operations { + for name in workflow_function_names(operation) { + names.insert( + name, + format!( + "the function of operation `{}.{}`", + service.name, operation.name + ), + ); + } + } + } + for service in services.iter().filter(|service| service.endpoint.is_some()) { + for resource in &service.resources { + let name = resource_factory_name(resource); + let generated_by = format!("the factory of resource `{}`", resource.type_name); + if let Some(conflicting_declaration) = names.insert(name.clone(), generated_by.clone()) + { + return Err(Error::PythonGeneratedNameConflict { + name, + generated_by, + conflicting_declaration, + }); + } + } + } + Ok(()) +} + /// Each public method of the package's client extensions belongs to one service, so one client /// class can inherit them all. fn validate_client_names(services: &[RenderedService<'_>]) -> Result<()> { let mut owners = BTreeMap::::new(); for service in services { - for operation in &service.operations { + let factory_names = service.resources.iter().map(resource_factory_name); + let operation_names = service.operations.iter().flat_map(|operation| { let start_name = operation_has_start_method(operation) .then(|| start_function_name(&operation.attr_name)); - for name in std::iter::once(operation.attr_name.clone()).chain(start_name) { - if let Some(first_service) = owners.insert(name.clone(), service.name) { - return Err(Error::ClientTargetNameConflict { - name, - first_service: first_service.to_string(), - second_service: service.name.to_string(), - }); - } + std::iter::once(operation.attr_name.clone()).chain(start_name) + }); + for name in factory_names.chain(operation_names) { + if let Some(first_service) = owners.insert(name.clone(), service.name) { + return Err(Error::ClientTargetNameConflict { + name, + first_service: first_service.to_string(), + second_service: service.name.to_string(), + }); } } } @@ -6186,6 +6662,10 @@ fn render_client_extension(output: &mut String, service: &RenderedService<'_>) { output.push_str(" in base.__mro__:\n"); output.push_str(" continue\n"); output.push_str(" for name in ("); + for resource in &service.resources { + output.push_str(&python_string_literal(&resource_factory_name(resource))); + output.push_str(", "); + } for operation in &service.operations { output.push_str(&python_string_literal(&operation.attr_name)); output.push_str(", "); diff --git a/src/parser/wit.rs b/src/parser/wit.rs index 9a3b68c4d..918ece183 100644 --- a/src/parser/wit.rs +++ b/src/parser/wit.rs @@ -1886,24 +1886,10 @@ fn build_field_flatten( .to_string(), )); }; - let mut ty = *ty; - let record = loop { - let Type::Id(id) = ty else { - return Err(invalid("the field's type isn't a record".to_string())); - }; - match &resolve.types[id].kind { - TypeDefKind::Option(inner) | TypeDefKind::Type(inner) => ty = *inner, - TypeDefKind::Record(record) => break record, - _ => return Err(invalid("the field's type isn't a record".to_string())), - } - }; - let key = |record: &Record, name: &str, owner: &str| -> Result { - let field = record - .fields - .iter() - .find(|field| field.name == name) - .ok_or_else(|| invalid(format!("`{name}` isn't a field of the {owner}")))?; - record_field_key(field, path, context) + let record = flattened_record(resolve, *ty) + .ok_or_else(|| invalid("the field's type isn't a record".to_string()))?; + let key = |record: &Record, name: &str, owner: &str| { + flattened_field_key(record, name, owner, path, context) }; let fields = fields .split_whitespace() @@ -1928,6 +1914,97 @@ fn build_field_flatten( Ok(Some(FieldFlattenSpec { fields, fills })) } +/// A resource's `@nexus.flatten-in-api`. It names a constructor parameter, and fields of that +/// parameter's record by WIT name. +fn build_resource_flatten( + resolve: &Resolve, + constructor: Option<&Function>, + directives: &[Directive], + path: &Path, + context: &str, +) -> Result> { + let Some(flatten) = directive(directives, "flatten-in-api", path, context)? else { + return Ok(None); + }; + let invalid = |reason: String| Error::InvalidWitDirective { + path: path.to_path_buf(), + context: context.to_string(), + directive: "@nexus.flatten-in-api".to_string(), + reason, + }; + if let Some(key) = flatten + .args + .keys() + .find(|key| !["param", "fields", "keep"].contains(&key.as_str())) + { + return Err(invalid(format!( + "on a resource, takes `param`, `fields` and `keep`, not `{key}`" + ))); + } + let (Some(param), Some(fields)) = (flatten.value("param"), flatten.value("fields")) else { + return Err(invalid( + "on a resource, names a constructor parameter and fields of its record, such as \ + `param=\"stream\" fields=\"id\"`" + .to_string(), + )); + }; + let keep = match flatten.value("keep") { + None | Some("false") => false, + Some("true") => true, + Some(other) => return Err(invalid(format!("`keep={other}` isn't `true` or `false`"))), + }; + let ty = constructor + .and_then(|constructor| constructor.params.iter().find(|item| item.name == param)) + .map(|item| item.ty) + .ok_or_else(|| invalid(format!("`{param}` isn't a parameter of the constructor")))?; + let record = flattened_record(resolve, ty) + .ok_or_else(|| invalid(format!("the type of `{param}` isn't a record")))?; + let fields = fields + .split_whitespace() + .map(|name| flattened_field_key(record, name, "parameter's record", path, context)) + .collect::>>()?; + Ok(Some(ResourceFlattenSpec { + param: param.to_string(), + fields, + keep, + })) +} + +/// The record of a flattened field or parameter, through `option` and type aliases. +fn flattened_record(resolve: &Resolve, mut ty: Type) -> Option<&Record> { + loop { + let Type::Id(id) = ty else { + return None; + }; + match &resolve.types[id].kind { + TypeDefKind::Option(inner) | TypeDefKind::Type(inner) => ty = *inner, + TypeDefKind::Record(record) => return Some(record), + _ => return None, + } + } +} + +/// The key of the field of a flattened record that a flatten names by WIT name. +fn flattened_field_key( + record: &Record, + name: &str, + owner: &str, + path: &Path, + context: &str, +) -> Result { + let field = record + .fields + .iter() + .find(|field| field.name == name) + .ok_or_else(|| Error::InvalidWitDirective { + path: path.to_path_buf(), + context: context.to_string(), + directive: "@nexus.flatten-in-api".to_string(), + reason: format!("`{name}` isn't a field of the {owner}"), + })?; + record_field_key(field, path, context) +} + /// The `@nexus.source` calls of one field. A source with no target fills the field in every /// target, as the model's default. #[derive(Default)] @@ -3083,6 +3160,9 @@ fn build_resource( None => Vec::new(), }; + let flatten = + build_resource_flatten(resolve, constructor, &resource_directives, path, &context)?; + let methods = interface .functions .values() @@ -3116,6 +3196,7 @@ fn build_resource( type_parameters, definition, fields, + flatten, methods, data: (), }) @@ -4371,6 +4452,28 @@ interface generic-service { assert_eq!(record.fields["identity"].flatten, None); } + #[test] + fn a_resource_flatten_names_its_parameter_and_fields() { + let spec = crate::parser::load_api_spec_from_wit_for_language_with_inputs( + Language::Python, + &[ + root().join("advanced/samples/inputs/stream-service.wit"), + linked_inputs_path(), + ], + ) + .unwrap(); + let resource = &spec.services[0].resources[0]; + + assert_eq!( + resource.flatten, + Some(crate::spec::ResourceFlattenSpec { + param: "stream".to_string(), + fields: vec!["id".to_string(), "backend".to_string()], + keep: true, + }) + ); + } + #[test] fn parses_enum_field_defaults() { let wit = r#" diff --git a/src/planning/mod.rs b/src/planning/mod.rs index 9201bb91d..a74dc05df 100644 --- a/src/planning/mod.rs +++ b/src/planning/mod.rs @@ -157,6 +157,8 @@ pub(crate) struct PlannedResource { /// The SDK type that gives the type arguments, with its import. pub(crate) definition: SelectedTextSpec, pub(crate) fields: Vec, + /// The constructor parameter whose fields the factory takes, if any. + pub(crate) flatten: Option, pub(crate) methods: Vec, } diff --git a/src/planning/resource_resolution.rs b/src/planning/resource_resolution.rs index 2ec00c35a..63502c662 100644 --- a/src/planning/resource_resolution.rs +++ b/src/planning/resource_resolution.rs @@ -97,6 +97,7 @@ pub(crate) struct ResolvedResourceSpec { pub(crate) type_parameters: Vec, pub(crate) definition: SelectedTextSpec, pub(crate) fields: Vec>, + pub(crate) flatten: Option, pub(crate) methods: Vec, } @@ -331,6 +332,7 @@ fn resolve_resource_methods( type_parameters: resource.type_parameters.clone(), definition: resource.definition.clone(), fields: resource.fields.clone(), + flatten: resource.flatten.clone(), methods, }) } diff --git a/src/planning/selected_validation.rs b/src/planning/selected_validation.rs index 3e8b31732..ac8ef1026 100644 --- a/src/planning/selected_validation.rs +++ b/src/planning/selected_validation.rs @@ -9,7 +9,7 @@ use crate::error::{Error, Result}; use crate::spec::{ ApiSpec, ApiSpecLeaf, CompilerPass, ExternalTypeSpec, FieldFlattenSpec, FunctionArgsSpec, FunctionResultSpec, OperationSpec, RecordFieldSpec, RecordFieldVisibility, RecordSpec, - ResourceSpec, SelectedFamily, ServiceSpec, TargetFieldRole, TypeSpec, + ResourceFlattenSpec, ResourceSpec, SelectedFamily, ServiceSpec, TargetFieldRole, TypeSpec, }; pub(crate) struct SelectedValidationPass; @@ -138,6 +138,11 @@ fn validate_generic_model_semantics(spec: &ApiSpec, path: &Path) } } } + for resource in spec.services.iter().flat_map(|service| &service.resources) { + if let Some(flatten) = &resource.flatten { + validate_resource_flatten(spec, resource, flatten, &invalid)?; + } + } // A record that holds a resource builds it from the record's message, with no type argument. let generic_resources = spec @@ -163,18 +168,15 @@ fn validate_generic_model_semantics(spec: &ApiSpec, path: &Path) Ok(()) } -/// A field-level flatten promotes fields of a proto-backed record, and the call must be able to -/// give every required field of that record. -fn validate_field_flatten( - spec: &ApiSpec, - parent: &RecordSpec, - field_name: &str, - field: &RecordFieldSpec, - flatten: &FieldFlattenSpec, +/// The proto-backed record whose fields a flatten promotes. A record that flattens all of its +/// fields already can't flatten some of them. +fn flattened_proto_record<'a>( + spec: &'a ApiSpec, + field_type: &TypeSpec, + context: &str, invalid: &dyn Fn(String) -> Error, -) -> Result<()> { - let context = format!("record `{}` field `{field_name}`", parent.full_name); - let nested = match field.field_type.without_option() { +) -> Result<&'a RecordSpec> { + let nested = match field_type.without_option() { TypeSpec::Record(record) => spec.record(record.as_str()), TypeSpec::External(ExternalTypeSpec::Proto(proto)) => spec.record_for_proto(proto.as_str()), _ => None, @@ -190,6 +192,75 @@ fn validate_field_flatten( nested.full_name ))); } + Ok(nested) +} + +/// A resource's flatten promotes fields of one constructor parameter's record in the factory. The +/// factory has no fills, so it must promote every required field of that record. +fn validate_resource_flatten( + spec: &ApiSpec, + resource: &ResourceSpec, + flatten: &ResourceFlattenSpec, + invalid: &dyn Fn(String) -> Error, +) -> Result<()> { + let context = format!("resource `{}` parameter `{}`", resource.name, flatten.param); + let Some(param) = resource + .fields + .iter() + .find(|field| field.name == flatten.param) + else { + return Err(invalid(format!( + "{context} isn't a parameter of the constructor" + ))); + }; + let nested = flattened_proto_record(spec, ¶m.field_type, &context, invalid)?; + for key in &flatten.fields { + let Some(promoted) = nested + .fields + .get(key) + .filter(|field| field.visibility != RecordFieldVisibility::Omitted) + else { + return Err(invalid(format!( + "{context} promotes `{key}`, which `{}` omits", + nested.full_name + ))); + }; + if let Some(other) = resource + .fields + .iter() + .find(|field| field.name != flatten.param && field.name == promoted.name) + { + return Err(invalid(format!( + "{context} promotes `{key}`, whose name `{}` is also the name of parameter `{}`", + promoted.name, other.name + ))); + } + } + if let Some((key, _)) = nested.fields.iter().find(|(key, field)| { + field.required + && field.visibility != RecordFieldVisibility::Omitted + && !flatten.fields.contains(key) + }) { + return Err(invalid(format!( + "{context} flattens fields of `{}`, but doesn't promote its required field `{key}`", + nested.full_name + ))); + } + Ok(()) +} + +/// A field-level flatten promotes fields of a proto-backed record, and the call must be able to +/// give every required field of that record. +fn validate_field_flatten( + spec: &ApiSpec, + parent: &RecordSpec, + field_name: &str, + field: &RecordFieldSpec, + flatten: &FieldFlattenSpec, + invalid: &dyn Fn(String) -> Error, +) -> Result<()> { + let context = format!("record `{}` field `{field_name}`", parent.full_name); + let nested = flattened_proto_record(spec, &field.field_type, &context, invalid)?; if field.api_hidden() { return Err(invalid(format!( "{context} flattens fields, so it can't have a source or an api-omit too" diff --git a/src/planning/type_planning.rs b/src/planning/type_planning.rs index 90d239b20..1f11ecc87 100644 --- a/src/planning/type_planning.rs +++ b/src/planning/type_planning.rs @@ -457,6 +457,7 @@ impl ApiSpecTransform for TypePlanningMap type_parameters: Vec::new(), definition: SelectedTextSpec::default(), fields: Vec::new(), + flatten: None, methods: Vec::new(), }) } @@ -923,6 +924,7 @@ impl<'a> TypePlanningContext<'a> { .iter() .map(|field| self.planned_resource_field(field)) .collect(), + flatten: resource.flatten.clone(), methods, }) } diff --git a/src/spec.rs b/src/spec.rs index 036443068..a33c1c7e4 100644 --- a/src/spec.rs +++ b/src/spec.rs @@ -730,10 +730,25 @@ pub struct ResourceSpec { /// An operation that returns the resource takes it in place of runtime types. pub definition: F::Text, pub fields: Vec>, + /// Set when `@nexus.flatten-in-api` on the resource makes the factory take fields of one + /// constructor parameter. + pub flatten: Option, pub methods: Vec>, pub data: F::ResourceData, } +/// A resource's `@nexus.flatten-in-api`. The factory takes the named fields of the record of one +/// constructor parameter in place of the parameter. Names of the record's fields are field keys. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ResourceFlattenSpec { + /// The constructor parameter, by WIT name. + pub param: String, + /// The fields of the parameter's record that become parameters, in order. + pub fields: Vec, + /// Whether the factory also takes the whole value, as the other choice. + pub keep: bool, +} + #[derive(Debug, Clone, PartialEq)] pub struct ResourceFieldSpec { pub name: String, @@ -773,6 +788,7 @@ impl ResourceSpec { .into_iter() .map(|field| field.map_names_with(map)) .collect(), + flatten: self.flatten, methods: self .methods .into_iter() diff --git a/tests/generate_python.rs b/tests/generate_python.rs index 3762ce2cf..5e051f75f 100644 --- a/tests/generate_python.rs +++ b/tests/generate_python.rs @@ -327,6 +327,70 @@ interface models { fs::remove_dir_all(temp_dir).unwrap(); } +#[test] +fn python_resource_with_a_required_field_after_an_optional_one_is_keyword_only() { + let temp_dir = unique_output_path("python-resource-keyword-only"); + fs::create_dir_all(&temp_dir).unwrap(); + let wit_path = temp_dir.join("jobs.wit"); + let wit = r#" +package test:jobs@1.0.0; + +world system { export job-service; } + +/// @nexus.endpoint "jobs" +interface job-service { + resource job { + constructor(label: option, id: string); + } + + type job-result = own; + + record start-job-request { + label: option, + id: string, + } + + start-job: func(request: start-job-request) -> job-result; +} +"#; + fs::write(&wit_path, wit).unwrap(); + let output_path = temp_dir.join("output"); + generate_to_file(&GenerateRequest { + config: nexgen::nexgen_config::NexgenConfig { + mode: nexgen::generator::GenerationMode::NativeApi, + ..Default::default() + }, + language: nexgen::language::Language::Python, + input_paths: vec![wit_path], + support_package: Some("temporal_support".into()), + descriptor_paths: Vec::new(), + output_path: output_path.clone(), + format: false, + java_package_name: None, + ts_date_time_types: Default::default(), + dotnet_namespace: None, + }) + .unwrap(); + let files = read_python_package_files(&output_path); + fs::remove_dir_all(temp_dir).unwrap(); + + let job = &files[&PathBuf::from("_resources/job.py")]; + assert!( + job.contains("@dataclasses.dataclass(kw_only=True)\nclass Job:"), + "{job}" + ); + assert!( + job.contains(" label: str | None = None\n id: str\n"), + "{job}" + ); + // The Workflow factory takes the fields by keyword, and the package exports it. + assert!( + job.contains("def job(\n *,\n label: str | None = None,\n id: str,\n) -> Job:") + ); + assert!(!job.contains("TransferTypeConverter"), "{job}"); + assert!(files[&PathBuf::from("__init__.py")].contains("from ._resources.job import job\n")); +} + #[test] fn python_proto_helpers_use_the_supplied_support_module() { let root = project_root();