Skip to content

Commit 384dafd

Browse files
committed
Fix annotation discovery in 3.10
1 parent 3cd8939 commit 384dafd

2 files changed

Lines changed: 98 additions & 2 deletions

File tree

durabletask/serialization.py

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
import inspect
3939
import json
4040
import logging
41+
import sys
4142
import types
4243
import typing
4344
from abc import ABC, abstractmethod
@@ -435,10 +436,67 @@ def _build_dataclass(cls: Any, data: dict[str, Any],
435436
hints = typing.get_type_hints(cls)
436437
except Exception:
437438
hints = {}
439+
globalns = _type_namespace(cls)
438440
kwargs: dict[str, Any] = {}
439441
for field in dataclasses.fields(cls):
440442
if field.name not in data:
441443
continue
442-
field_type = hints.get(field.name)
444+
# ``get_type_hints`` on Python 3.10 does not deep-resolve forward
445+
# references nested inside container args (e.g. the ``"TreeNode"`` in
446+
# ``list["TreeNode"]`` on a self-referential dataclass), leaving a bare
447+
# string or ``ForwardRef`` that the coercion below would skip. Resolve
448+
# them against the class's defining module so reconstruction behaves the
449+
# same as it does on 3.11+.
450+
field_type = _resolve_forward_refs(hints.get(field.name), globalns)
443451
kwargs[field.name] = _coerce_to_type(data[field.name], field_type, converter)
444452
return cls(**kwargs)
453+
454+
455+
def _type_namespace(cls: Any) -> dict[str, Any]:
456+
"""Build the namespace used to resolve forward references in ``cls``'s hints.
457+
458+
Forward references in a class's annotations are resolved against the
459+
module in which the class is defined, plus the class's own name (so a
460+
self-referential type like ``list["TreeNode"]`` resolves).
461+
"""
462+
module = sys.modules.get(getattr(cls, "__module__", None) or "")
463+
ns: dict[str, Any] = dict(getattr(module, "__dict__", {}))
464+
name = getattr(cls, "__name__", None)
465+
if name:
466+
ns.setdefault(name, cls)
467+
return ns
468+
469+
470+
def _resolve_forward_refs(tp: Any, globalns: dict[str, Any]) -> Any:
471+
"""Resolve string / ``ForwardRef`` leaves in a type hint, recursing into args.
472+
473+
Returns ``tp`` unchanged when it (or a nested name) cannot be resolved, so an
474+
unresolvable hint simply falls back to "leave the value as parsed JSON"
475+
rather than raising. Only the supported generic shapes (``Union``, ``list``,
476+
``dict``, ``tuple``, etc.) are rebuilt; the destination type is still
477+
entirely caller-supplied, so this does not weaken the security model.
478+
"""
479+
if isinstance(tp, str):
480+
try:
481+
tp = eval(tp, globalns) # noqa: S307 - resolves caller-authored annotations
482+
except Exception:
483+
return tp
484+
elif isinstance(tp, typing.ForwardRef):
485+
try:
486+
tp = eval(tp.__forward_arg__, globalns) # noqa: S307
487+
except Exception:
488+
return tp
489+
490+
origin = typing.get_origin(tp)
491+
if origin is None:
492+
return tp
493+
args = typing.get_args(tp)
494+
if not args:
495+
return tp
496+
resolved = [_resolve_forward_refs(a, globalns) for a in args]
497+
if origin is typing.Union or origin is types.UnionType:
498+
return typing.Union[tuple(resolved)]
499+
try:
500+
return origin[tuple(resolved) if len(resolved) > 1 else resolved[0]]
501+
except TypeError:
502+
return tp

tests/durabletask/test_serialization.py

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,14 @@
1212
from collections import namedtuple
1313
from dataclasses import dataclass
1414
from types import SimpleNamespace
15-
from typing import Optional, Union
15+
from typing import List, Optional, Union, get_args
1616

1717
import pytest
1818

1919
from durabletask.serialization import _AUTO_SERIALIZED as AUTO_SERIALIZED
2020
from durabletask.serialization import _coerce_to_type as coerce_to_type
2121
from durabletask.serialization import _from_json as from_json
22+
from durabletask.serialization import _resolve_forward_refs as resolve_forward_refs
2223
from durabletask.serialization import _to_json as to_json
2324

2425

@@ -485,3 +486,40 @@ def test_coerce_to_type_without_converter_calls_single_arg_hook():
485486
# the hook could accept it (defensive: no converter available).
486487
result = coerce_to_type({"label": "gear", "size": 5}, Widget)
487488
assert result == Widget("gear", 5)
489+
490+
491+
# ----- forward-reference resolution (Python 3.10 get_type_hints parity) -----
492+
#
493+
# On Python 3.10, ``typing.get_type_hints`` does not deep-resolve forward
494+
# references nested inside container args (e.g. the element type of
495+
# ``list["TreeNode"]`` on a self-referential dataclass), leaving a bare string
496+
# or ``ForwardRef``. ``_resolve_forward_refs`` restores the 3.11+ behavior so
497+
# nested coercion still fires. These tests run on every supported version.
498+
499+
500+
def test_resolve_forward_refs_resolves_string_element_type():
501+
# ``list["Address"]`` evaluates to a generic whose arg is the raw string
502+
# "Address" -- exactly what 3.10 leaves behind.
503+
resolved = resolve_forward_refs(list["Address"], {"Address": Address})
504+
assert get_args(resolved) == (Address,)
505+
506+
507+
def test_resolve_forward_refs_resolves_forwardref_element_type():
508+
resolved = resolve_forward_refs(List["Address"], {"Address": Address})
509+
assert get_args(resolved) == (Address,)
510+
511+
512+
def test_resolve_forward_refs_then_coerce_reconstructs_nested_dataclass():
513+
# The full 3.10 path: resolve the unresolved element type, then coerce the
514+
# contained dicts into the target dataclass.
515+
field_type = resolve_forward_refs(list["Address"], {"Address": Address})
516+
result = coerce_to_type([{"street": "a", "city": "b"}], field_type)
517+
assert isinstance(result[0], Address)
518+
assert result[0].city == "b"
519+
520+
521+
def test_resolve_forward_refs_leaves_unresolvable_name_untouched():
522+
# An unknown name is left as a string so coercion harmlessly falls back to
523+
# the parsed JSON rather than raising.
524+
resolved = resolve_forward_refs(list["DoesNotExist"], {})
525+
assert get_args(resolved) == ("DoesNotExist",)

0 commit comments

Comments
 (0)