|
38 | 38 | import inspect |
39 | 39 | import json |
40 | 40 | import logging |
| 41 | +import sys |
41 | 42 | import types |
42 | 43 | import typing |
43 | 44 | from abc import ABC, abstractmethod |
@@ -435,10 +436,67 @@ def _build_dataclass(cls: Any, data: dict[str, Any], |
435 | 436 | hints = typing.get_type_hints(cls) |
436 | 437 | except Exception: |
437 | 438 | hints = {} |
| 439 | + globalns = _type_namespace(cls) |
438 | 440 | kwargs: dict[str, Any] = {} |
439 | 441 | for field in dataclasses.fields(cls): |
440 | 442 | if field.name not in data: |
441 | 443 | 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) |
443 | 451 | kwargs[field.name] = _coerce_to_type(data[field.name], field_type, converter) |
444 | 452 | 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 |
0 commit comments