clan-cli: Add support for ForwardRef type in type_to_jsonschema and tests

This commit is contained in:
Qubasa
2025-10-22 14:38:13 +02:00
parent 9851993b82
commit c7ec9a9715
2 changed files with 101 additions and 1 deletions

View File

@@ -1,5 +1,6 @@
import copy import copy
import dataclasses import dataclasses
import importlib
import inspect import inspect
import pathlib import pathlib
from dataclasses import MISSING from dataclasses import MISSING
@@ -9,6 +10,7 @@ from types import NoneType, UnionType
from typing import ( from typing import (
Annotated, Annotated,
Any, Any,
ForwardRef,
Literal, Literal,
NewType, NewType,
NotRequired, NotRequired,
@@ -118,6 +120,18 @@ def type_to_dict(
if t is None: if t is None:
return {"type": "null"} return {"type": "null"}
# Handle string type annotations (forward references represented as strings)
if isinstance(t, str):
# Special case for JSONValue which represents arbitrary JSON values
# This is a recursive type that can't be fully represented in JSON Schema without $ref
if t == "JSONValue":
# Return a permissive schema that allows any JSON-compatible value
# This matches the intent of JSONValue = str | int | float | bool | None | list[JSONValue] | dict[str, JSONValue]
return {} # Empty schema allows any type
msg = f"{scope} - String type annotation '{t}' cannot be resolved. This may indicate a forward reference or recursive type."
raise JSchemaTypeError(msg)
if inspect.isclass(t) and t.__name__ == "Unknown": if inspect.isclass(t) and t.__name__ == "Unknown":
# Empty should represent unknown # Empty should represent unknown
# We don't know anything about this type # We don't know anything about this type
@@ -335,5 +349,31 @@ def type_to_dict(
msg = f"{scope} - Basic type '{t!s}' is not supported" msg = f"{scope} - Basic type '{t!s}' is not supported"
raise JSchemaTypeError(msg) raise JSchemaTypeError(msg)
# Handle ForwardRef types by resolving them to their actual types
if isinstance(t, ForwardRef):
# Get the module name and type name from the forward ref
module_name = getattr(t, "__forward_module__", None)
type_name = getattr(t, "__forward_arg__", None)
if module_name and type_name:
try:
# Import the module to get its namespace
module = importlib.import_module(module_name)
namespace = {**vars(module), "__builtins__": __builtins__}
# Evaluate the type string in the namespace (handles both module types and builtins)
resolved_type = eval(type_name, namespace) # noqa: S307
return type_to_dict(
resolved_type, scope, type_map, narrow_unsupported_union_types
)
except (ImportError, NameError, AttributeError, SyntaxError) as e:
msg = f"{scope} - Could not resolve ForwardRef('{type_name}', module='{module_name}'): {e}"
raise JSchemaTypeError(msg) from e
msg = f"{scope} - ForwardRef without module or type name: {t}"
raise JSchemaTypeError(msg)
msg = f"{scope} - Type '{t!s}' is not supported" msg = f"{scope} - Type '{t!s}' is not supported"
raise JSchemaTypeError(msg) raise JSchemaTypeError(msg)

View File

@@ -1,6 +1,6 @@
from dataclasses import dataclass, field from dataclasses import dataclass, field
from enum import Enum from enum import Enum
from typing import Any, Generic, NotRequired, Required, TypedDict, TypeVar from typing import Any, ForwardRef, Generic, NotRequired, Required, TypedDict, TypeVar
import pytest import pytest
@@ -379,3 +379,63 @@ def test_type_alias() -> None:
"additionalProperties": False, "additionalProperties": False,
"required": ["input_name", "readmes"], "required": ["input_name", "readmes"],
} }
def test_string_type_annotation_jsonvalue() -> None:
# Test that "JSONValue" string type annotation returns permissive schema
result = type_to_dict("JSONValue")
assert result == {}, "JSONValue should return empty schema allowing any type"
def test_string_type_annotation_error() -> None:
# Test that other string type annotations raise an error
with pytest.raises(JSchemaTypeError) as exc_info:
type_to_dict("SomeUnknownType")
assert "String type annotation 'SomeUnknownType' cannot be resolved" in str(
exc_info.value
)
def test_forwardref_resolution() -> None:
# Create a ForwardRef that references a built-in type
forward_ref = ForwardRef("int")
forward_ref.__forward_module__ = "builtins"
result = type_to_dict(forward_ref)
assert result == {"type": "integer"}
# Test ForwardRef to str
forward_ref_str = ForwardRef("str")
forward_ref_str.__forward_module__ = "builtins"
result_str = type_to_dict(forward_ref_str)
assert result_str == {"type": "string"}
def test_forwardref_resolution_from_module() -> None:
# Create a ForwardRef that references a complex type from typing module
forward_ref = ForwardRef("list[str]")
forward_ref.__forward_module__ = "builtins"
# This test verifies that we can resolve complex type expressions
result = type_to_dict(forward_ref)
assert result == {"type": "array", "items": {"type": "string"}}
def test_forwardref_without_module() -> None:
# Test that ForwardRef without module info raises an error
forward_ref = ForwardRef("SomeType")
with pytest.raises(JSchemaTypeError) as exc_info:
type_to_dict(forward_ref)
assert "ForwardRef without module or type name" in str(exc_info.value)
def test_forwardref_invalid_type() -> None:
# Test that ForwardRef with invalid type name raises an error
forward_ref = ForwardRef("NonExistentType")
forward_ref.__forward_module__ = "builtins"
with pytest.raises(JSchemaTypeError) as exc_info:
type_to_dict(forward_ref)
assert "Could not resolve ForwardRef" in str(exc_info.value)