@@ -26,6 +26,75 @@ def __init__(self, *, alias: str) -> None:
2626 self .alias = alias
2727
2828
29+ # Resolving type hints (typing.get_type_hints) is expensive because it eval/compiles
30+ # forward-reference annotations. The result is constant for a given type, so we cache it.
31+ # This is critical for hot paths like SSE event parsing, where the same (often large
32+ # discriminated-union) type is converted on every single event.
33+ _type_hints_cache : typing .Dict [typing .Any , typing .Dict [str , typing .Any ]] = {}
34+
35+
36+ def _get_cached_type_hints (expected_type : typing .Any ) -> typing .Dict [str , typing .Any ]:
37+ try :
38+ cached = _type_hints_cache .get (expected_type )
39+ except TypeError :
40+ # Unhashable type; resolve without caching.
41+ return _resolve_type_hints (expected_type )
42+ if cached is None :
43+ cached = _resolve_type_hints (expected_type )
44+ _type_hints_cache [expected_type ] = cached
45+ return cached
46+
47+
48+ def _resolve_type_hints (expected_type : typing .Any ) -> typing .Dict [str , typing .Any ]:
49+ try :
50+ return typing_extensions .get_type_hints (expected_type , include_extras = True )
51+ except NameError :
52+ # The type contains a circular reference, so we use the __annotations__ attribute directly.
53+ return getattr (expected_type , "__annotations__" , {})
54+
55+
56+ # Whether convert_and_respect_annotation_metadata can possibly rewrite anything for a given
57+ # annotation, i.e. whether any reachable model/TypedDict field carries a FieldMetadata alias.
58+ # This is constant per type, so we cache it and use it to short-circuit the recursive walk.
59+ _requires_conversion_cache : typing .Dict [typing .Any , bool ] = {}
60+
61+
62+ def _requires_conversion (type_ : typing .Any ) -> bool :
63+ try :
64+ cached = _requires_conversion_cache .get (type_ )
65+ except TypeError :
66+ # Unhashable annotation; compute without caching.
67+ return _compute_requires_conversion (type_ , set ())
68+ if cached is None :
69+ cached = _compute_requires_conversion (type_ , set ())
70+ _requires_conversion_cache [type_ ] = cached
71+ return cached
72+
73+
74+ def _compute_requires_conversion (type_ : typing .Any , seen : typing .Set [typing .Any ]) -> bool :
75+ clean_type = _remove_annotations (type_ )
76+
77+ try :
78+ if clean_type in seen :
79+ return False
80+ seen = seen | {clean_type }
81+ except TypeError :
82+ # Unhashable type; skip cycle tracking (the type graph is finite in practice).
83+ pass
84+
85+ # Models / TypedDicts: a field alias here means we must dealias; otherwise recurse into fields.
86+ if (inspect .isclass (clean_type ) and issubclass (clean_type , pydantic .BaseModel )) or typing_extensions .is_typeddict (
87+ clean_type
88+ ):
89+ annotations = _get_cached_type_hints (clean_type )
90+ if _get_alias_to_field_name (annotations ):
91+ return True
92+ return any (_compute_requires_conversion (hint , seen ) for hint in annotations .values ())
93+
94+ # Containers / unions: recurse into the type arguments (List/Set/Sequence/Dict/Union/etc.).
95+ return any (_compute_requires_conversion (arg , seen ) for arg in typing_extensions .get_args (clean_type ))
96+
97+
2998def convert_and_respect_annotation_metadata (
3099 * ,
31100 object_ : typing .Any ,
@@ -57,6 +126,13 @@ def convert_and_respect_annotation_metadata(
57126 return None
58127 if inner_type is None :
59128 inner_type = annotation
129+ # The only thing this function ever rewrites is keys that carry a FieldMetadata
130+ # alias. If nothing in the (cached) type graph has such an alias, the conversion is
131+ # a content-identity transform, so we can skip the entire recursive walk. This is
132+ # the hot path for SSE streaming, where a large discriminated union would otherwise
133+ # be traversed on every single event.
134+ if not _requires_conversion (annotation ):
135+ return object_
60136
61137 clean_type = _remove_annotations (inner_type )
62138 # Pydantic models
@@ -160,12 +236,7 @@ def _convert_mapping(
160236 direction : typing .Literal ["read" , "write" ],
161237) -> typing .Mapping [str , object ]:
162238 converted_object : typing .Dict [str , object ] = {}
163- try :
164- annotations = typing_extensions .get_type_hints (expected_type , include_extras = True )
165- except NameError :
166- # The TypedDict contains a circular reference, so
167- # we use the __annotations__ attribute directly.
168- annotations = getattr (expected_type , "__annotations__" , {})
239+ annotations = _get_cached_type_hints (expected_type )
169240 aliases_to_field_names = _get_alias_to_field_name (annotations )
170241 for key , value in object_ .items ():
171242 if direction == "read" and key in aliases_to_field_names :
@@ -221,12 +292,12 @@ def _remove_annotations(type_: typing.Any) -> typing.Any:
221292
222293
223294def get_alias_to_field_mapping (type_ : typing .Any ) -> typing .Dict [str , str ]:
224- annotations = typing_extensions . get_type_hints (type_ , include_extras = True )
295+ annotations = _get_cached_type_hints (type_ )
225296 return _get_alias_to_field_name (annotations )
226297
227298
228299def get_field_to_alias_mapping (type_ : typing .Any ) -> typing .Dict [str , str ]:
229- annotations = typing_extensions . get_type_hints (type_ , include_extras = True )
300+ annotations = _get_cached_type_hints (type_ )
230301 return _get_field_to_alias_name (annotations )
231302
232303
0 commit comments