From 2e88947ac594ecedf5ba4dd8843991301c318f23 Mon Sep 17 00:00:00 2001 From: ErenAta16 Date: Wed, 29 Jul 2026 22:17:21 +0300 Subject: [PATCH 1/5] Python: resolve string forward references nested inside list/dict/tuple annotations KernelJsonSchemaBuilder emits a bare {"type": "object"} for the element type of list["Inner"] while list[Inner] gets the full schema. That schema is what goes to the model as a function-calling parameter definition, so a plugin using the forward-reference style hands the model an untyped blob. get_type_hints evaluates an annotation that *is* a string, but it does not descend into a generic alias that already exists as an object. list["Inner"] goes through list.__class_getitem__, which stores "Inner" verbatim -- no ForwardRef wrapper, so nothing resolves it. build() then takes the isinstance(parameter_type, str) branch and build_from_type_name has no entry for "Inner", returning the {"type": "object"} fallback with no error. Optional["Inner"] works today because typing.Optional wraps the string in a real ForwardRef, which get_type_hints does resolve -- that asymmetry is what makes this easy to miss. Resolve the args against the owning model's module globals in build_model_schema, where those globals are already fetched for the get_type_hints call. An unresolvable name leaves the annotation untouched so the existing fallback still applies rather than raising mid-build. Addresses the forward-reference TODO at line 80 (issue #6464). Closes #14239 --- .../schema/kernel_json_schema_builder.py | 54 ++++++++++++++++ .../tests/unit/schema/test_schema_builder.py | 64 +++++++++++++++++++ 2 files changed, 118 insertions(+) diff --git a/python/semantic_kernel/schema/kernel_json_schema_builder.py b/python/semantic_kernel/schema/kernel_json_schema_builder.py index 5ec519b5b377..809f76fb82c5 100644 --- a/python/semantic_kernel/schema/kernel_json_schema_builder.py +++ b/python/semantic_kernel/schema/kernel_json_schema_builder.py @@ -86,6 +86,7 @@ def build_model_schema( hints = get_type_hints(model, globalns=model_module_globals, localns={}) for field_name, field_type in hints.items(): + field_type = cls._resolve_nested_forward_refs(field_type, model_module_globals) field_description = None if hasattr(model, "model_fields") and field_name in model.model_fields: field_info = model.model_fields[field_name] @@ -150,6 +151,59 @@ def get_json_schema(cls, parameter_type: type) -> dict[str, Any]: type_name = TYPE_MAPPING.get(parameter_type, "object") return {"type": type_name} + @classmethod + def _resolve_nested_forward_refs(cls, annotation: Any, globalns: dict[str, Any]) -> Any: + """Resolve string forward references nested inside a generic alias. + + `get_type_hints` evaluates an annotation that *is* a string, but it does not descend into + a generic alias that already exists as an object. `list["Inner"]` goes through + `list.__class_getitem__`, which stores `"Inner"` verbatim instead of wrapping it in a + `ForwardRef`, so nothing resolves it and `build` formats the bare string rather than the + class it names. + + Args: + annotation: The annotation to resolve, typically a generic alias. + globalns: The globals of the module the owning model was defined in. + + Returns: + Any: The annotation with resolvable string arguments replaced by the types they name, + or the original annotation when nothing could be resolved. + """ + args = get_args(annotation) + if not args: + return annotation + + resolved_args = [] + changed = False + for arg in args: + reference = arg if isinstance(arg, str) else getattr(arg, "__forward_arg__", None) + if reference is not None: + try: + resolved = eval(reference, globalns, {}) + except Exception: + # Not resolvable from this module; leave the annotation alone so the existing + # fallback applies instead of raising while a schema is being built. + return annotation + changed = True + else: + resolved = cls._resolve_nested_forward_refs(arg, globalns) + changed = changed or resolved is not arg + resolved_args.append(resolved) + + if not changed: + return annotation + + copy_with = getattr(annotation, "copy_with", None) + if copy_with is not None: + return copy_with(tuple(resolved_args)) + origin = get_origin(annotation) + if origin is None: + return annotation + try: + return origin[tuple(resolved_args)] + except TypeError: + return annotation + @classmethod def handle_complex_type( cls, parameter_type: type, description: str | None = None, structured_output: bool = False diff --git a/python/tests/unit/schema/test_schema_builder.py b/python/tests/unit/schema/test_schema_builder.py index 5d24a599c96c..ed38e55ad5cb 100644 --- a/python/tests/unit/schema/test_schema_builder.py +++ b/python/tests/unit/schema/test_schema_builder.py @@ -455,3 +455,67 @@ def test_build_schema_with_nonpydantic_structured_output(): } assert structured_output_schema == expected_schema + + +class InnerForward(KernelBaseModel): + value: int + label: str + + +class HolderForwardList(KernelBaseModel): + items: list["InnerForward"] = [] + + +class HolderDirectList(KernelBaseModel): + items: list[InnerForward] = [] + + +class HolderForwardDict(KernelBaseModel): + mapping: dict[str, "InnerForward"] = {} + + +class HolderForwardOptional(KernelBaseModel): + maybe: Optional["InnerForward"] = None + + +def test_build_list_with_string_forward_reference_matches_direct_reference(): + """`list["Inner"]` and `list[Inner]` must produce the same schema.""" + forward = KernelJsonSchemaBuilder.build(HolderForwardList) + direct = KernelJsonSchemaBuilder.build(HolderDirectList) + + assert forward == direct + assert forward["properties"]["items"]["items"]["properties"] == { + "value": {"type": "integer"}, + "label": {"type": "string"}, + } + + +def test_build_dict_with_string_forward_reference(): + schema = KernelJsonSchemaBuilder.build(HolderForwardDict) + + assert schema["properties"]["mapping"]["additionalProperties"]["properties"] == { + "value": {"type": "integer"}, + "label": {"type": "string"}, + } + + +def test_build_optional_with_string_forward_reference_still_works(): + """`Optional["Inner"]` already resolved via `get_type_hints`; make sure it still does.""" + schema = KernelJsonSchemaBuilder.build(HolderForwardOptional) + + assert schema["properties"]["maybe"]["properties"] == { + "value": {"type": "integer"}, + "label": {"type": "string"}, + } + + +def test_unresolvable_forward_reference_falls_back_instead_of_raising(): + """An annotation naming something that doesn't exist must not break schema building.""" + + class HolderUnknown(KernelBaseModel): + items: list["DoesNotExistAnywhere"] = [] # noqa: F821 + + schema = KernelJsonSchemaBuilder.build(HolderUnknown) + + assert schema["properties"]["items"]["type"] == "array" + assert schema["properties"]["items"]["items"] == {"type": "object"} From 0f006d1074cc4b67b09b6169785ff8ffe76dac3e Mon Sep 17 00:00:00 2001 From: ErenAta16 Date: Fri, 31 Jul 2026 14:39:25 +0300 Subject: [PATCH 2/5] Reject non-type expressions before evaluating a nested forward reference The nested string in list["Inner"] is stored verbatim, so anything it contains reaches the resolver's eval. Parse it first and require the grammar of a type expression - names, attributes, subscripts and X | Y unions - so a call, lambda or comprehension is refused and the annotation falls back to the existing untyped-object behaviour. --- .../schema/kernel_json_schema_builder.py | 39 +++++++++++++++ .../tests/unit/schema/test_schema_builder.py | 48 ++++++++++++++++++- 2 files changed, 86 insertions(+), 1 deletion(-) diff --git a/python/semantic_kernel/schema/kernel_json_schema_builder.py b/python/semantic_kernel/schema/kernel_json_schema_builder.py index 809f76fb82c5..3a4647ab2e3c 100644 --- a/python/semantic_kernel/schema/kernel_json_schema_builder.py +++ b/python/semantic_kernel/schema/kernel_json_schema_builder.py @@ -1,5 +1,6 @@ # Copyright (c) Microsoft. All rights reserved. +import ast import sys import types from enum import Enum @@ -151,6 +152,39 @@ def get_json_schema(cls, parameter_type: type) -> dict[str, Any]: type_name = TYPE_MAPPING.get(parameter_type, "object") return {"type": type_name} + _TYPE_EXPRESSION_NODES = ( + ast.Expression, + ast.Name, + ast.Attribute, + ast.Subscript, + ast.Tuple, + ast.List, + ast.Load, + ast.Constant, + ast.BinOp, + ast.BitOr, + ) + + @classmethod + def _is_type_expression(cls, source: str) -> bool: + """Return whether `source` parses as a type expression and nothing more. + + A forward reference names a type: an identifier, a dotted path, a subscription such as + `dict[str, Inner]`, or a `X | None` union. Calls, lambdas and comprehensions are not part + of that grammar, so rejecting them keeps `eval` from running anything a type annotation + would never legitimately contain. + """ + try: + tree = ast.parse(source, mode="eval") + except SyntaxError: + return False + for node in ast.walk(tree): + if not isinstance(node, cls._TYPE_EXPRESSION_NODES): + return False + if isinstance(node, ast.Constant) and not isinstance(node.value, (str, int, bool, type(None))): + return False + return True + @classmethod def _resolve_nested_forward_refs(cls, annotation: Any, globalns: dict[str, Any]) -> Any: """Resolve string forward references nested inside a generic alias. @@ -178,6 +212,11 @@ class it names. for arg in args: reference = arg if isinstance(arg, str) else getattr(arg, "__forward_arg__", None) if reference is not None: + if not cls._is_type_expression(reference): + # Anything that isn't the grammar of a type expression is not a forward + # reference; refuse to evaluate it rather than widen what a stray + # annotation can run while a schema is being built. + return annotation try: resolved = eval(reference, globalns, {}) except Exception: diff --git a/python/tests/unit/schema/test_schema_builder.py b/python/tests/unit/schema/test_schema_builder.py index ed38e55ad5cb..3c37932d62c9 100644 --- a/python/tests/unit/schema/test_schema_builder.py +++ b/python/tests/unit/schema/test_schema_builder.py @@ -2,7 +2,7 @@ import json from enum import Enum -from typing import Annotated, Any, Optional, Union +from typing import Annotated, Any, Optional, Union, get_args from unittest.mock import Mock import pytest @@ -519,3 +519,49 @@ class HolderUnknown(KernelBaseModel): assert schema["properties"]["items"]["type"] == "array" assert schema["properties"]["items"]["items"] == {"type": "object"} + + +def test_non_type_expression_forward_reference_is_not_evaluated(): + """A nested string that isn't a type expression must not be evaluated. + + `list["Inner"]` stores the string verbatim, so whatever it contains reaches the resolver. + Only the grammar of a type expression is evaluated; a call is left alone and the annotation + comes back unchanged for the existing fallback to handle. + """ + executed = [] + + def _canary(): + executed.append(True) + return int + + annotation = list["_canary()"] # noqa: F821 + resolved = KernelJsonSchemaBuilder._resolve_nested_forward_refs(annotation, {"_canary": _canary}) + + assert executed == [] + assert resolved is annotation + + +def test_type_expression_forward_reference_is_still_resolved(): + """The guard must not block the case the resolver exists for.""" + annotation = list["InnerForward"] # noqa: F821 + resolved = KernelJsonSchemaBuilder._resolve_nested_forward_refs(annotation, {"InnerForward": InnerForward}) + + assert get_args(resolved) == (InnerForward,) + + +@pytest.mark.parametrize( + ("reference", "is_type_expression"), + [ + ("InnerForward", True), + ("dict[str, InnerForward]", True), + ("InnerForward | None", True), + ("tuple[int, str]", True), + ("__import__('os').getcwd()", False), + ("_canary()", False), + ("(lambda: 1)()", False), + ("[x for x in ().__class__.__base__.__subclasses__()]", False), + ], +) +def test_is_type_expression(reference: str, is_type_expression: bool): + """The guard admits type expressions and rejects anything that can call out.""" + assert KernelJsonSchemaBuilder._is_type_expression(reference) is is_type_expression From e5b2d3864920309d27f43ea45a244a1dce9c1ff6 Mon Sep 17 00:00:00 2001 From: ErenAta16 Date: Sun, 18 Oct 2026 22:24:50 +0300 Subject: [PATCH 3/5] Only substitute nested forward references that resolve to a type A nested string reference was evaluated against the model's module globals and the result substituted unconditionally. When the name belongs to a module, a function or a plain value, build() then receives that object: a module raises AttributeError, a list raises TypeError as an unhashable mapping key, a function is rendered as a model of its parameters, and a string value is read as another type name. Substitute the evaluated value only when it is a class or a typing construct recognized by get_origin. Anything else leaves the annotation as written, so the existing {"type": "object"} fallback applies, as it does on main. The schema-level test is skipped from Python 3.11 on, because there get_type_hints evaluates strings nested in list[...] itself and the builder never sees them. --- .../schema/kernel_json_schema_builder.py | 16 +++- .../tests/unit/schema/test_schema_builder.py | 79 ++++++++++++++++++- 2 files changed, 93 insertions(+), 2 deletions(-) diff --git a/python/semantic_kernel/schema/kernel_json_schema_builder.py b/python/semantic_kernel/schema/kernel_json_schema_builder.py index 3a4647ab2e3c..dc199f607446 100644 --- a/python/semantic_kernel/schema/kernel_json_schema_builder.py +++ b/python/semantic_kernel/schema/kernel_json_schema_builder.py @@ -185,6 +185,15 @@ def _is_type_expression(cls, source: str) -> bool: return False return True + @classmethod + def _is_type_or_typing_construct(cls, value: Any) -> bool: + """Return whether `value` is something a forward reference can name. + + That is a class (a model, an enum, a builtin) or a typing construct such as + `dict[str, Inner]`, `Optional[Inner]` or `Inner | None`, all of which `get_origin` recognizes. + """ + return isinstance(value, type) or get_origin(value) is not None + @classmethod def _resolve_nested_forward_refs(cls, annotation: Any, globalns: dict[str, Any]) -> Any: """Resolve string forward references nested inside a generic alias. @@ -201,7 +210,7 @@ class it names. Returns: Any: The annotation with resolvable string arguments replaced by the types they name, - or the original annotation when nothing could be resolved. + or the original annotation when a reference does not resolve to a type. """ args = get_args(annotation) if not args: @@ -223,6 +232,11 @@ class it names. # Not resolvable from this module; leave the annotation alone so the existing # fallback applies instead of raising while a schema is being built. return annotation + if not cls._is_type_or_typing_construct(resolved): + # The name exists but is a module, a function or a plain value rather than a + # type. Substituting it would make `build` raise or describe the wrong object, + # so keep the reference as written and let the existing fallback apply. + return annotation changed = True else: resolved = cls._resolve_nested_forward_refs(arg, globalns) diff --git a/python/tests/unit/schema/test_schema_builder.py b/python/tests/unit/schema/test_schema_builder.py index 3c37932d62c9..0ea483e3fc8c 100644 --- a/python/tests/unit/schema/test_schema_builder.py +++ b/python/tests/unit/schema/test_schema_builder.py @@ -1,8 +1,9 @@ # Copyright (c) Microsoft. All rights reserved. import json +import sys from enum import Enum -from typing import Annotated, Any, Optional, Union, get_args +from typing import Annotated, Any, ForwardRef, Optional, Union, get_args from unittest.mock import Mock import pytest @@ -457,6 +458,14 @@ def test_build_schema_with_nonpydantic_structured_output(): assert structured_output_schema == expected_schema +# Up to 3.10, get_type_hints leaves a string nested in list[...] as written, so the builder is what resolves it. +# From 3.11 on, get_type_hints evaluates that string itself, and the builder only ever sees the result. +skip_if_get_type_hints_evaluates_nested_strings = pytest.mark.skipif( + sys.version_info >= (3, 11), + reason="From 3.11 on, get_type_hints evaluates strings nested in list[...] itself, before the builder runs.", +) + + class InnerForward(KernelBaseModel): value: int label: str @@ -565,3 +574,71 @@ def test_type_expression_forward_reference_is_still_resolved(): def test_is_type_expression(reference: str, is_type_expression: bool): """The guard admits type expressions and rejects anything that can call out.""" assert KernelJsonSchemaBuilder._is_type_expression(reference) is is_type_expression + + +def forward_reference_helper(count: int) -> str: + return str(count) + + +FORWARD_REFERENCE_LIMITS = [1, 2, 3] +FORWARD_REFERENCE_ALIAS = "int" + + +class HolderModuleReference: + items: list["json"] + + +class HolderFunctionReference: + items: list["forward_reference_helper"] + + +class HolderListValueReference: + items: list["FORWARD_REFERENCE_LIMITS"] + + +class HolderStrValueReference: + items: list["FORWARD_REFERENCE_ALIAS"] + + +@pytest.mark.parametrize( + "reference", ["json", "forward_reference_helper", "FORWARD_REFERENCE_LIMITS", "FORWARD_REFERENCE_ALIAS"] +) +@pytest.mark.parametrize("wrap", [str, ForwardRef], ids=["str", "ForwardRef"]) +def test_forward_reference_to_non_type_global_is_left_unchanged(reference: str, wrap: Any): + """A name that resolves to a module, a function or a plain value does not name a type. + + Substituting it would hand `build` a module (raises), a function (rendered as a model of its + parameters), a list (raises as an unhashable mapping key) or a string (read as another type + name). The annotation must come back unchanged so the existing fallback applies. + """ + annotation = list[wrap(reference)] + resolved = KernelJsonSchemaBuilder._resolve_nested_forward_refs(annotation, globals()) + + assert resolved is annotation + + +@pytest.mark.parametrize( + ("reference", "expected"), + [ + ("InnerForward", InnerForward), + ("dict[str, InnerForward]", dict[str, InnerForward]), + ("Optional[InnerForward]", Optional[InnerForward]), + ("InnerForward | None", InnerForward | None), + ], +) +def test_forward_reference_to_type_or_typing_construct_is_resolved(reference: str, expected: Any): + """Classes and typing constructs are what a forward reference names; those are still substituted.""" + resolved = KernelJsonSchemaBuilder._resolve_nested_forward_refs(list[reference], globals()) + + assert get_args(resolved) == (expected,) + + +@skip_if_get_type_hints_evaluates_nested_strings +@pytest.mark.parametrize( + "holder", + [HolderModuleReference, HolderFunctionReference, HolderListValueReference, HolderStrValueReference], +) +def test_build_with_non_type_forward_reference_keeps_object_fallback(holder: type): + schema = KernelJsonSchemaBuilder.build(holder) + + assert schema["properties"]["items"] == {"type": "array", "items": {"type": "object"}} From d4f03cd7dd6be65c0a31563285e98386b8e55f27 Mon Sep 17 00:00:00 2001 From: ErenAta16 Date: Sun, 18 Oct 2026 22:25:19 +0300 Subject: [PATCH 4/5] Skip the unresolvable-reference fallback test from Python 3.11 on From 3.11 on, get_type_hints evaluates a string nested in list[...] itself, so list["DoesNotExistAnywhere"] raises NameError inside build_model_schema before the resolver is reached. That is how main behaves for the same annotation on those versions, and the test fails on this branch without the previous commit too. The fallback it pins only exists on 3.10, so it uses the same skip as the other 3.10-only test. --- python/tests/unit/schema/test_schema_builder.py | 1 + 1 file changed, 1 insertion(+) diff --git a/python/tests/unit/schema/test_schema_builder.py b/python/tests/unit/schema/test_schema_builder.py index 0ea483e3fc8c..ca954528528d 100644 --- a/python/tests/unit/schema/test_schema_builder.py +++ b/python/tests/unit/schema/test_schema_builder.py @@ -518,6 +518,7 @@ def test_build_optional_with_string_forward_reference_still_works(): } +@skip_if_get_type_hints_evaluates_nested_strings def test_unresolvable_forward_reference_falls_back_instead_of_raising(): """An annotation naming something that doesn't exist must not break schema building.""" From 63e01785a32ac6b0d2917ed322a1ad88c028b535 Mon Sep 17 00:00:00 2001 From: ErenAta16 Date: Sun, 18 Oct 2026 22:48:34 +0300 Subject: [PATCH 5/5] refactor: evaluate forward refs without eval The pre-commit job runs bandit with --all-files against python/semantic_kernel, and the eval() call in _resolve_nested_forward_refs is the only finding on this branch (B307, medium). main is bandit-clean, so the Python Code Quality Checks job fails as soon as it runs on this PR. _is_type_expression already limits a reference to names, attribute access, subscription, | unions, tuples, lists and simple literals. Those nodes are now evaluated directly, with names looked up in the module globals and then in builtins, the same order eval uses. That is the same approach as _SafeFilterEvaluator in connectors/in_memory.py. A suppression comment would also silence bandit, but it would leave eval in place where the guard already gives everything needed to avoid it. Behaviour is unchanged: a parametrized test checks that every admitted expression evaluates to the same object eval returns. --- .../schema/kernel_json_schema_builder.py | 37 +++++++++++++++++-- .../tests/unit/schema/test_schema_builder.py | 30 +++++++++++++++ 2 files changed, 64 insertions(+), 3 deletions(-) diff --git a/python/semantic_kernel/schema/kernel_json_schema_builder.py b/python/semantic_kernel/schema/kernel_json_schema_builder.py index dc199f607446..2e81bc3b1877 100644 --- a/python/semantic_kernel/schema/kernel_json_schema_builder.py +++ b/python/semantic_kernel/schema/kernel_json_schema_builder.py @@ -1,6 +1,7 @@ # Copyright (c) Microsoft. All rights reserved. import ast +import builtins import sys import types from enum import Enum @@ -171,8 +172,8 @@ def _is_type_expression(cls, source: str) -> bool: A forward reference names a type: an identifier, a dotted path, a subscription such as `dict[str, Inner]`, or a `X | None` union. Calls, lambdas and comprehensions are not part - of that grammar, so rejecting them keeps `eval` from running anything a type annotation - would never legitimately contain. + of that grammar, so rejecting them limits `_evaluate_type_expression` to the few + operations a type annotation is made of. """ try: tree = ast.parse(source, mode="eval") @@ -185,6 +186,36 @@ def _is_type_expression(cls, source: str) -> bool: return False return True + @classmethod + def _evaluate_type_expression(cls, node: ast.AST, globalns: dict[str, Any]) -> Any: + """Evaluate a tree that `_is_type_expression` admitted, without using `eval`. + + Names are looked up the way `eval` looks them up, in `globalns` first and then in the + builtins. Every other admitted node is a subscription, an attribute access, a `|` union, + a tuple or list of arguments, or a literal; there is no call node to evaluate. + """ + if isinstance(node, ast.Expression): + return cls._evaluate_type_expression(node.body, globalns) + if isinstance(node, ast.Name): + if node.id in globalns: + return globalns[node.id] + return getattr(builtins, node.id) + if isinstance(node, ast.Attribute): + return getattr(cls._evaluate_type_expression(node.value, globalns), node.attr) + if isinstance(node, ast.Subscript): + value = cls._evaluate_type_expression(node.value, globalns) + return value[cls._evaluate_type_expression(node.slice, globalns)] + if isinstance(node, ast.BinOp) and isinstance(node.op, ast.BitOr): + left = cls._evaluate_type_expression(node.left, globalns) + return left | cls._evaluate_type_expression(node.right, globalns) + if isinstance(node, ast.Tuple): + return tuple(cls._evaluate_type_expression(element, globalns) for element in node.elts) + if isinstance(node, ast.List): + return [cls._evaluate_type_expression(element, globalns) for element in node.elts] + if isinstance(node, ast.Constant): + return node.value + raise TypeError(f"Unsupported node in a type expression: {type(node).__name__}") + @classmethod def _is_type_or_typing_construct(cls, value: Any) -> bool: """Return whether `value` is something a forward reference can name. @@ -227,7 +258,7 @@ class it names. # annotation can run while a schema is being built. return annotation try: - resolved = eval(reference, globalns, {}) + resolved = cls._evaluate_type_expression(ast.parse(reference, mode="eval"), globalns) except Exception: # Not resolvable from this module; leave the annotation alone so the existing # fallback applies instead of raising while a schema is being built. diff --git a/python/tests/unit/schema/test_schema_builder.py b/python/tests/unit/schema/test_schema_builder.py index ca954528528d..b2b534935ff0 100644 --- a/python/tests/unit/schema/test_schema_builder.py +++ b/python/tests/unit/schema/test_schema_builder.py @@ -1,5 +1,6 @@ # Copyright (c) Microsoft. All rights reserved. +import ast import json import sys from enum import Enum @@ -577,6 +578,35 @@ def test_is_type_expression(reference: str, is_type_expression: bool): assert KernelJsonSchemaBuilder._is_type_expression(reference) is is_type_expression +@pytest.mark.parametrize( + "reference", + [ + "InnerForward", + "dict[str, InnerForward]", + "InnerForward | None", + "tuple[int, str]", + "Optional[InnerForward]", + "Union[int, str, None]", + "Annotated[int, 'meta']", + "list[dict[str, 'InnerForward']]", + "json.JSONDecoder", + ], +) +def test_evaluate_type_expression_matches_eval(reference: str): + """Every expression the guard admits evaluates to the same object `eval` would return.""" + assert KernelJsonSchemaBuilder._is_type_expression(reference) + tree = ast.parse(reference, mode="eval") + + assert KernelJsonSchemaBuilder._evaluate_type_expression(tree, globals()) == eval(reference, globals(), {}) + + +def test_unknown_name_in_forward_reference_is_left_unchanged(): + annotation = list["DoesNotExistAnywhere"] # noqa: F821 + resolved = KernelJsonSchemaBuilder._resolve_nested_forward_refs(annotation, globals()) + + assert resolved is annotation + + def forward_reference_helper(count: int) -> str: return str(count)