@@ -74,9 +74,6 @@ class PythonTypeMapping:
7474 method_type = mapping.method_invocation_type(call_node)
7575 """
7676
77- # Cache for type mappings to avoid repeated class type creation
78- _type_cache : Dict [str , JavaType ] = {}
79-
8077 def __init__ (self , source : str , file_path : Optional [str ] = None , ty_client = None ):
8178 """Initialize type mapping for a source file.
8279
@@ -91,6 +88,7 @@ def __init__(self, source: str, file_path: Optional[str] = None, ty_client=None)
9188 self ._file_path = file_path
9289 self ._source_lines = source .splitlines ()
9390 self ._temp_file : Optional [Path ] = None
91+ self ._type_cache : Dict [str , JavaType ] = {} # FQN -> JavaType (per-instance)
9492
9593 # Compute line byte offsets for position conversion
9694 self ._line_byte_offsets = self ._compute_line_byte_offsets (source )
@@ -137,16 +135,18 @@ def _ensure_file_on_disk(self, source: str, file_path: Optional[str]) -> Optiona
137135 """Ensure the source is available as a file on disk for ty-types.
138136
139137 Returns the absolute file path, or None if unavailable.
138+ When file_path is given but doesn't exist, writes source there.
139+ Callers are responsible for providing safe paths (e.g. within a temp directory).
140140 """
141141 if file_path :
142142 path = Path (file_path )
143143 if not path .is_absolute ():
144144 path = path .resolve ()
145145 if path .exists ():
146146 return str (path )
147- # File path given but doesn't exist — write source there
147+ # File path given but doesn't exist — write source there.
148+ # The parent directory must already exist (caller should ensure this).
148149 try :
149- path .parent .mkdir (parents = True , exist_ok = True )
150150 path .write_text (source , encoding = 'utf-8' )
151151 self ._temp_file = path
152152 return str (path )
@@ -177,7 +177,7 @@ def _compute_line_byte_offsets(source: str) -> List[int]:
177177 def _pos_to_byte_offset (self , lineno : int , col_offset : int ) -> int :
178178 """Convert AST (lineno, col_offset) to an absolute byte offset.
179179
180- Python's ast uses 1-based lineno and character -based col_offset.
180+ Python's ast uses 1-based lineno and 0 -based character col_offset (Python 3.8+) .
181181 ty-types uses absolute byte offsets (ruff convention).
182182 """
183183 line_start = self ._line_byte_offsets [lineno - 1 ]
@@ -276,7 +276,7 @@ def _resolve_type(self, type_id: int) -> Optional[JavaType]:
276276 if type_id in self ._cycle_placeholders :
277277 placeholder = self ._cycle_placeholders .pop (type_id )
278278 if isinstance (result , JavaType .Class ):
279- placeholder ._fully_qualified_name = result ._fully_qualified_name
279+ placeholder ._fully_qualified_name = result .fully_qualified_name
280280 placeholder ._kind = result ._kind
281281 # Copy enriched fields so cycle placeholders retain supertypes/methods
282282 for attr in ('_supertype' , '_methods' , '_type_parameters' , '_interfaces' ,
@@ -285,8 +285,8 @@ def _resolve_type(self, type_id: int) -> Optional[JavaType]:
285285 if val is not None :
286286 setattr (placeholder , attr , val )
287287 elif isinstance (result , JavaType .Parameterized ):
288- if hasattr (result ._type , '_fully_qualified_name ' ):
289- placeholder ._fully_qualified_name = result ._type ._fully_qualified_name
288+ if hasattr (result ._type , 'fully_qualified_name ' ):
289+ placeholder ._fully_qualified_name = result ._type .fully_qualified_name
290290 self ._type_id_cache [type_id ] = placeholder
291291 return placeholder
292292
@@ -351,16 +351,24 @@ def _descriptor_to_java_type(self, descriptor: Dict[str, Any]) -> Optional[JavaT
351351 return JavaType .Primitive .String
352352
353353 elif kind == 'union' :
354- # Unwrap union: take first non-None type
354+ # Resolve all non-None members into a Union type.
355+ # For Optional[X] (= X | None) with a single real member, unwrap to just X.
356+ resolved_bounds = []
355357 for member_id in descriptor .get ('members' , []):
356358 member = self ._type_registry .get (member_id )
357359 if member :
358360 member_kind = member .get ('kind' )
359361 # Skip None/NoneType members
360362 if member_kind == 'instance' and member .get ('className' ) in ('None' , 'NoneType' ):
361363 continue
362- return self ._resolve_type (member_id )
363- return _UNKNOWN
364+ resolved = self ._resolve_type (member_id )
365+ if resolved is not None :
366+ resolved_bounds .append (resolved )
367+ if not resolved_bounds :
368+ return _UNKNOWN
369+ if len (resolved_bounds ) == 1 :
370+ return resolved_bounds [0 ]
371+ return JavaType .Union (_bounds = resolved_bounds )
364372
365373 elif kind == 'module' :
366374 module_name = descriptor .get ('moduleName' , '' )
@@ -691,7 +699,12 @@ def _get_declaring_type(self, node: ast.Call) -> Optional[JavaType.FullyQualifie
691699 return self ._infer_declaring_type_from_ast (node )
692700
693701 def _resolve_declaring_type (self , type_id : int ) -> Optional [JavaType .FullyQualified ]:
694- """Resolve a type ID to a declaring type, maximizing object reuse."""
702+ """Resolve a type ID to a declaring type, maximizing object reuse.
703+
704+ NOTE: The cycle-detection pattern here mirrors _resolve_type intentionally.
705+ They use separate caches and placeholder dicts because declaring types are
706+ resolved independently (often to a simpler Class without methods/members).
707+ """
695708 if type_id in self ._declaring_type_id_cache :
696709 return self ._declaring_type_id_cache [type_id ]
697710
@@ -720,7 +733,7 @@ def _resolve_declaring_type(self, type_id: int) -> Optional[JavaType.FullyQualif
720733 if type_id in self ._declaring_cycle_placeholders :
721734 placeholder = self ._declaring_cycle_placeholders .pop (type_id )
722735 if isinstance (result , JavaType .Class ):
723- placeholder ._fully_qualified_name = result ._fully_qualified_name
736+ placeholder ._fully_qualified_name = result .fully_qualified_name
724737 placeholder ._kind = result ._kind
725738 self ._declaring_type_id_cache [type_id ] = placeholder
726739 return placeholder
0 commit comments