diff --git a/CHANGELOG.md b/CHANGELOG.md index a84835436..987fa2001 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -59,6 +59,10 @@ to include examples, links to docs, or any other relevant information. OpenTelemetry's threading instrumentation (enabled by strands, among others) is active; a context is now detached exactly when its token is still valid in the current `contextvars.Context`, which it stays when a workflow resumes on another pool thread. + +- Decoding a dataclass with `field(init=False)` fields no longer raises `TypeError` for an + unexpected keyword argument. Those fields are left out of the constructor call and their + serialized values are restored afterwards, including on frozen dataclasses. ### Security ## [1.34.0] - 2026-09-30 diff --git a/temporalio/converter/_payload_converter.py b/temporalio/converter/_payload_converter.py index d0537c1a5..9fab6092e 100644 --- a/temporalio/converter/_payload_converter.py +++ b/temporalio/converter/_payload_converter.py @@ -992,6 +992,7 @@ def value_to_type( fields = dataclasses.fields(hint) field_hints = get_type_hints(hint) field_values = {} + non_init_field_values = {} for field in fields: field_value = value.get(field.name, dataclasses.MISSING) # We do not check whether field is required here. Rather, we let the @@ -999,17 +1000,27 @@ def value_to_type( # missing if field_value is not dataclasses.MISSING: try: - field_values[field.name] = value_to_type( + converted = value_to_type( field_hints[field.name], field_value, custom_converters ) except Exception as err: raise TypeError( f"Failed converting field {field.name} on dataclass {hint}" ) from err + if field.init: + field_values[field.name] = converted + else: + non_init_field_values[field.name] = converted # Simply instantiate the dataclass. This will fail as expected when # missing required fields. # TODO(cretz): Want way to convert snake case to camel case? - return hint(**field_values) + obj = hint(**field_values) + # Fields with init=False can't be passed to __init__, but they are + # serialized, so restore them afterwards. object.__setattr__ is used + # because the dataclass may be frozen. + for name, field_value in non_init_field_values.items(): + object.__setattr__(obj, name, field_value) + return obj # Pydantic model instance # Pydantic users should use Pydantic v2 with diff --git a/tests/test_converter.py b/tests/test_converter.py index 30b1351a9..d9a48373e 100644 --- a/tests/test_converter.py +++ b/tests/test_converter.py @@ -560,6 +560,43 @@ def test_json_frozenset_round_trip(hint: Any, value: frozenset[int]): assert converted == value +@dataclass +class DataClassWithNonInitFields: + foo: str + computed: str = dataclasses.field(init=False) + counter: int = dataclasses.field(init=False, default=0) + + def __post_init__(self) -> None: + self.computed = self.foo.upper() + + +@dataclass(frozen=True) +class FrozenDataClassWithNonInitField: + foo: str + counter: int = dataclasses.field(init=False, default=0) + + +def test_json_dataclass_non_init_fields_round_trip(): + converter = JSONPlainPayloadConverter() + + value = DataClassWithNonInitFields("foo") + value.counter = 3 + payload = converter.to_payload(value) + assert payload + converted = converter.from_payload(payload, DataClassWithNonInitFields) + assert converted == value + assert converted.computed == "FOO" + assert converted.counter == 3 + + frozen_value = FrozenDataClassWithNonInitField("foo") + object.__setattr__(frozen_value, "counter", 3) + payload = converter.to_payload(frozen_value) + assert payload + frozen_converted = converter.from_payload(payload, FrozenDataClassWithNonInitField) + assert frozen_converted == frozen_value + assert frozen_converted.counter == 3 + + def test_json_nested_frozenset_round_trip(): converter = JSONPlainPayloadConverter() value = {frozenset({1, 2}), frozenset({3})}