diff --git a/CHANGELOG.md b/CHANGELOG.md index 7dd995bf..ac164813 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 including required and optional oneofs, scaffolding through `add-rpc` and `add-message`, and explicit diagnostics for unsupported target backends. - Python proto-backed generic records may use Temporal `Payload` and `Payloads` - fields (including oneof members) as runtime value carriers. + fields (including oneof members) as runtime value carriers. Decoding preserves + concrete runtime type arguments through nested models and `Payload` values. - JSON Schema: An object `oneOf` branch may now be written **inline**, whatever its shape. A structured branch (declared `properties`, or a typed `additionalProperties`) is named — `Object` for a lone branch, or the diff --git a/GUIDE.md b/GUIDE.md index e6ff472f..ce129e98 100644 --- a/GUIDE.md +++ b/GUIDE.md @@ -68,11 +68,13 @@ This produces a data model for the request and response, a service definition, and a convenience wrapper function that lets callers write: **Python:** + ```python user = await get_user(user_id="abc") ``` **TypeScript:** + ```typescript const user = await getUser({ userId: "abc" }); ``` @@ -94,6 +96,7 @@ record postal-address { ``` **Python:** + ```python @dataclasses.dataclass(slots=True) class PostalAddress: @@ -103,6 +106,7 @@ class PostalAddress: ``` **TypeScript:** + ```typescript export interface PostalAddress { street: string; @@ -128,6 +132,7 @@ enum user-status { ``` **Python:** + ```python class UserStatus(enum.IntEnum): Active = 0 @@ -136,6 +141,7 @@ class UserStatus(enum.IntEnum): ``` **TypeScript:** + ```typescript export enum UserStatus { Active = 0, @@ -157,6 +163,7 @@ flags user-capability { ``` **Python:** + ```python UserCapability: typing.TypeAlias = int UserCapabilityReadProfile = 1 << 0 @@ -165,6 +172,7 @@ UserCapabilityDeactivate = 1 << 2 ``` **TypeScript:** + ```typescript export enum UserCapability { ReadProfile = 2 ** 0, @@ -186,6 +194,7 @@ variant notification-target { ``` **Python:** + ```python NotificationTarget = ( tuple[typing.Literal["email"], str] @@ -195,6 +204,7 @@ NotificationTarget = ( ``` **TypeScript:** + ```typescript export type NotificationTarget = | { tag: "email"; value: string } @@ -216,11 +226,13 @@ record user-profile { ``` **Python:** + ```python sync_state: tuple[typing.Literal["ok"], str] | tuple[typing.Literal["err"], str] ``` **TypeScript:** + ```typescript syncState: { tag: "ok"; value: string } | { tag: "err"; value: string }; ``` @@ -236,11 +248,13 @@ record postal-address { ``` **Python:** + ```python coordinates: tuple[float, float] | None = None ``` **TypeScript:** + ```typescript coordinates?: [number, number]; ``` @@ -250,6 +264,7 @@ coordinates?: [number, number]; `option` fields become optional with a `None`/`undefined` default. **Python:** + ```python # Required field: no default user_id: str @@ -258,6 +273,7 @@ reason: str | None = None ``` **TypeScript:** + ```typescript // Required field: no ? userId: string; @@ -277,12 +293,14 @@ record user-profile { ``` **Python:** + ```python tags: list[str] | None = dataclasses.field(default_factory=list) metadata: dict[str, str] | None = dataclasses.field(default_factory=dict) ``` **TypeScript:** + ```typescript tags?: string[]; metadata?: Record; @@ -320,6 +338,7 @@ update-email: func(request: update-email-request) -> user-result; ``` **Python:** + ```python @dataclasses.dataclass class User: @@ -332,16 +351,17 @@ class User: ``` **TypeScript:** + ```typescript export class User { - public constructor( - public readonly userId: string, - public readonly email: string, - ) {} - - public async updateEmail(email: string): Promise { - return await updateEmail({ userId: this.userId, email: email }); - } + public constructor( + public readonly userId: string, + public readonly email: string, + ) {} + + public async updateEmail(email: string): Promise { + return await updateEmail({ userId: this.userId, email: email }); + } } ``` @@ -365,6 +385,7 @@ say "method X calls operation Y." Instead, it uses **field-name matching** to automatically bind resource methods to operations. For each resource method, the generator builds a **name environment** from: + - The resource's **constructor field names** (e.g., `user-id`, `email`) - The method's **parameter names** (e.g., `email`) @@ -385,11 +406,13 @@ update-email: func(request: update-email-request) -> user-result; ``` The environment for the `update-email` method is: + ``` user-id => ResourceField("user-id") (from constructor) email => MethodParam("email") (from method signature) ``` -Notice that the method's `email` parameter shadows the `email` parameter we got + +Notice that the method's `email` parameter shadows the `email` parameter we got from the constructor! The generator tries the `update-email` operation, which has input @@ -398,6 +421,7 @@ are found in the environment, so the binding succeeds. The generated code then uses `self.user_id` for the resource field and the `email` parameter for the method parameter: + ```python request = UpdateEmailRequest(user_id=self.user_id, email=email) ``` @@ -424,6 +448,7 @@ record cancel-workflow-request { ``` The environment for `cancel` is: + ``` namespace => ResourceField workflow-id => ResourceField @@ -432,6 +457,7 @@ reason => MethodParam ``` The `cancel-workflow-request` has three fields: + - `namespace` -- directly matched to ResourceField - `workflow-execution` -- not directly in the environment, but it is a record type, so the generator recurses into it and finds `workflow-id` and `run-id`, @@ -590,6 +616,7 @@ record activity-options { ``` **Python:** + ```python @dataclasses.dataclass(slots=True, kw_only=True) class ActivityOptions: @@ -615,6 +642,7 @@ class ActivityOptions: ``` **TypeScript:** + ```typescript export interface ActivityOptions { taskQueue?: string; @@ -713,9 +741,9 @@ async def signal_with_start_workflow( ) ``` -In the snippet above, notice that the entire `user-metadata` field is `None` if +In the snippet above, notice that the entire `user-metadata` field is `None` if all its constituent fields are `None`. This is the behavior in Python and Typescript, -but not in Go; in Go we implement flattening with value embedding, so `user-metadata` +but not in Go; in Go we implement flattening with value embedding, so `user-metadata` is never nil. ### Output Transforms @@ -748,7 +776,10 @@ return workflow.get_external_workflow_handle(request.id, run_id=result.run_id) ```typescript const result = await handle.result(); -return workflow.getExternalWorkflowHandle(request.id, result.runId ?? undefined); +return workflow.getExternalWorkflowHandle( + request.id, + result.runId ?? undefined, +); ``` ```go @@ -1075,26 +1106,25 @@ language-prefixed keys such as `python-result` and `typescript-result` when it does not. Do not combine these result overrides with `signature` on a type alias. - ### Option Summary -| Option | Motivation | Generated effect | -| --- | --- | --- | -| `signature` | Keep callable shape in a WIT function instead of a placeholder alias. | Derives callable argument and result annotations. Required for type-alias `@nexus.function`. | -| `alternate-type` | Accept raw names as well as typed callables. | Generates union annotations and overloads such as `str | Callable[...]`. | -| `args-field` | Map ergonomic function args to a wire field such as `input` or `signal-input`. | Stores normalized args in that request/proto field. Defaults to the signature's args name for type aliases. | -| `result-type-parameter` | Preserve the callable's result type in returned handles. | Emits a type variable such as `WorkflowResult` and uses it in callable overload returns. | -| `primary` | Identify the main callable when a request has multiple function references. | Enables positional primary args and result type propagation. Defaults to `false`. | -| `converter` | Reuse the same conversion helper name in all languages. | Calls the helper when converting the function field to proto. | -| `-converter` | Use language-specific conversion helper names. | Python uses `python-converter`; TypeScript uses `typescript-converter`. | -| `result` / `-result` | Direct field-level form when there is no type alias `signature`. | Supplies the callable result annotation directly. Prefer `signature` for authored aliases. | +| Option | Motivation | Generated effect | +| ------------------------------ | ------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------- | +| `signature` | Keep callable shape in a WIT function instead of a placeholder alias. | Derives callable argument and result annotations. Required for type-alias `@nexus.function`. | +| `alternate-type` | Accept raw names as well as typed callables. | Generates union annotations and overloads such as `str \| Callable[...]`. | +| `args-field` | Map ergonomic function args to a wire field such as `input` or `signal-input`. | Stores normalized args in that request/proto field. Defaults to the signature's args name for type aliases. | +| `result-type-parameter` | Preserve the callable's result type in returned handles. | Emits a type variable such as `WorkflowResult` and uses it in callable overload returns. | +| `primary` | Identify the main callable when a request has multiple function references. | Enables positional primary args and result type propagation. Defaults to `false`. | +| `converter` | Reuse the same conversion helper name in all languages. | Calls the helper when converting the function field to proto. | +| `-converter` | Use language-specific conversion helper names. | Python uses `python-converter`; TypeScript uses `typescript-converter`. | +| `result` / `-result` | Direct field-level form when there is no type alias `signature`. | Supplies the callable result annotation directly. Prefer `signature` for authored aliases. | `@nexus.function-args` options: -| Option | Motivation | Generated effect | -| --- | --- | --- | -| `varargs` | Treat a final list-shaped signature parameter as `*args`. | Generates positional overloads plus list-form `args`. | -| `param` | Disambiguate which final parameter is the varargs list. | Required when the signature has more than one parameter. | +| Option | Motivation | Generated effect | +| ------------------------ | ---------------------------------------------------------------------- | ---------------------------------------------------------- | +| `varargs` | Treat a final list-shaped signature parameter as `*args`. | Generates positional overloads plus list-form `args`. | +| `param` | Disambiguate which final parameter is the varargs list. | Required when the signature has more than one parameter. | | `typescript-drop-prefix` | Remove implicit receiver/context parameters from TypeScript inference. | TypeScript omits the prefix from inferred argument tuples. | --- @@ -1175,7 +1205,7 @@ record response { ``` Python performs bidirectional oneof conversion using tagged tuples such as -`("success", value)`. Other targets reject a reachable model containing a +`("success", value)`. Other targets reject a reachable model containing a oneof they cannot convert; unreachable declarations and omitted oneofs remain valid. --- @@ -1208,9 +1238,13 @@ target. Type parameters are not currently supported in proto-backed records except in Python when a field or oneof member maps to Temporal's protobuf `Payload` or -`Payloads` carrier. They are also unsupported in resources, map keys, -function-signature metadata, or resource-bound generic operations. Go type -parameters use an `any` constraint. +`Payloads` carrier. When decoding a parameterized Python model, concrete type +arguments propagate through nested proto-backed records and become type hints +for single-value `Payload` fields. An unparameterized model decodes those fields +as `typing.Any`. `Payloads` fields continue to decode as untyped sequences. + +Type parameters are also unsupported in resources, map keys, function-signature +metadata, or resource-bound generic operations. Generic variants retain each target's normal tagged representation: tagged tuples in Python, tagged object unions in TypeScript, sealed interfaces and @@ -1327,6 +1361,7 @@ id-reuse-policy: workflow-id-reuse-policy, ``` **Python:** + ```python id_reuse_policy: temporalio.common.WorkflowIDReusePolicy = ( temporalio.common.WorkflowIDReusePolicy.ALLOW_DUPLICATE @@ -1427,6 +1462,7 @@ The referenced helper must be provided through `@nexus.support`. **Placement:** Operation (function) **Syntax:** + ``` @nexus.output-transform python-type="" python="" @@ -1511,6 +1547,7 @@ static-summary: option, **Placement:** Type alias or record field **Syntax:** + ``` @nexus.function signature="" @@ -1532,6 +1569,7 @@ generated Python samples. **Placement:** Function used as a `@nexus.function signature` **Syntax:** + ``` @nexus.function-args varargs=true diff --git a/advanced/README.md b/advanced/README.md index 07531463..124e5fec 100644 --- a/advanced/README.md +++ b/advanced/README.md @@ -307,6 +307,13 @@ tags continue to raise `ValueError`. Other language backends report an explicit unsupported-conversion error when a reachable oneof model requires protobuf conversion. +Python also preserves concrete runtime type arguments while decoding nested +proto-backed generic records. A type parameter represented by Temporal's +single-value `Payload` carrier is passed to the payload converter as its type +hint; `Payloads` continues to decode as a sequence. The +`proto-generic-python` sample exercises this Python-only exception without +making the cross-language `generic-models` sample depend on protobuf support. + Generate WIT for a proto RPC from a descriptor set: ```bash diff --git a/advanced/samples/inputs/deps/nexus-temporal-types/python/temporal_model_converters.py b/advanced/samples/inputs/deps/nexus-temporal-types/python/temporal_model_converters.py index cd187cd5..449459ef 100644 --- a/advanced/samples/inputs/deps/nexus-temporal-types/python/temporal_model_converters.py +++ b/advanced/samples/inputs/deps/nexus-temporal-types/python/temporal_model_converters.py @@ -98,8 +98,14 @@ def payloads_to_proto( def payloads_from_proto( proto: common_pb2.Payloads, -) -> list[object]: - return list(temporalio.nexus.system._current_user_payload_converter().from_payloads_wrapper(proto)) + type_hints: list[typing.Any] | None = None, +) -> list[typing.Any]: + if not proto.payloads: + return [] + return temporalio.nexus.system._current_user_payload_converter().from_payloads( + proto.payloads, + type_hints, + ) def _clone_payload(payload: common_pb2.Payload) -> common_pb2.Payload: @@ -130,10 +136,31 @@ def _payload_to_value( ) +_PayloadT = typing.TypeVar("_PayloadT") + + +@typing.overload def payload_from_proto( proto: common_pb2.Payload, -) -> object: - return _payload_to_value(proto) + type_hint: type[_PayloadT], +) -> _PayloadT: ... + + +@typing.overload +def payload_from_proto( + proto: common_pb2.Payload, + type_hint: None = None, +) -> typing.Any: ... + + +def payload_from_proto( + proto: common_pb2.Payload, + type_hint: type[typing.Any] | None = None, +) -> typing.Any: + converter = temporalio.nexus.system._current_user_payload_converter() + if type_hint is None: + return converter.from_payload(_clone_payload(proto)) + return converter.from_payload(_clone_payload(proto), type_hint) def payload_to_proto( diff --git a/advanced/samples/inputs/proto-generic-python.wit b/advanced/samples/inputs/proto-generic-python.wit new file mode 100644 index 00000000..f2f54bfe --- /dev/null +++ b/advanced/samples/inputs/proto-generic-python.wit @@ -0,0 +1,40 @@ +/// Python-only fixture for concrete type hints in Payload-backed proto models. +package temporal:nexus@1.0.0; + +world system { + export proto-generic-python; +} + +interface proto-generic-python { + use nexus:temporal-types/model@1.0.0.{placeholder}; + + /// @nexus.type-parameter + type output-t = placeholder; + + /// @nexus.type-parameter + type context-t = placeholder; + + /// @nexus.proto "temporal.api.compute.v1.ComputeProvider" + record payload-backed-output { + /// @nexus.omit + %type: placeholder, + details: output-t, + /// @nexus.omit + nexus-endpoint: placeholder, + } + + /// @nexus.proto "temporal.api.compute.v1.ComputeScaler" + record payload-backed-context { + /// @nexus.omit + %type: placeholder, + details: context-t, + } + + /// @nexus.proto "temporal.api.compute.v1.ComputeConfigScalingGroup" + record payload-backed-envelope { + /// @nexus.omit + task-queue-types: placeholder, + provider: payload-backed-output, + scaler: payload-backed-context, + } +} diff --git a/advanced/samples/python/tests/test_proto_generic.py b/advanced/samples/python/tests/test_proto_generic.py new file mode 100644 index 00000000..135e34b5 --- /dev/null +++ b/advanced/samples/python/tests/test_proto_generic.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +import dataclasses + +import temporalio.converter +import temporalio.nexus.system + +from wit.proto_generic_python import ( + PayloadBackedContext, + PayloadBackedEnvelope, + PayloadBackedOutput, +) + + +@dataclasses.dataclass +class EchoOutput: + value: str + + +@dataclasses.dataclass +class MyCtx: + neat: str + + +def test_proto_backed_generic_type_hints_are_preserved() -> None: + model = PayloadBackedEnvelope( + provider=PayloadBackedOutput(details=EchoOutput(value="hello")), + scaler=PayloadBackedContext(details=MyCtx(neat="very")), + ) + converter = temporalio.nexus.system._SystemNexusPayloadConverter( + temporalio.converter.PayloadConverter.default + ) + + payload = converter.to_payload(model) + decoded = converter.from_payload( + payload, + PayloadBackedEnvelope[EchoOutput, MyCtx], + ) + + assert isinstance(decoded, PayloadBackedEnvelope) + assert isinstance(decoded.provider.details, EchoOutput) + assert decoded.provider.details == EchoOutput(value="hello") + assert isinstance(decoded.scaler.details, MyCtx) + assert decoded.scaler.details == MyCtx(neat="very") + + +def test_unparameterized_proto_backed_generic_decodes_payload_values() -> None: + model = PayloadBackedEnvelope( + provider=PayloadBackedOutput(details={"value": "hello"}), + scaler=PayloadBackedContext(details={"neat": "very"}), + ) + converter = temporalio.nexus.system._SystemNexusPayloadConverter( + temporalio.converter.PayloadConverter.default + ) + + payload = converter.to_payload(model) + decoded = converter.from_payload(payload, PayloadBackedEnvelope) + + assert decoded == model diff --git a/advanced/samples/python/tests/test_proto_oneof.py b/advanced/samples/python/tests/test_proto_oneof.py index d80c4e53..c4f8866a 100644 --- a/advanced/samples/python/tests/test_proto_oneof.py +++ b/advanced/samples/python/tests/test_proto_oneof.py @@ -1,5 +1,6 @@ from __future__ import annotations +import dataclasses import typing import pytest @@ -17,6 +18,11 @@ ) +@dataclasses.dataclass +class SuccessfulOutput: + message: str + + def test_proto_oneof_success_round_trip(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr( temporalio.nexus.system, @@ -24,12 +30,16 @@ def test_proto_oneof_success_round_trip(monkeypatch: pytest.MonkeyPatch) -> None lambda: PayloadConverter.default, ) converter = _OutcomeTransferTypeConverter() + model: Outcome[SuccessfulOutput] = Outcome( + value=("success", SuccessfulOutput(message="hello")) + ) - wire = converter.to_transfer_type(Outcome(value=("success", ["hello", 7]))) + wire = converter.to_transfer_type(model) assert wire.WhichOneof("value") == "success" - decoded = converter.from_transfer_type(wire, Outcome) - assert decoded == Outcome(value=("success", ["hello", 7])) + decoded = converter.from_transfer_type(wire, Outcome[SuccessfulOutput]) + assert decoded == model + assert isinstance(decoded.value[1], SuccessfulOutput) def test_required_proto_oneof_failure_round_trip() -> None: diff --git a/advanced/samples/python/wit/function_execution/_support/temporal_model_converters.py b/advanced/samples/python/wit/function_execution/_support/temporal_model_converters.py index 51955e20..128cc6b7 100644 --- a/advanced/samples/python/wit/function_execution/_support/temporal_model_converters.py +++ b/advanced/samples/python/wit/function_execution/_support/temporal_model_converters.py @@ -102,11 +102,13 @@ def payloads_to_proto( def payloads_from_proto( proto: common_pb2.Payloads, -) -> list[object]: - return list( - temporalio.nexus.system._current_user_payload_converter().from_payloads_wrapper( - proto - ) + type_hints: list[typing.Any] | None = None, +) -> list[typing.Any]: + if not proto.payloads: + return [] + return temporalio.nexus.system._current_user_payload_converter().from_payloads( + proto.payloads, + type_hints, ) @@ -144,10 +146,31 @@ def _payload_to_value( ) +_PayloadT = typing.TypeVar("_PayloadT") + + +@typing.overload def payload_from_proto( proto: common_pb2.Payload, -) -> object: - return _payload_to_value(proto) + type_hint: type[_PayloadT], +) -> _PayloadT: ... + + +@typing.overload +def payload_from_proto( + proto: common_pb2.Payload, + type_hint: None = None, +) -> typing.Any: ... + + +def payload_from_proto( + proto: common_pb2.Payload, + type_hint: type[typing.Any] | None = None, +) -> typing.Any: + converter = temporalio.nexus.system._current_user_payload_converter() + if type_hint is None: + return converter.from_payload(_clone_payload(proto)) + return converter.from_payload(_clone_payload(proto), type_hint) def payload_to_proto( diff --git a/advanced/samples/python/wit/generic_models/_support/temporal_model_converters.py b/advanced/samples/python/wit/generic_models/_support/temporal_model_converters.py index 51955e20..128cc6b7 100644 --- a/advanced/samples/python/wit/generic_models/_support/temporal_model_converters.py +++ b/advanced/samples/python/wit/generic_models/_support/temporal_model_converters.py @@ -102,11 +102,13 @@ def payloads_to_proto( def payloads_from_proto( proto: common_pb2.Payloads, -) -> list[object]: - return list( - temporalio.nexus.system._current_user_payload_converter().from_payloads_wrapper( - proto - ) + type_hints: list[typing.Any] | None = None, +) -> list[typing.Any]: + if not proto.payloads: + return [] + return temporalio.nexus.system._current_user_payload_converter().from_payloads( + proto.payloads, + type_hints, ) @@ -144,10 +146,31 @@ def _payload_to_value( ) +_PayloadT = typing.TypeVar("_PayloadT") + + +@typing.overload def payload_from_proto( proto: common_pb2.Payload, -) -> object: - return _payload_to_value(proto) + type_hint: type[_PayloadT], +) -> _PayloadT: ... + + +@typing.overload +def payload_from_proto( + proto: common_pb2.Payload, + type_hint: None = None, +) -> typing.Any: ... + + +def payload_from_proto( + proto: common_pb2.Payload, + type_hint: type[typing.Any] | None = None, +) -> typing.Any: + converter = temporalio.nexus.system._current_user_payload_converter() + if type_hint is None: + return converter.from_payload(_clone_payload(proto)) + return converter.from_payload(_clone_payload(proto), type_hint) def payload_to_proto( diff --git a/advanced/samples/python/wit/proto_generic_python/__init__.py b/advanced/samples/python/wit/proto_generic_python/__init__.py new file mode 100644 index 00000000..ddb040d9 --- /dev/null +++ b/advanced/samples/python/wit/proto_generic_python/__init__.py @@ -0,0 +1,17 @@ +# Generated by nexgen. DO NOT EDIT! + +from __future__ import annotations + +from .models import ( + PayloadBackedContext, + PayloadBackedEnvelope, + PayloadBackedOutput, +) +from .services import ProtoGenericPythonClient + +__all__ = [ + "PayloadBackedContext", + "PayloadBackedEnvelope", + "PayloadBackedOutput", + "ProtoGenericPythonClient", +] diff --git a/advanced/samples/python/wit/proto_generic_python/_support/__init__.py b/advanced/samples/python/wit/proto_generic_python/_support/__init__.py new file mode 100644 index 00000000..5859107c --- /dev/null +++ b/advanced/samples/python/wit/proto_generic_python/_support/__init__.py @@ -0,0 +1,5 @@ +# Generated by nexgen. DO NOT EDIT! + +from __future__ import annotations + +from .temporal_model_converters import * # noqa: F401,F403 diff --git a/advanced/samples/python/wit/proto_generic_python/_support/temporal_model_converters.py b/advanced/samples/python/wit/proto_generic_python/_support/temporal_model_converters.py new file mode 100644 index 00000000..128cc6b7 --- /dev/null +++ b/advanced/samples/python/wit/proto_generic_python/_support/temporal_model_converters.py @@ -0,0 +1,321 @@ +import collections.abc +from datetime import timedelta +import typing + +import google.protobuf.duration_pb2 +import temporalio.api.common.v1.message_pb2 as common_pb2 +import temporalio.api.enums.v1.workflow_pb2 as workflow_enums_pb2 +import temporalio.api.failure.v1.message_pb2 as failure_pb2 +import temporalio.api.taskqueue.v1.message_pb2 as taskqueue_pb2 +import temporalio.api.workflow.v1 as workflow_pb2 +import temporalio.converter as temporalio_converter +import temporalio.common as temporalio_common +import temporalio.nexus.system +import temporalio.exceptions as temporalio_exceptions + + +class SignalWithStartWorkflowModelRequest(typing.Protocol): + namespace: str + id: str + + +def retry_policy_from_proto( + proto: common_pb2.RetryPolicy, +) -> temporalio_common.RetryPolicy: + return temporalio_common.RetryPolicy.from_proto(proto) + + +def retry_policy_to_proto( + retry_policy: temporalio_common.RetryPolicy, +) -> common_pb2.RetryPolicy: + proto = common_pb2.RetryPolicy() + retry_policy.apply_to_proto(proto) + return proto + + +def workflow_function_name( + value: str | collections.abc.Callable[..., collections.abc.Awaitable[object]], +) -> str: + from temporalio.workflow import _Definition + + name, _result_type = _Definition.get_name_and_result_type(value) + return name + + +def signal_function_to_proto( + value: str | collections.abc.Callable[..., typing.Any], +) -> str: + from temporalio.workflow import _SignalDefinition + + return _SignalDefinition.must_name_from_fn_or_str(value) + + +def workflow_type_to_proto( + workflow_type: str + | collections.abc.Callable[..., collections.abc.Awaitable[object]], +) -> common_pb2.WorkflowType: + return common_pb2.WorkflowType(name=workflow_function_name(workflow_type)) + + +def workflow_type_from_proto( + proto: common_pb2.WorkflowType, +) -> str: + return proto.name + + +def task_queue_from_proto( + proto: taskqueue_pb2.TaskQueue, +) -> str: + return proto.name + + +def task_queue_to_proto( + task_queue: str, +) -> taskqueue_pb2.TaskQueue: + return taskqueue_pb2.TaskQueue(name=task_queue) + + +def workflow_namespace() -> str: + from temporalio.workflow import info + + return info().namespace + + +def signal_with_start_workflow_serialization_context( + request: SignalWithStartWorkflowModelRequest, +) -> temporalio_converter.WorkflowSerializationContext: + return temporalio_converter.WorkflowSerializationContext( + namespace=request.namespace, + workflow_id=request.id, + ) + + +def payloads_to_proto( + values: collections.abc.Sequence[typing.Any], +) -> common_pb2.Payloads: + return ( + temporalio.nexus.system._current_user_payload_converter().to_payloads_wrapper( + values + ) + ) + + +def payloads_from_proto( + proto: common_pb2.Payloads, + type_hints: list[typing.Any] | None = None, +) -> list[typing.Any]: + if not proto.payloads: + return [] + return temporalio.nexus.system._current_user_payload_converter().from_payloads( + proto.payloads, + type_hints, + ) + + +def _clone_payload(payload: common_pb2.Payload) -> common_pb2.Payload: + clone = common_pb2.Payload() + clone.CopyFrom(payload) + return clone + + +def _value_to_payload( + value: object | common_pb2.Payload, +) -> common_pb2.Payload: + if isinstance(value, common_pb2.Payload): + return _clone_payload(value) + + payloads = ( + temporalio.nexus.system._current_user_payload_converter().to_payloads_wrapper( + [value] + ) + ) + return _clone_payload(payloads.payloads[0]) + + +def _payload_to_value( + payload: common_pb2.Payload, +) -> object: + wrapper = common_pb2.Payloads() + wrapper.payloads.add().CopyFrom(payload) + + return typing.cast( + object, + temporalio.nexus.system._current_user_payload_converter().from_payloads_wrapper( + wrapper + )[0], + ) + + +_PayloadT = typing.TypeVar("_PayloadT") + + +@typing.overload +def payload_from_proto( + proto: common_pb2.Payload, + type_hint: type[_PayloadT], +) -> _PayloadT: ... + + +@typing.overload +def payload_from_proto( + proto: common_pb2.Payload, + type_hint: None = None, +) -> typing.Any: ... + + +def payload_from_proto( + proto: common_pb2.Payload, + type_hint: type[typing.Any] | None = None, +) -> typing.Any: + converter = temporalio.nexus.system._current_user_payload_converter() + if type_hint is None: + return converter.from_payload(_clone_payload(proto)) + return converter.from_payload(_clone_payload(proto), type_hint) + + +def payload_to_proto( + payload: object, +) -> common_pb2.Payload: + return _value_to_payload(payload) + + +def failure_from_proto( + proto: failure_pb2.Failure, +) -> BaseException: + return temporalio_converter.FailureConverter.default.from_failure( + proto, + _failure_payload_converter(), + ) + + +def failure_to_proto( + failure: BaseException, +) -> failure_pb2.Failure: + proto = failure_pb2.Failure() + temporalio_converter.FailureConverter.default.to_failure( + failure, + _failure_payload_converter(), + proto, + ) + return proto + + +def _failure_payload_converter() -> temporalio_converter.PayloadConverter: + try: + return temporalio.nexus.system._current_user_payload_converter() + except RuntimeError: + try: + from temporalio.workflow import payload_converter + + return payload_converter() + except temporalio_exceptions.TemporalError: + return temporalio_converter.PayloadConverter.default + + +def memo_from_proto( + proto: common_pb2.Memo, +) -> collections.abc.Mapping[str, object]: + return {key: _payload_to_value(value) for key, value in proto.fields.items()} + + +def memo_to_proto( + memo: collections.abc.Mapping[str, object], +) -> common_pb2.Memo: + message = common_pb2.Memo() + for key, value in memo.items(): + message.fields[key].CopyFrom(_value_to_payload(value)) + return message + + +def duration_from_proto( + proto: google.protobuf.duration_pb2.Duration, +) -> timedelta: + return proto.ToTimedelta() + + +def duration_to_proto( + duration: timedelta, +) -> google.protobuf.duration_pb2.Duration: + proto = google.protobuf.duration_pb2.Duration() + proto.FromTimedelta(duration) + return proto + + +def workflow_id_reuse_policy_from_proto( + policy: workflow_enums_pb2.WorkflowIdReusePolicy.ValueType, +) -> temporalio_common.WorkflowIDReusePolicy: + return temporalio_common.WorkflowIDReusePolicy(int(policy)) + + +def workflow_id_reuse_policy_to_proto( + policy: temporalio_common.WorkflowIDReusePolicy, +) -> workflow_enums_pb2.WorkflowIdReusePolicy.ValueType: + return typing.cast(workflow_enums_pb2.WorkflowIdReusePolicy.ValueType, int(policy)) + + +def workflow_id_conflict_policy_from_proto( + policy: workflow_enums_pb2.WorkflowIdConflictPolicy.ValueType, +) -> temporalio_common.WorkflowIDConflictPolicy: + return temporalio_common.WorkflowIDConflictPolicy(int(policy)) + + +def workflow_id_conflict_policy_to_proto( + policy: temporalio_common.WorkflowIDConflictPolicy, +) -> workflow_enums_pb2.WorkflowIdConflictPolicy.ValueType: + return typing.cast( + workflow_enums_pb2.WorkflowIdConflictPolicy.ValueType, int(policy) + ) + + +def search_attributes_to_proto( + search_attributes: temporalio_common.TypedSearchAttributes, +) -> common_pb2.SearchAttributes: + proto = common_pb2.SearchAttributes() + temporalio_converter.encode_search_attributes(search_attributes, proto) + return proto + + +def search_attributes_from_proto( + proto: common_pb2.SearchAttributes, +) -> temporalio_common.TypedSearchAttributes: + return temporalio_converter.decode_typed_search_attributes(proto) + + +def priority_from_proto( + proto: common_pb2.Priority, +) -> temporalio_common.Priority: + return temporalio_common.Priority._from_proto(proto) + + +def priority_to_proto( + priority: temporalio_common.Priority, +) -> common_pb2.Priority: + return priority._to_proto() + + +def versioning_override_to_proto( + versioning_override: temporalio_common.VersioningOverride, +) -> workflow_pb2.VersioningOverride: + return versioning_override._to_proto() + + +def versioning_override_from_proto( + proto: workflow_pb2.VersioningOverride, +) -> temporalio_common.VersioningOverride: + if proto.HasField("pinned") and proto.pinned.HasField("version"): + version = proto.pinned.version + return temporalio_common.PinnedVersioningOverride( + temporalio_common.WorkerDeploymentVersion( + deployment_name=version.deployment_name, + build_id=version.build_id, + ) + ) + if proto.pinned_version: + return temporalio_common.PinnedVersioningOverride( + temporalio_common.WorkerDeploymentVersion.from_canonical_string( + proto.pinned_version + ) + ) + if proto.auto_upgrade: + return temporalio_common.AutoUpgradeVersioningOverride() + raise ValueError("unknown versioning override proto shape") diff --git a/advanced/samples/python/wit/proto_generic_python/models.py b/advanced/samples/python/wit/proto_generic_python/models.py new file mode 100644 index 00000000..13480dba --- /dev/null +++ b/advanced/samples/python/wit/proto_generic_python/models.py @@ -0,0 +1,172 @@ +# Generated by nexgen. DO NOT EDIT! + +from __future__ import annotations + +import dataclasses +import typing +import typing_extensions +import temporalio.api.compute.v1.config_pb2 +import temporalio.api.compute.v1.provider_pb2 +import temporalio.api.compute.v1.scaler_pb2 +import temporalio.converter + +from ._support import ( + payload_from_proto, + payload_to_proto, +) + + +ContextT = typing.TypeVar("ContextT") +OutputT = typing.TypeVar("OutputT") + + +class _PayloadBackedContextTransferTypeConverter( + temporalio.converter.TransferTypeConverter[ + "PayloadBackedContext[typing.Any]", + temporalio.api.compute.v1.scaler_pb2.ComputeScaler, + ] +): + transfer_type: type[temporalio.api.compute.v1.scaler_pb2.ComputeScaler] | None = ( + temporalio.api.compute.v1.scaler_pb2.ComputeScaler + ) + + @typing_extensions.override + def from_transfer_type( + self, + value: temporalio.api.compute.v1.scaler_pb2.ComputeScaler, + type_hint: type["PayloadBackedContext[typing.Any]"], + ) -> "PayloadBackedContext[typing.Any]": + (context_type,) = typing.get_args(type_hint) or (typing.Any,) + if not value.HasField("details"): + raise ValueError("missing required field PayloadBackedContext.details") + details = payload_from_proto(value.details, context_type) + return PayloadBackedContext( + details=details, + ) + + @typing_extensions.override + def to_transfer_type( + self, + value: "PayloadBackedContext[typing.Any]", + ) -> temporalio.api.compute.v1.scaler_pb2.ComputeScaler: + message = temporalio.api.compute.v1.scaler_pb2.ComputeScaler() + message.details.CopyFrom(payload_to_proto(value.details)) + return message + + +@typing.cast( + typing.Any, + temporalio.converter.transfer_type_convertible( + _PayloadBackedContextTransferTypeConverter + ), +) +@dataclasses.dataclass(slots=True) +class PayloadBackedContext(typing.Generic[ContextT]): + details: ContextT + + +class _PayloadBackedEnvelopeTransferTypeConverter( + temporalio.converter.TransferTypeConverter[ + "PayloadBackedEnvelope[typing.Any, typing.Any]", + temporalio.api.compute.v1.config_pb2.ComputeConfigScalingGroup, + ] +): + transfer_type: ( + type[temporalio.api.compute.v1.config_pb2.ComputeConfigScalingGroup] | None + ) = temporalio.api.compute.v1.config_pb2.ComputeConfigScalingGroup + + @typing_extensions.override + def from_transfer_type( + self, + value: temporalio.api.compute.v1.config_pb2.ComputeConfigScalingGroup, + type_hint: type["PayloadBackedEnvelope[typing.Any, typing.Any]"], + ) -> "PayloadBackedEnvelope[typing.Any, typing.Any]": + output_type, context_type = typing.get_args(type_hint) or ( + typing.Any, + typing.Any, + ) + if not value.HasField("provider"): + raise ValueError("missing required field PayloadBackedEnvelope.provider") + provider = _PayloadBackedOutputTransferTypeConverter().from_transfer_type( + value.provider, PayloadBackedOutput[output_type] + ) + if not value.HasField("scaler"): + raise ValueError("missing required field PayloadBackedEnvelope.scaler") + scaler = _PayloadBackedContextTransferTypeConverter().from_transfer_type( + value.scaler, PayloadBackedContext[context_type] + ) + return PayloadBackedEnvelope( + provider=provider, + scaler=scaler, + ) + + @typing_extensions.override + def to_transfer_type( + self, + value: "PayloadBackedEnvelope[typing.Any, typing.Any]", + ) -> temporalio.api.compute.v1.config_pb2.ComputeConfigScalingGroup: + message = temporalio.api.compute.v1.config_pb2.ComputeConfigScalingGroup() + message.provider.CopyFrom( + _PayloadBackedOutputTransferTypeConverter().to_transfer_type(value.provider) + ) + message.scaler.CopyFrom( + _PayloadBackedContextTransferTypeConverter().to_transfer_type(value.scaler) + ) + return message + + +@typing.cast( + typing.Any, + temporalio.converter.transfer_type_convertible( + _PayloadBackedEnvelopeTransferTypeConverter + ), +) +@dataclasses.dataclass(slots=True) +class PayloadBackedEnvelope(typing.Generic[OutputT, ContextT]): + provider: PayloadBackedOutput[OutputT] + scaler: PayloadBackedContext[ContextT] + + +class _PayloadBackedOutputTransferTypeConverter( + temporalio.converter.TransferTypeConverter[ + "PayloadBackedOutput[typing.Any]", + temporalio.api.compute.v1.provider_pb2.ComputeProvider, + ] +): + transfer_type: ( + type[temporalio.api.compute.v1.provider_pb2.ComputeProvider] | None + ) = temporalio.api.compute.v1.provider_pb2.ComputeProvider + + @typing_extensions.override + def from_transfer_type( + self, + value: temporalio.api.compute.v1.provider_pb2.ComputeProvider, + type_hint: type["PayloadBackedOutput[typing.Any]"], + ) -> "PayloadBackedOutput[typing.Any]": + (output_type,) = typing.get_args(type_hint) or (typing.Any,) + if not value.HasField("details"): + raise ValueError("missing required field PayloadBackedOutput.details") + details = payload_from_proto(value.details, output_type) + return PayloadBackedOutput( + details=details, + ) + + @typing_extensions.override + def to_transfer_type( + self, + value: "PayloadBackedOutput[typing.Any]", + ) -> temporalio.api.compute.v1.provider_pb2.ComputeProvider: + message = temporalio.api.compute.v1.provider_pb2.ComputeProvider() + message.details.CopyFrom(payload_to_proto(value.details)) + return message + + +@typing.cast( + typing.Any, + temporalio.converter.transfer_type_convertible( + _PayloadBackedOutputTransferTypeConverter + ), +) +@dataclasses.dataclass(slots=True) +class PayloadBackedOutput(typing.Generic[OutputT]): + details: OutputT diff --git a/advanced/samples/python/wit/proto_generic_python/services.py b/advanced/samples/python/wit/proto_generic_python/services.py new file mode 100644 index 00000000..3db63ad5 --- /dev/null +++ b/advanced/samples/python/wit/proto_generic_python/services.py @@ -0,0 +1,22 @@ +# Generated by nexgen. DO NOT EDIT! + +from __future__ import annotations + +from nexusrpc import service +import typing +import temporalio.workflow + + +@service +class ProtoGenericPython: + pass + + +class ProtoGenericPythonClient: + def __init__(self, endpoint: str) -> None: + self._nexus_client: typing.Any = temporalio.workflow.create_nexus_client( + service="ProtoGenericPython", + endpoint=endpoint, + ) + + pass diff --git a/advanced/samples/python/wit/proto_oneof/_support/temporal_model_converters.py b/advanced/samples/python/wit/proto_oneof/_support/temporal_model_converters.py index 51955e20..128cc6b7 100644 --- a/advanced/samples/python/wit/proto_oneof/_support/temporal_model_converters.py +++ b/advanced/samples/python/wit/proto_oneof/_support/temporal_model_converters.py @@ -102,11 +102,13 @@ def payloads_to_proto( def payloads_from_proto( proto: common_pb2.Payloads, -) -> list[object]: - return list( - temporalio.nexus.system._current_user_payload_converter().from_payloads_wrapper( - proto - ) + type_hints: list[typing.Any] | None = None, +) -> list[typing.Any]: + if not proto.payloads: + return [] + return temporalio.nexus.system._current_user_payload_converter().from_payloads( + proto.payloads, + type_hints, ) @@ -144,10 +146,31 @@ def _payload_to_value( ) +_PayloadT = typing.TypeVar("_PayloadT") + + +@typing.overload def payload_from_proto( proto: common_pb2.Payload, -) -> object: - return _payload_to_value(proto) + type_hint: type[_PayloadT], +) -> _PayloadT: ... + + +@typing.overload +def payload_from_proto( + proto: common_pb2.Payload, + type_hint: None = None, +) -> typing.Any: ... + + +def payload_from_proto( + proto: common_pb2.Payload, + type_hint: type[typing.Any] | None = None, +) -> typing.Any: + converter = temporalio.nexus.system._current_user_payload_converter() + if type_hint is None: + return converter.from_payload(_clone_payload(proto)) + return converter.from_payload(_clone_payload(proto), type_hint) def payload_to_proto( diff --git a/advanced/samples/python/wit/proto_oneof/models.py b/advanced/samples/python/wit/proto_oneof/models.py index db8df466..21dc021b 100644 --- a/advanced/samples/python/wit/proto_oneof/models.py +++ b/advanced/samples/python/wit/proto_oneof/models.py @@ -2,7 +2,6 @@ from __future__ import annotations -import collections.abc import dataclasses import typing import typing_extensions @@ -24,7 +23,7 @@ class _OutcomeTransferTypeConverter( temporalio.converter.TransferTypeConverter[ - typing.Any, temporalio.api.update.v1.message_pb2.Outcome + "Outcome[typing.Any]", temporalio.api.update.v1.message_pb2.Outcome ] ): transfer_type: type[temporalio.api.update.v1.message_pb2.Outcome] | None = ( @@ -35,19 +34,19 @@ class _OutcomeTransferTypeConverter( def from_transfer_type( self, value: temporalio.api.update.v1.message_pb2.Outcome, - type_hint: type[typing.Any], - ) -> typing.Any: - proto = value - _oneof_value_case = proto.WhichOneof("value") + type_hint: type["Outcome[typing.Any]"], + ) -> "Outcome[typing.Any]": + (output_type,) = typing.get_args(type_hint) or (typing.Any,) + _oneof_value_case = value.WhichOneof("value") if _oneof_value_case is None: raise ValueError("missing required field Outcome.value") elif _oneof_value_case == "success": _oneof_value = ( "success", - typing.cast(typing.Any, payloads_from_proto(proto.success)), + payloads_from_proto(value.success, [output_type])[0], ) elif _oneof_value_case == "failure": - _oneof_value = ("failure", failure_from_proto(proto.failure)) + _oneof_value = ("failure", failure_from_proto(value.failure)) else: raise ValueError( f"unknown protobuf oneof case Outcome.value: {_oneof_value_case}" @@ -59,17 +58,13 @@ def from_transfer_type( @typing_extensions.override def to_transfer_type( self, - value: typing.Any, + value: "Outcome[typing.Any]", ) -> temporalio.api.update.v1.message_pb2.Outcome: message = temporalio.api.update.v1.message_pb2.Outcome() if value.value is None: raise ValueError("missing required field Outcome.value") if value.value[0] == "success": - message.success.CopyFrom( - payloads_to_proto( - typing.cast(collections.abc.Sequence[typing.Any], value.value[1]) - ) - ) + message.success.CopyFrom(payloads_to_proto([value.value[1]])) elif value.value[0] == "failure": message.failure.CopyFrom(failure_to_proto(value.value[1])) else: @@ -107,36 +102,35 @@ def from_transfer_type( value: temporalio.api.workflowservice.v1.request_response_pb2.PauseActivityRequest, type_hint: type["PauseActivityRequest"], ) -> "PauseActivityRequest": - proto = value - if not proto.namespace: + if not value.namespace: raise ValueError("missing required field PauseActivityRequest.namespace") - namespace = proto.namespace - if not proto.identity: + namespace = value.namespace + if not value.identity: raise ValueError("missing required field PauseActivityRequest.identity") - identity = proto.identity - _oneof_activity_case = proto.WhichOneof("activity") + identity = value.identity + _oneof_activity_case = value.WhichOneof("activity") if _oneof_activity_case is None: _oneof_activity = None elif _oneof_activity_case == "id": - _oneof_activity = ("id", proto.id) + _oneof_activity = ("id", value.id) elif _oneof_activity_case == "type": - _oneof_activity = ("type", proto.type) + _oneof_activity = ("type", value.type) else: raise ValueError( f"unknown protobuf oneof case PauseActivityRequest.activity: {_oneof_activity_case}" ) - if not proto.reason: + if not value.reason: raise ValueError("missing required field PauseActivityRequest.reason") - reason = proto.reason - if not proto.request_id: + reason = value.reason + if not value.request_id: raise ValueError("missing required field PauseActivityRequest.request_id") - request_id = proto.request_id + request_id = value.request_id return PauseActivityRequest( namespace=namespace, execution=_WorkflowExecutionTransferTypeConverter().from_transfer_type( - proto.execution, WorkflowExecution + value.execution, WorkflowExecution ) - if proto.HasField("execution") + if value.HasField("execution") else None, identity=identity, activity=_oneof_activity, @@ -200,13 +194,12 @@ def from_transfer_type( value: temporalio.api.common.v1.message_pb2.WorkflowExecution, type_hint: type["WorkflowExecution"], ) -> "WorkflowExecution": - proto = value - if not proto.workflow_id: + if not value.workflow_id: raise ValueError("missing required field WorkflowExecution.workflow_id") - workflow_id = proto.workflow_id - if not proto.run_id: + workflow_id = value.workflow_id + if not value.run_id: raise ValueError("missing required field WorkflowExecution.run_id") - run_id = proto.run_id + run_id = value.run_id return WorkflowExecution( workflow_id=workflow_id, run_id=run_id, diff --git a/advanced/samples/python/wit/start_workflow/_support/temporal_model_converters.py b/advanced/samples/python/wit/start_workflow/_support/temporal_model_converters.py index 51955e20..128cc6b7 100644 --- a/advanced/samples/python/wit/start_workflow/_support/temporal_model_converters.py +++ b/advanced/samples/python/wit/start_workflow/_support/temporal_model_converters.py @@ -102,11 +102,13 @@ def payloads_to_proto( def payloads_from_proto( proto: common_pb2.Payloads, -) -> list[object]: - return list( - temporalio.nexus.system._current_user_payload_converter().from_payloads_wrapper( - proto - ) + type_hints: list[typing.Any] | None = None, +) -> list[typing.Any]: + if not proto.payloads: + return [] + return temporalio.nexus.system._current_user_payload_converter().from_payloads( + proto.payloads, + type_hints, ) @@ -144,10 +146,31 @@ def _payload_to_value( ) +_PayloadT = typing.TypeVar("_PayloadT") + + +@typing.overload def payload_from_proto( proto: common_pb2.Payload, -) -> object: - return _payload_to_value(proto) + type_hint: type[_PayloadT], +) -> _PayloadT: ... + + +@typing.overload +def payload_from_proto( + proto: common_pb2.Payload, + type_hint: None = None, +) -> typing.Any: ... + + +def payload_from_proto( + proto: common_pb2.Payload, + type_hint: type[typing.Any] | None = None, +) -> typing.Any: + converter = temporalio.nexus.system._current_user_payload_converter() + if type_hint is None: + return converter.from_payload(_clone_payload(proto)) + return converter.from_payload(_clone_payload(proto), type_hint) def payload_to_proto( diff --git a/advanced/samples/python/wit/start_workflow/models.py b/advanced/samples/python/wit/start_workflow/models.py index 6e73c1db..c7648fcc 100644 --- a/advanced/samples/python/wit/start_workflow/models.py +++ b/advanced/samples/python/wit/start_workflow/models.py @@ -39,24 +39,23 @@ def from_transfer_type( value: temporalio.api.workflowservice.v1.request_response_pb2.StartWorkflowExecutionRequest, type_hint: type["StartWorkflowRequest"], ) -> "StartWorkflowRequest": - proto = value - if not proto.HasField("workflow_type"): + if not value.HasField("workflow_type"): raise ValueError("missing required field StartWorkflowRequest.workflow") - workflow = workflow_type_from_proto(proto.workflow_type) - if not proto.workflow_id: + workflow = workflow_type_from_proto(value.workflow_type) + if not value.workflow_id: raise ValueError("missing required field StartWorkflowRequest.workflow_id") - workflow_id = proto.workflow_id - if not proto.HasField("task_queue"): + workflow_id = value.workflow_id + if not value.HasField("task_queue"): raise ValueError("missing required field StartWorkflowRequest.task_queue") - task_queue = task_queue_from_proto(proto.task_queue) + task_queue = task_queue_from_proto(value.task_queue) return StartWorkflowRequest( workflow=workflow, workflow_id=workflow_id, task_queue=task_queue, - workflow_start_delay=duration_from_proto(proto.workflow_start_delay) - if proto.HasField("workflow_start_delay") + workflow_start_delay=duration_from_proto(value.workflow_start_delay) + if value.HasField("workflow_start_delay") else None, - namespace=proto.namespace, + namespace=value.namespace, ) @typing_extensions.override @@ -107,9 +106,8 @@ def from_transfer_type( value: temporalio.api.workflowservice.v1.request_response_pb2.StartWorkflowExecutionResponse, type_hint: type["StartWorkflowResult"], ) -> "StartWorkflowResult": - proto = value return StartWorkflowResult( - run_id=proto.run_id if bool(proto.run_id) else None, + run_id=value.run_id if bool(value.run_id) else None, ) @typing_extensions.override @@ -150,20 +148,19 @@ def from_transfer_type( value: temporalio.api.workflowservice.v1.request_response_pb2.RequestCancelWorkflowExecutionRequest, type_hint: type["CancelWorkflowRequest"], ) -> "CancelWorkflowRequest": - proto = value - if not proto.HasField("workflow_execution"): + if not value.HasField("workflow_execution"): raise ValueError( "missing required field CancelWorkflowRequest.workflow_execution" ) workflow_execution = ( _WorkflowExecutionTransferTypeConverter().from_transfer_type( - proto.workflow_execution, WorkflowExecution + value.workflow_execution, WorkflowExecution ) ) return CancelWorkflowRequest( - namespace=proto.namespace, + namespace=value.namespace, workflow_execution=workflow_execution, - reason=proto.reason if bool(proto.reason) else None, + reason=value.reason if bool(value.reason) else None, ) @typing_extensions.override @@ -208,13 +205,12 @@ def from_transfer_type( value: temporalio.api.common.v1.message_pb2.WorkflowExecution, type_hint: type["WorkflowExecution"], ) -> "WorkflowExecution": - proto = value - if not proto.workflow_id: + if not value.workflow_id: raise ValueError("missing required field WorkflowExecution.workflow_id") - workflow_id = proto.workflow_id + workflow_id = value.workflow_id return WorkflowExecution( workflow_id=workflow_id, - run_id=proto.run_id if bool(proto.run_id) else None, + run_id=value.run_id if bool(value.run_id) else None, ) @typing_extensions.override diff --git a/advanced/samples/python/wit/type_roundtrip/_support/temporal_model_converters.py b/advanced/samples/python/wit/type_roundtrip/_support/temporal_model_converters.py index 51955e20..128cc6b7 100644 --- a/advanced/samples/python/wit/type_roundtrip/_support/temporal_model_converters.py +++ b/advanced/samples/python/wit/type_roundtrip/_support/temporal_model_converters.py @@ -102,11 +102,13 @@ def payloads_to_proto( def payloads_from_proto( proto: common_pb2.Payloads, -) -> list[object]: - return list( - temporalio.nexus.system._current_user_payload_converter().from_payloads_wrapper( - proto - ) + type_hints: list[typing.Any] | None = None, +) -> list[typing.Any]: + if not proto.payloads: + return [] + return temporalio.nexus.system._current_user_payload_converter().from_payloads( + proto.payloads, + type_hints, ) @@ -144,10 +146,31 @@ def _payload_to_value( ) +_PayloadT = typing.TypeVar("_PayloadT") + + +@typing.overload def payload_from_proto( proto: common_pb2.Payload, -) -> object: - return _payload_to_value(proto) + type_hint: type[_PayloadT], +) -> _PayloadT: ... + + +@typing.overload +def payload_from_proto( + proto: common_pb2.Payload, + type_hint: None = None, +) -> typing.Any: ... + + +def payload_from_proto( + proto: common_pb2.Payload, + type_hint: type[typing.Any] | None = None, +) -> typing.Any: + converter = temporalio.nexus.system._current_user_payload_converter() + if type_hint is None: + return converter.from_payload(_clone_payload(proto)) + return converter.from_payload(_clone_payload(proto), type_hint) def payload_to_proto( diff --git a/advanced/samples/python/wit/type_roundtrip/models.py b/advanced/samples/python/wit/type_roundtrip/models.py index 98c26dcb..f40d7ebd 100644 --- a/advanced/samples/python/wit/type_roundtrip/models.py +++ b/advanced/samples/python/wit/type_roundtrip/models.py @@ -39,22 +39,21 @@ def from_transfer_type( value: temporalio.api.activity.v1.message_pb2.ActivityOptions, type_hint: type["ActivityOptions"], ) -> "ActivityOptions": - proto = value - if not proto.HasField("retry_policy"): + if not value.HasField("retry_policy"): raise ValueError("missing required field ActivityOptions.retry_policy") - retry_policy = retry_policy_from_proto(proto.retry_policy) + retry_policy = retry_policy_from_proto(value.retry_policy) return ActivityOptions( - task_queue=task_queue_from_proto(proto.task_queue) - if proto.HasField("task_queue") + task_queue=task_queue_from_proto(value.task_queue) + if value.HasField("task_queue") else None, retry_policy=retry_policy, schedule_to_close_timeout=duration_from_proto( - proto.schedule_to_close_timeout + value.schedule_to_close_timeout ) - if proto.HasField("schedule_to_close_timeout") + if value.HasField("schedule_to_close_timeout") else None, - priority=priority_from_proto(proto.priority) - if proto.HasField("priority") + priority=priority_from_proto(value.priority) + if value.HasField("priority") else None, ) @@ -104,10 +103,9 @@ def from_transfer_type( value: temporalio.api.command.v1.message_pb2.FailWorkflowExecutionCommandAttributes, type_hint: type["FailureContainer"], ) -> "FailureContainer": - proto = value return FailureContainer( - failure=failure_from_proto(proto.failure) - if proto.HasField("failure") + failure=failure_from_proto(value.failure) + if value.HasField("failure") else None, ) diff --git a/advanced/samples/python/wit/type_showcase/_support/temporal_model_converters.py b/advanced/samples/python/wit/type_showcase/_support/temporal_model_converters.py index 51955e20..128cc6b7 100644 --- a/advanced/samples/python/wit/type_showcase/_support/temporal_model_converters.py +++ b/advanced/samples/python/wit/type_showcase/_support/temporal_model_converters.py @@ -102,11 +102,13 @@ def payloads_to_proto( def payloads_from_proto( proto: common_pb2.Payloads, -) -> list[object]: - return list( - temporalio.nexus.system._current_user_payload_converter().from_payloads_wrapper( - proto - ) + type_hints: list[typing.Any] | None = None, +) -> list[typing.Any]: + if not proto.payloads: + return [] + return temporalio.nexus.system._current_user_payload_converter().from_payloads( + proto.payloads, + type_hints, ) @@ -144,10 +146,31 @@ def _payload_to_value( ) +_PayloadT = typing.TypeVar("_PayloadT") + + +@typing.overload def payload_from_proto( proto: common_pb2.Payload, -) -> object: - return _payload_to_value(proto) + type_hint: type[_PayloadT], +) -> _PayloadT: ... + + +@typing.overload +def payload_from_proto( + proto: common_pb2.Payload, + type_hint: None = None, +) -> typing.Any: ... + + +def payload_from_proto( + proto: common_pb2.Payload, + type_hint: type[typing.Any] | None = None, +) -> typing.Any: + converter = temporalio.nexus.system._current_user_payload_converter() + if type_hint is None: + return converter.from_payload(_clone_payload(proto)) + return converter.from_payload(_clone_payload(proto), type_hint) def payload_to_proto( diff --git a/advanced/samples/python/wit/user_service/_support/temporal_model_converters.py b/advanced/samples/python/wit/user_service/_support/temporal_model_converters.py index 51955e20..128cc6b7 100644 --- a/advanced/samples/python/wit/user_service/_support/temporal_model_converters.py +++ b/advanced/samples/python/wit/user_service/_support/temporal_model_converters.py @@ -102,11 +102,13 @@ def payloads_to_proto( def payloads_from_proto( proto: common_pb2.Payloads, -) -> list[object]: - return list( - temporalio.nexus.system._current_user_payload_converter().from_payloads_wrapper( - proto - ) + type_hints: list[typing.Any] | None = None, +) -> list[typing.Any]: + if not proto.payloads: + return [] + return temporalio.nexus.system._current_user_payload_converter().from_payloads( + proto.payloads, + type_hints, ) @@ -144,10 +146,31 @@ def _payload_to_value( ) +_PayloadT = typing.TypeVar("_PayloadT") + + +@typing.overload def payload_from_proto( proto: common_pb2.Payload, -) -> object: - return _payload_to_value(proto) + type_hint: type[_PayloadT], +) -> _PayloadT: ... + + +@typing.overload +def payload_from_proto( + proto: common_pb2.Payload, + type_hint: None = None, +) -> typing.Any: ... + + +def payload_from_proto( + proto: common_pb2.Payload, + type_hint: type[typing.Any] | None = None, +) -> typing.Any: + converter = temporalio.nexus.system._current_user_payload_converter() + if type_hint is None: + return converter.from_payload(_clone_payload(proto)) + return converter.from_payload(_clone_payload(proto), type_hint) def payload_to_proto( diff --git a/advanced/samples/python/wit/workflow_service/_support/temporal_model_converters.py b/advanced/samples/python/wit/workflow_service/_support/temporal_model_converters.py index 51955e20..128cc6b7 100644 --- a/advanced/samples/python/wit/workflow_service/_support/temporal_model_converters.py +++ b/advanced/samples/python/wit/workflow_service/_support/temporal_model_converters.py @@ -102,11 +102,13 @@ def payloads_to_proto( def payloads_from_proto( proto: common_pb2.Payloads, -) -> list[object]: - return list( - temporalio.nexus.system._current_user_payload_converter().from_payloads_wrapper( - proto - ) + type_hints: list[typing.Any] | None = None, +) -> list[typing.Any]: + if not proto.payloads: + return [] + return temporalio.nexus.system._current_user_payload_converter().from_payloads( + proto.payloads, + type_hints, ) @@ -144,10 +146,31 @@ def _payload_to_value( ) +_PayloadT = typing.TypeVar("_PayloadT") + + +@typing.overload def payload_from_proto( proto: common_pb2.Payload, -) -> object: - return _payload_to_value(proto) + type_hint: type[_PayloadT], +) -> _PayloadT: ... + + +@typing.overload +def payload_from_proto( + proto: common_pb2.Payload, + type_hint: None = None, +) -> typing.Any: ... + + +def payload_from_proto( + proto: common_pb2.Payload, + type_hint: type[typing.Any] | None = None, +) -> typing.Any: + converter = temporalio.nexus.system._current_user_payload_converter() + if type_hint is None: + return converter.from_payload(_clone_payload(proto)) + return converter.from_payload(_clone_payload(proto), type_hint) def payload_to_proto( diff --git a/advanced/samples/python/wit/workflow_service/models.py b/advanced/samples/python/wit/workflow_service/models.py index 598d7fc4..147cfcc3 100644 --- a/advanced/samples/python/wit/workflow_service/models.py +++ b/advanced/samples/python/wit/workflow_service/models.py @@ -61,76 +61,75 @@ def from_transfer_type( value: temporalio.api.workflowservice.v1.request_response_pb2.SignalWithStartWorkflowExecutionRequest, type_hint: type["SignalWithStartWorkflowRequest"], ) -> "SignalWithStartWorkflowRequest": - proto = value - if not proto.HasField("workflow_type"): + if not value.HasField("workflow_type"): raise ValueError( "missing required field SignalWithStartWorkflowRequest.workflow" ) - workflow = workflow_type_from_proto(proto.workflow_type) - if not proto.workflow_id: + workflow = workflow_type_from_proto(value.workflow_type) + if not value.workflow_id: raise ValueError("missing required field SignalWithStartWorkflowRequest.id") - id = proto.workflow_id - if not proto.HasField("task_queue"): + id = value.workflow_id + if not value.HasField("task_queue"): raise ValueError( "missing required field SignalWithStartWorkflowRequest.task_queue" ) - task_queue = task_queue_from_proto(proto.task_queue) - if not proto.signal_name: + task_queue = task_queue_from_proto(value.task_queue) + if not value.signal_name: raise ValueError( "missing required field SignalWithStartWorkflowRequest.signal" ) - signal = proto.signal_name + signal = value.signal_name return SignalWithStartWorkflowRequest( workflow=workflow, - args=payloads_from_proto(proto.input) if proto.HasField("input") else None, + args=payloads_from_proto(value.input) if value.HasField("input") else None, id=id, task_queue=task_queue, signal=signal, - signal_args=payloads_from_proto(proto.signal_input) - if proto.HasField("signal_input") + signal_args=payloads_from_proto(value.signal_input) + if value.HasField("signal_input") else None, - execution_timeout=duration_from_proto(proto.workflow_execution_timeout) - if proto.HasField("workflow_execution_timeout") + execution_timeout=duration_from_proto(value.workflow_execution_timeout) + if value.HasField("workflow_execution_timeout") else None, - run_timeout=duration_from_proto(proto.workflow_run_timeout) - if proto.HasField("workflow_run_timeout") + run_timeout=duration_from_proto(value.workflow_run_timeout) + if value.HasField("workflow_run_timeout") else None, - task_timeout=duration_from_proto(proto.workflow_task_timeout) - if proto.HasField("workflow_task_timeout") + task_timeout=duration_from_proto(value.workflow_task_timeout) + if value.HasField("workflow_task_timeout") else None, id_reuse_policy=workflow_id_reuse_policy_from_proto( - proto.workflow_id_reuse_policy + value.workflow_id_reuse_policy ), id_conflict_policy=workflow_id_conflict_policy_from_proto( - proto.workflow_id_conflict_policy + value.workflow_id_conflict_policy ) - if proto.workflow_id_conflict_policy != 0 + if value.workflow_id_conflict_policy != 0 else None, - retry_policy=retry_policy_from_proto(proto.retry_policy) - if proto.HasField("retry_policy") + retry_policy=retry_policy_from_proto(value.retry_policy) + if value.HasField("retry_policy") else None, - cron_schedule=proto.cron_schedule if bool(proto.cron_schedule) else None, - memo=memo_from_proto(proto.memo) if proto.HasField("memo") else None, - search_attributes=search_attributes_from_proto(proto.search_attributes) - if proto.HasField("search_attributes") + cron_schedule=value.cron_schedule if bool(value.cron_schedule) else None, + memo=memo_from_proto(value.memo) if value.HasField("memo") else None, + search_attributes=search_attributes_from_proto(value.search_attributes) + if value.HasField("search_attributes") else None, - priority=priority_from_proto(proto.priority) - if proto.HasField("priority") + priority=priority_from_proto(value.priority) + if value.HasField("priority") else None, versioning_override=versioning_override_from_proto( - proto.versioning_override + value.versioning_override ) - if proto.HasField("versioning_override") + if value.HasField("versioning_override") else None, - start_delay=duration_from_proto(proto.workflow_start_delay) - if proto.HasField("workflow_start_delay") + start_delay=duration_from_proto(value.workflow_start_delay) + if value.HasField("workflow_start_delay") else None, user_metadata=_UserMetadataTransferTypeConverter().from_transfer_type( - proto.user_metadata, UserMetadata + value.user_metadata, UserMetadata ) - if proto.HasField("user_metadata") + if value.HasField("user_metadata") else None, - namespace=proto.namespace, + namespace=value.namespace, ) @typing_extensions.override @@ -241,13 +240,12 @@ def from_transfer_type( value: temporalio.api.sdk.v1.user_metadata_pb2.UserMetadata, type_hint: type["UserMetadata"], ) -> "UserMetadata": - proto = value return UserMetadata( - static_summary=payload_from_proto(proto.summary) - if proto.HasField("summary") + static_summary=payload_from_proto(value.summary) + if value.HasField("summary") else None, - static_details=payload_from_proto(proto.details) - if proto.HasField("details") + static_details=payload_from_proto(value.details) + if value.HasField("details") else None, ) @@ -290,10 +288,9 @@ def from_transfer_type( value: temporalio.api.workflowservice.v1.request_response_pb2.SignalWithStartWorkflowExecutionResponse, type_hint: type["SignalWithStartWorkflowResponse"], ) -> "SignalWithStartWorkflowResponse": - proto = value return SignalWithStartWorkflowResponse( - run_id=proto.run_id if bool(proto.run_id) else None, - started=proto.started if bool(proto.started) else None, + run_id=value.run_id if bool(value.run_id) else None, + started=value.started if bool(value.started) else None, ) @typing_extensions.override diff --git a/src/generator/proto/python.rs b/src/generator/proto/python.rs index add4869e..5111bc00 100644 --- a/src/generator/proto/python.rs +++ b/src/generator/proto/python.rs @@ -353,7 +353,7 @@ fn generated_wire_conversion( return Some(WireValueConversion { annotation: model_name.clone(), from_wire: format!( - "_{model_name}TransferTypeConverter().from_transfer_type({{wire}}, {model_name})" + "_{model_name}TransferTypeConverter().from_transfer_type({{wire}}, {{type_hint}})" ), to_wire: format!("_{model_name}TransferTypeConverter().to_transfer_type({{value}})"), imports: PythonImports::default(), @@ -441,15 +441,25 @@ fn field_read( field: &RecordFieldSpec, resolved_value_type: &ResolvedFieldType, generic_carrier: Option, + type_arguments: &[(String, String)], policy: WireReadPolicy, ) -> RenderedWireRead { - let proto_expr = format!("proto.{proto_name}"); + let proto_expr = format!("value.{proto_name}"); let value_expr = match generic_carrier { - Some(carrier) => generic_carrier_from_proto_expr(carrier, resolved_value_type, &proto_expr), + Some(carrier) => generic_carrier_from_proto_expr( + carrier, + resolved_value_type, + &proto_expr, + type_arguments, + ), None => match &field.field_type { - PlannedType::Map(_, _) => map_value_from_proto_expr(resolved_value_type, proto_name), - PlannedType::List(_) => repeated_from_proto_expr(resolved_value_type, proto_name), - _ => from_proto_value_expr(resolved_value_type, &proto_expr), + PlannedType::Map(_, _) => { + map_value_from_proto_expr(resolved_value_type, proto_name, type_arguments) + } + PlannedType::List(_) => { + repeated_from_proto_expr(resolved_value_type, proto_name, type_arguments) + } + _ => from_proto_value_expr(resolved_value_type, &proto_expr, type_arguments), }, }; @@ -457,10 +467,10 @@ fn field_read( WireReadPolicy::Required { missing_error } => { let mut setup_lines = Vec::new(); if field_has_proto_presence(field) { - setup_lines.push(format!("if not proto.HasField(\"{proto_name}\"):")); + setup_lines.push(format!("if not value.HasField(\"{proto_name}\"):")); setup_lines.push(format!(" raise ValueError({missing_error})")); } else if matches!(&field.field_type, PlannedType::String | PlannedType::Bytes) { - setup_lines.push(format!("if not proto.{proto_name}:")); + setup_lines.push(format!("if not value.{proto_name}:")); setup_lines.push(format!(" raise ValueError({missing_error})")); } setup_lines.push(format!("{attr_name} = {value_expr}")); @@ -510,14 +520,73 @@ fn field_write( fn generic_carrier_from_proto_expr( carrier: ProtoGenericCarrier, - _resolved_type: &ResolvedFieldType, + resolved_type: &ResolvedFieldType, proto_expr: &str, + type_arguments: &[(String, String)], ) -> String { - let converter = match carrier { - ProtoGenericCarrier::Payload => "payload_from_proto", - ProtoGenericCarrier::Payloads => "payloads_from_proto", + match carrier { + ProtoGenericCarrier::Payload => format!( + "payload_from_proto({proto_expr}, {})", + concrete_type_hint(&resolved_type.annotation, type_arguments) + ), + ProtoGenericCarrier::Payloads => format!( + "payloads_from_proto({proto_expr}, [{}])[0]", + concrete_type_hint(&resolved_type.annotation, type_arguments) + ), + } +} + +fn concrete_type_hint(annotation: &str, type_arguments: &[(String, String)]) -> String { + let mut output = String::new(); + let mut identifier = String::new(); + let flush_identifier = |output: &mut String, identifier: &mut String| { + if identifier.is_empty() { + return; + } + if let Some((_, replacement)) = type_arguments + .iter() + .find(|(parameter, _)| parameter == identifier) + { + output.push_str(replacement); + } else { + output.push_str(identifier); + } + identifier.clear(); }; - format!("typing.cast(typing.Any, {converter}({proto_expr}))") + + for character in annotation.chars() { + if character.is_ascii_alphanumeric() || character == '_' { + identifier.push(character); + } else { + flush_identifier(&mut output, &mut identifier); + output.push(character); + } + } + flush_identifier(&mut output, &mut identifier); + output +} + +fn runtime_type_arguments(model: &RenderedModel) -> Vec<(String, String)> { + let mut arguments = Vec::new(); + for parameter in &model.type_parameters { + let stem = parameter + .strip_suffix('T') + .filter(|stem| !stem.is_empty()) + .unwrap_or(parameter); + let base_name = format!("{}_type", stem.to_snake_case()); + let mut name = base_name.clone(); + let mut suffix = 1; + while arguments + .iter() + .any(|(_, existing): &(String, String)| existing == &name) + || model.fields.iter().any(|field| field.attr_name == name) + { + suffix += 1; + name = format!("{base_name}_{suffix}"); + } + arguments.push((parameter.clone(), name)); + } + arguments } fn generic_carrier_to_proto_lines( @@ -533,9 +602,7 @@ fn generic_carrier_to_proto_lines( let indent = if optional_guard { " " } else { "" }; let converted = match carrier { ProtoGenericCarrier::Payload => format!("payload_to_proto({value_expr})"), - ProtoGenericCarrier::Payloads => format!( - "payloads_to_proto(typing.cast(collections.abc.Sequence[typing.Any], {value_expr}))" - ), + ProtoGenericCarrier::Payloads => format!("payloads_to_proto([{value_expr}])"), }; lines.push(format!( "{indent}message.{proto_name}.CopyFrom({converted})" @@ -577,7 +644,7 @@ fn optional_from_proto_expr( value_expr: String, ) -> String { if field_has_proto_presence(field) { - format!("{value_expr} if proto.HasField(\"{proto_name}\") else None") + format!("{value_expr} if value.HasField(\"{proto_name}\") else None") } else if let Some(present_expr) = no_presence_default_value_present_expr(field, resolved_type, proto_name) { @@ -593,12 +660,12 @@ fn no_presence_default_value_present_expr( proto_name: &str, ) -> Option { match resolved_type.kind { - ResolvedFieldKind::Enum => Some(format!("proto.{proto_name} != 0")), + ResolvedFieldKind::Enum => Some(format!("value.{proto_name} != 0")), ResolvedFieldKind::Scalar => match field.field_type.without_option() { PlannedType::Bool | PlannedType::String | PlannedType::Bytes => { - Some(format!("bool(proto.{proto_name})")) + Some(format!("bool(value.{proto_name})")) } - PlannedType::Int(_) | PlannedType::Float => Some(format!("proto.{proto_name} != 0")), + PlannedType::Int(_) | PlannedType::Float => Some(format!("value.{proto_name} != 0")), _ => None, }, _ => None, @@ -612,55 +679,76 @@ fn defaulted_from_proto_expr( default_expr: String, ) -> String { if field_has_proto_presence(field) { - format!("{value_expr} if proto.HasField(\"{proto_name}\") else {default_expr}") + format!("{value_expr} if value.HasField(\"{proto_name}\") else {default_expr}") } else { value_expr } } -fn repeated_from_proto_expr(resolved_type: &ResolvedFieldType, proto_name: &str) -> String { +fn repeated_from_proto_expr( + resolved_type: &ResolvedFieldType, + proto_name: &str, + type_arguments: &[(String, String)], +) -> String { match resolved_type.kind { ResolvedFieldKind::Message => format!( - "[{} for value in proto.{proto_name}]", + "[{} for item in value.{proto_name}]", resolved_type .wire_conversion .as_ref() .expect("message conversion should be present") - .from_wire_expr("value") + .from_wire_expr_with_type_hint( + "item", + &concrete_type_hint(&resolved_type.annotation, type_arguments), + ) ), ResolvedFieldKind::Enum => format!( - "[{} for value in proto.{proto_name}]", - enum_from_proto_expr(resolved_type, "value") + "[{} for item in value.{proto_name}]", + enum_from_proto_expr(resolved_type, "item") ), - _ => format!("list(proto.{proto_name})"), + _ => format!("list(value.{proto_name})"), } } -fn map_value_from_proto_expr(map_value_type: &ResolvedFieldType, proto_name: &str) -> String { +fn map_value_from_proto_expr( + map_value_type: &ResolvedFieldType, + proto_name: &str, + type_arguments: &[(String, String)], +) -> String { match map_value_type.kind { ResolvedFieldKind::Message => format!( - "{{key: {} for key, value in proto.{proto_name}.items()}}", + "{{key: {} for key, item in value.{proto_name}.items()}}", map_value_type .wire_conversion .as_ref() .expect("message conversion should be present") - .from_wire_expr("value") + .from_wire_expr_with_type_hint( + "item", + &concrete_type_hint(&map_value_type.annotation, type_arguments), + ) ), ResolvedFieldKind::Enum => format!( - "{{key: {} for key, value in proto.{proto_name}.items()}}", - enum_from_proto_expr(map_value_type, "value") + "{{key: {} for key, item in value.{proto_name}.items()}}", + enum_from_proto_expr(map_value_type, "item") ), - _ => format!("{{key: value for key, value in proto.{proto_name}.items()}}"), + _ => format!("{{key: item for key, item in value.{proto_name}.items()}}"), } } -fn from_proto_value_expr(resolved_type: &ResolvedFieldType, proto_expr: &str) -> String { +fn from_proto_value_expr( + resolved_type: &ResolvedFieldType, + proto_expr: &str, + type_arguments: &[(String, String)], +) -> String { match resolved_type.kind { ResolvedFieldKind::Message => resolved_type .wire_conversion .as_ref() .expect("message conversion should be present") - .from_wire_expr(proto_expr), + .from_wire_expr_with_type_hint( + proto_expr, + &concrete_type_hint(&resolved_type.annotation, type_arguments), + ), ResolvedFieldKind::Enum => enum_from_proto_expr(resolved_type, proto_expr), _ => proto_expr.to_string(), } @@ -834,10 +922,17 @@ fn render_record_wire_block( .filter(|field| field.visibility != crate::spec::RecordFieldVisibility::Omitted) .map(|field| ModelBackend::analyze_field(api_plan, planned_model, field, resolve_type)) .collect::>>()?; + let type_arguments = runtime_type_arguments(model); let converter_model_annotation = if model.type_parameters.is_empty() { format!("\"{}\"", model.name) } else { - "typing.Any".to_string() + format!( + "\"{}[{}]\"", + model.name, + std::iter::repeat_n("typing.Any", model.type_parameters.len()) + .collect::>() + .join(", ") + ) }; let mut output = String::new(); let converter_name = format!("_{}TransferTypeConverter", model.name); @@ -873,7 +968,29 @@ fn render_record_wire_block( output.push_str(&model.name); output.push_str("()\n"); } else { - output.push_str(" proto = value\n"); + if !type_arguments.is_empty() { + output.push_str(" "); + output.push_str( + &type_arguments + .iter() + .map(|(_, argument)| argument.as_str()) + .collect::>() + .join(", "), + ); + if type_arguments.len() == 1 { + output.push(','); + } + output.push_str(" = typing.get_args(type_hint) or ("); + output.push_str( + &std::iter::repeat_n("typing.Any", type_arguments.len()) + .collect::>() + .join(", "), + ); + if type_arguments.len() == 1 { + output.push(','); + } + output.push_str(")\n"); + } for (((field_name, planned_field), rendered_field), proto_field) in planned_model .fields .iter() @@ -892,6 +1009,7 @@ fn render_record_wire_block( planned_field, &rendered_field.wire_value_type, proto_field.generic_carrier, + &type_arguments, field_read_policy(&model.name, rendered_field), ) }, @@ -900,6 +1018,7 @@ fn render_record_wire_block( &model.name, &rendered_field.attr_name, oneof, + &type_arguments, matches!( rendered_field.default_kind, PythonFieldDefaultKind::Required @@ -935,6 +1054,7 @@ fn render_record_wire_block( planned_field, &rendered_field.wire_value_type, proto_field.generic_carrier, + &type_arguments, field_read_policy(&model.name, rendered_field), ) }, @@ -943,6 +1063,7 @@ fn render_record_wire_block( &model.name, &rendered_field.attr_name, oneof, + &type_arguments, matches!( rendered_field.default_kind, PythonFieldDefaultKind::Required @@ -1121,13 +1242,14 @@ fn oneof_field_read( model_name: &str, attr_name: &str, oneof: &ProtoOneof, + type_arguments: &[(String, String)], required: bool, ) -> RenderedWireRead { let local_var = format!("_oneof_{attr_name}"); let case_var = format!("{local_var}_case"); let mut setup_lines = vec![ format!( - "{case_var} = proto.WhichOneof({})", + "{case_var} = value.WhichOneof({})", python_string_literal(&oneof.name) ), format!("if {case_var} is None:"), @@ -1152,11 +1274,13 @@ fn oneof_field_read( Some(carrier) => generic_carrier_from_proto_expr( carrier, &case.payload_type, - &format!("proto.{}", case.proto_name), + &format!("value.{}", case.proto_name), + type_arguments, ), None => from_proto_value_expr( &case.payload_type, - &format!("proto.{}", case.proto_name), + &format!("value.{}", case.proto_name), + type_arguments, ), } )); diff --git a/src/generator/python.rs b/src/generator/python.rs index 906188f5..b6aacb31 100644 --- a/src/generator/python.rs +++ b/src/generator/python.rs @@ -2730,7 +2730,17 @@ pub(in crate::generator) struct WireValueConversion { impl WireValueConversion { pub(in crate::generator) fn from_wire_expr(&self, wire_expr: &str) -> String { - self.from_wire.replace("{wire}", wire_expr) + self.from_wire_expr_with_type_hint(wire_expr, &self.annotation) + } + + pub(in crate::generator) fn from_wire_expr_with_type_hint( + &self, + wire_expr: &str, + type_hint_expr: &str, + ) -> String { + self.from_wire + .replace("{wire}", wire_expr) + .replace("{type_hint}", type_hint_expr) } pub(in crate::generator) fn to_wire_expr(&self, value_expr: &str) -> String { @@ -7790,9 +7800,9 @@ class Example(enum.Enum): "raise ValueError(\"missing required field ActivityOptions.retry_policy\")" ) ); - assert!(type_roundtrip_output.contains("if not proto.HasField(\"retry_policy\"):\n raise ValueError(\"missing required field ActivityOptions.retry_policy\")")); + assert!(type_roundtrip_output.contains("if not value.HasField(\"retry_policy\"):\n raise ValueError(\"missing required field ActivityOptions.retry_policy\")")); assert!(type_roundtrip_output.contains("retry_policy_from_proto(")); - assert!(type_roundtrip_output.contains("proto.retry_policy")); + assert!(type_roundtrip_output.contains("value.retry_policy")); assert!(!type_roundtrip_output.contains("async def retry_policy_operation(")); assert!(type_roundtrip_output.contains("async def activity_options_operation(")); assert!(type_roundtrip_output.contains("task_queue: str | None = None,")); diff --git a/tests/generate_python.rs b/tests/generate_python.rs index 4df5638f..4c206ec8 100644 --- a/tests/generate_python.rs +++ b/tests/generate_python.rs @@ -759,26 +759,25 @@ fn python_standalone_proto_oneof_models_are_exported_and_converted() { .expect("standalone Python package should include __init__.py"); assert!(models.contains("class Outcome(typing.Generic[OutputT]):")); + assert!(models.contains("\"Outcome[typing.Any]\"")); assert!(models.contains("OutcomeValue = (")); assert!(models.contains(" value: OutcomeValue[OutputT]\n")); assert!(!models.contains("value: OutcomeValue[OutputT] | None")); assert!(!models.contains("class Failure:")); assert!(!models.contains("class Payloads:")); - assert!(models.contains("_oneof_value_case = proto.WhichOneof(\"value\")")); + assert!(models.contains("_oneof_value_case = value.WhichOneof(\"value\")")); assert!(models.contains( "if _oneof_value_case is None:\n raise ValueError(\"missing required field Outcome.value\")" )); assert!(models.contains( - "_oneof_value = (\"success\", typing.cast(typing.Any, payloads_from_proto(proto.success)))" + "_oneof_value = (\"success\", payloads_from_proto(value.success, [output_type])[0])" )); - assert!(models.contains("_oneof_value = (\"failure\", failure_from_proto(proto.failure))")); + assert!(models.contains("_oneof_value = (\"failure\", failure_from_proto(value.failure))")); assert!(models.contains("if value.value[0] == \"success\":")); assert!(models.contains( "if value.value is None:\n raise ValueError(\"missing required field Outcome.value\")" )); - assert!(models.contains( - "message.success.CopyFrom(payloads_to_proto(typing.cast(collections.abc.Sequence[typing.Any], value.value[1])))" - )); + assert!(models.contains("message.success.CopyFrom(payloads_to_proto([value.value[1]]))")); assert!(models.contains("elif value.value[0] == \"failure\":")); assert!(models.contains("message.failure.CopyFrom(failure_to_proto(value.value[1]))")); assert!(models.contains("raise ValueError(f\"unknown protobuf oneof tag Outcome.value:")); @@ -790,7 +789,7 @@ fn python_standalone_proto_oneof_models_are_exported_and_converted() { assert!(models.contains("reason: str")); assert!(models.contains("request_id: str")); assert!(models.contains("class WorkflowExecution:")); - assert!(models.contains("_oneof_activity_case = proto.WhichOneof(\"activity\")")); + assert!(models.contains("_oneof_activity_case = value.WhichOneof(\"activity\")")); assert!( models.contains("if _oneof_activity_case is None:\n _oneof_activity = None") ); @@ -798,6 +797,41 @@ fn python_standalone_proto_oneof_models_are_exported_and_converted() { assert!(package_init.contains("PauseActivityRequest,")); } +#[test] +fn python_proto_generics_propagate_payload_type_hints() { + let root = project_root(); + let package = generate_python_package_files( + &example_input_paths(&root, "proto-generic-python"), + &[descriptor_path(&root)], + ); + let models = package + .get(&PathBuf::from("models.py")) + .expect("proto generic models should include models.py"); + let support = package + .get(&PathBuf::from("_support/temporal_model_converters.py")) + .expect("proto generic models should include the Temporal converter support module"); + + assert!(models.contains("class PayloadBackedEnvelope(typing.Generic[OutputT, ContextT]):")); + assert!(models.contains("\"PayloadBackedEnvelope[typing.Any, typing.Any]\"")); + assert!(models.contains("\"PayloadBackedOutput[typing.Any]\"")); + assert!(models.contains("\"PayloadBackedContext[typing.Any]\"")); + assert!(models.contains( + "output_type, context_type = typing.get_args(type_hint) or (typing.Any, typing.Any)" + )); + assert!(models.contains( + "_PayloadBackedOutputTransferTypeConverter().from_transfer_type(value.provider, PayloadBackedOutput[output_type])" + )); + assert!(models.contains( + "_PayloadBackedContextTransferTypeConverter().from_transfer_type(value.scaler, PayloadBackedContext[context_type])" + )); + assert!(models.contains("output_type, = typing.get_args(type_hint) or (typing.Any,)")); + assert!(models.contains("payload_from_proto(value.details, output_type)")); + assert!(models.contains("context_type, = typing.get_args(type_hint) or (typing.Any,)")); + assert!(models.contains("payload_from_proto(value.details, context_type)")); + assert!(support.contains("type_hint: type[typing.Any] | None = None,")); + assert!(support.contains("converter.from_payload(_clone_payload(proto), type_hint)")); +} + #[test] fn python_rejects_support_namespace() { let root = project_root();