Skip to content

Commit c71e8c9

Browse files
committed
PR feedback
1 parent a98ccc0 commit c71e8c9

5 files changed

Lines changed: 63 additions & 14 deletions

File tree

durabletask/internal/type_discovery.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
1111
Discovery is intentionally conservative: it only returns an annotation when the
1212
active :class:`~durabletask.serialization.DataConverter` reports it as
13-
*reconstructable* via :meth:`DataConverter.is_reconstructable`. The default
13+
*reconstructable* via :meth:`DataConverter.can_reconstruct`. The default
1414
converter recognizes dataclasses, ``from_json()``-capable types, and ``Optional``
1515
/ ``list`` hints wrapping them; a custom converter can recognize its own types
1616
(e.g. ``pydantic.BaseModel``). Primitive and unknown annotations resolve to

durabletask/serialization.py

Lines changed: 18 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -403,7 +403,12 @@ def _invoke_from_json(hook: Any, value: Any, converter: DataConverter | None) ->
403403
> ``from_json`` must be a ``@classmethod`` or ``@staticmethod`` -- the hook
404404
> is resolved off the *type* (no instance exists yet during reconstruction).
405405
> A plain instance method would have ``self`` consume the value and is
406-
> unsupported regardless of the converter-arity detection below.
406+
> unsupported regardless of the converter detection below.
407+
>
408+
> The converter is passed positionally as the second argument, so a hook
409+
> opts in only by naming that parameter exactly ``converter``. This reserved
410+
> name avoids misreading an unrelated second parameter (e.g.
411+
> ``from_json(cls, value, strict=False)``) as converter-aware.
407412
"""
408413
if converter is not None and _hook_accepts_converter(hook):
409414
return hook(value, converter)
@@ -412,26 +417,27 @@ def _invoke_from_json(hook: Any, value: Any, converter: DataConverter | None) ->
412417

413418
@functools.lru_cache(maxsize=2048)
414419
def _hook_accepts_converter(hook: Any) -> bool:
415-
"""Return True if a bound ``from_json`` hook can accept a second argument.
420+
"""Return True if a bound ``from_json`` hook opts in to receiving the converter.
416421
417422
The hook is inspected as accessed off the type (``cls``/``self`` already
418423
bound), so a classmethod ``from_json(cls, value, converter)`` presents as
419-
``(value, converter)``. Results are cached because reconstruction runs on
420-
hot paths; bound classmethods hash equal across attribute accesses, so the
421-
cache stays effective and bounded.
424+
``(value, converter)``. A hook is treated as converter-aware only when its
425+
second positional parameter is named exactly ``converter`` -- the reserved
426+
name for this argument -- so an unrelated second parameter such as
427+
``strict=False`` is not mistaken for it. Results are cached because
428+
reconstruction runs on hot paths; bound classmethods hash equal across
429+
attribute accesses, so the cache stays effective and bounded.
422430
"""
423431
try:
424432
sig = inspect.signature(hook)
425433
except (TypeError, ValueError):
426434
return False
427-
positional = 0
428-
for param in sig.parameters.values():
435+
positional = [
436+
param for param in sig.parameters.values()
429437
if param.kind in (inspect.Parameter.POSITIONAL_ONLY,
430-
inspect.Parameter.POSITIONAL_OR_KEYWORD):
431-
positional += 1
432-
elif param.kind is inspect.Parameter.VAR_POSITIONAL:
433-
return True
434-
return positional >= 2
438+
inspect.Parameter.POSITIONAL_OR_KEYWORD)
439+
]
440+
return len(positional) >= 2 and positional[1].name == "converter"
435441

436442

437443
def _coerce_generic(value: Any, expected_type: Any, origin: Any,

examples/custom_data_converter/README.md

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,9 +48,19 @@ A `DataConverter` implements three methods:
4848
The converter in [src/converter.py](src/converter.py) recognizes
4949
`pydantic.BaseModel` subclasses and uses pydantic for them, **delegating
5050
everything else** to the default `JsonDataConverter`. This "handle my types,
51-
delegate the rest" shape is the recommended pattern for a real converter — it
51+
delegate the rest" shape is a good starting point for a real converter — it
5252
costs nothing for non-pydantic payloads.
5353

54+
> [!NOTE]
55+
> To stay focused on the seam, this example only intercepts when a model is the
56+
> *top-level* type. A model nested inside another model (like `Order.items`)
57+
> round-trips because pydantic recurses on its own, but a model nested in a
58+
> *top-level generic* the SDK rebuilds directly — e.g. `return_type=list[OrderItem]`
59+
> or an input annotated `dict[str, OrderItem]` — is not intercepted and falls to
60+
> the default codec, which leaves the elements as raw dicts. A production
61+
> converter would recurse into such generics (for example via
62+
> `pydantic.TypeAdapter`).
63+
5464
## Inbound inputs: `can_reconstruct`
5565

5666
There is one extra detail for reconstructing **inbound** orchestrator/activity

examples/custom_data_converter/src/converter.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,18 @@ class PydanticDataConverter(DataConverter):
6161
(with full validation) via ``model_validate_json()`` / ``model_validate()``
6262
whenever the SDK supplies the model type. Every other value falls through to
6363
the default :class:`JsonDataConverter`.
64+
65+
> [!NOTE]
66+
> For simplicity this converter only intercepts when a pydantic model is the
67+
> *top-level* ``target_type``. A model nested **inside another model** (like
68+
> ``Order.items: list[OrderItem]`` below) round-trips fine because pydantic
69+
> handles that recursion itself. But a model nested in a *top-level generic*
70+
> the SDK is asked to rebuild directly -- e.g. ``return_type=list[OrderItem]``
71+
> or an input annotated ``dict[str, OrderItem]`` -- is **not** intercepted
72+
> here; it falls to the default codec, which cannot construct a pydantic
73+
> model from a positional ``dict`` and so leaves the elements as raw dicts. A
74+
> production converter would recurse into such generics (for example via
75+
> ``pydantic.TypeAdapter``); this example keeps the surface minimal.
6476
"""
6577

6678
def __init__(self) -> None:

tests/durabletask/test_serialization.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -488,6 +488,27 @@ def test_coerce_to_type_without_converter_calls_single_arg_hook():
488488
assert result == Widget("gear", 5)
489489

490490

491+
def test_from_json_hook_unrelated_second_param_is_not_treated_as_converter():
492+
# A ``from_json`` whose second parameter is *not* named ``converter`` (here a
493+
# ``strict`` flag with a default) must not be mistaken for a converter-aware
494+
# hook -- otherwise the DataConverter would be bound to ``strict``.
495+
from durabletask.serialization import JsonDataConverter
496+
497+
@dataclass
498+
class Flagged:
499+
label: str
500+
501+
@classmethod
502+
def from_json(cls, value, strict=False):
503+
# If the converter were passed here, ``strict`` would be truthy.
504+
assert strict is False
505+
return cls(value["label"])
506+
507+
conv = JsonDataConverter()
508+
result = conv.deserialize('{"label": "ok"}', Flagged)
509+
assert result == Flagged("ok")
510+
511+
491512
# ----- forward-reference resolution (Python 3.10 get_type_hints parity) -----
492513
#
493514
# On Python 3.10, ``typing.get_type_hints`` does not deep-resolve forward

0 commit comments

Comments
 (0)