33import importlib
44import os
55import pkgutil
6+ import re
67import sys
78import token
89import tokenize
1617TYPE_CHECKING = False
1718
1819if TYPE_CHECKING :
20+ from types import ModuleType
1921 from typing import Any , Iterable , Iterator , Mapping
22+ from .types import CompletionAction
2023
2124
2225HARDCODED_SUBMODULES = {
2831 "xml.parsers.expat" : ["errors" , "model" ],
2932}
3033
34+ AUTO_IMPORT_DENYLIST = {
35+ # Standard library modules/submodules that have import side effects
36+ # and must not be automatically imported to complete attributes
37+ re .compile (r"antigravity" ), # Calls webbrowser.open
38+ re .compile (r"idlelib\..+" ), # May open IDLE GUI
39+ re .compile (r"test\..+" ), # Various side-effects
40+ re .compile (r"this" ), # Prints to stdout
41+ re .compile (r"_ios_support" ), # Spawns a subprocess
42+ re .compile (r".+\.__main__" ), # Should not be imported
43+ }
44+
3145
3246def make_default_module_completer () -> ModuleCompleter :
3347 # Inside pyrepl, __package__ is set to None by default
@@ -53,11 +67,17 @@ class ModuleCompleter:
5367 def __init__ (self , namespace : Mapping [str , Any ] | None = None ) -> None :
5468 self .namespace = namespace or {}
5569 self ._global_cache : list [pkgutil .ModuleInfo ] = []
70+ self ._failed_imports : set [str ] = set ()
5671 self ._curr_sys_path : list [str ] = sys .path [:]
5772 self ._stdlib_path = os .path .dirname (importlib .__path__ [0 ])
5873
59- def get_completions (self , line : str ) -> list [str ] | None :
60- """Return the next possible import completions for 'line'."""
74+ def get_completions (self , line : str ) -> tuple [list [str ], CompletionAction | None ] | None :
75+ """Return the next possible import completions for 'line'.
76+
77+ For attributes completion, if the module to complete from is not
78+ imported, also return an action (prompt + callback to run if the
79+ user press TAB again) to import the module.
80+ """
6181 result = ImportParser (line ).parse ()
6282 if not result :
6383 return None
@@ -66,24 +86,26 @@ def get_completions(self, line: str) -> list[str] | None:
6686 except Exception :
6787 # Some unexpected error occurred, make it look like
6888 # no completions are available
69- return []
89+ return [], None
7090
71- def complete (self , from_name : str | None , name : str | None ) -> list [str ]:
91+ def complete (self , from_name : str | None , name : str | None ) -> tuple [ list [str ], CompletionAction | None ]:
7292 if from_name is None :
7393 # import x.y.z<tab>
7494 assert name is not None
7595 path , prefix = self .get_path_and_prefix (name )
7696 modules = self .find_modules (path , prefix )
77- return [self .format_completion (path , module ) for module in modules ]
97+ return [self .format_completion (path , module ) for module in modules ], None
7898
7999 if name is None :
80100 # from x.y.z<tab>
81101 path , prefix = self .get_path_and_prefix (from_name )
82102 modules = self .find_modules (path , prefix )
83- return [self .format_completion (path , module ) for module in modules ]
103+ return [self .format_completion (path , module ) for module in modules ], None
84104
85105 # from x.y import z<tab>
86- return self .find_modules (from_name , name )
106+ submodules = self .find_modules (from_name , name )
107+ attributes , action = self .find_attributes (from_name , name )
108+ return sorted ({* submodules , * attributes }), action
87109
88110 def find_modules (self , path : str , prefix : str ) -> list [str ]:
89111 """Find all modules under 'path' that start with 'prefix'."""
@@ -101,23 +123,25 @@ def _find_modules(self, path: str, prefix: str) -> list[str]:
101123 if self .is_suggestion_match (module .name , prefix )]
102124 return sorted (builtin_modules + third_party_modules )
103125
104- if path .startswith ('.' ):
105- # Convert relative path to absolute path
106- package = self .namespace .get ('__package__' , '' )
107- path = self .resolve_relative_name (path , package ) # type: ignore[assignment]
108- if path is None :
109- return []
126+ path = self ._resolve_relative_path (path ) # type: ignore[assignment]
127+ if path is None :
128+ return []
110129
111130 modules : Iterable [pkgutil .ModuleInfo ] = self .global_cache
112131 imported_module = sys .modules .get (path .split ('.' )[0 ])
113132 if imported_module :
114- # Filter modules to those who name and specs match the
133+ # Filter modules to those whose name and specs match the
115134 # imported module to avoid invalid suggestions
116135 spec = imported_module .__spec__
117136 if spec :
137+ def _safe_find_spec (mod : pkgutil .ModuleInfo ) -> bool :
138+ try :
139+ return mod .module_finder .find_spec (mod .name , None ) == spec
140+ except Exception :
141+ return False
118142 modules = [mod for mod in modules
119143 if mod .name == spec .name
120- and mod . module_finder . find_spec (mod . name , None ) == spec ]
144+ and _safe_find_spec (mod ) ]
121145 else :
122146 modules = []
123147
@@ -142,6 +166,32 @@ def _is_stdlib_module(self, module_info: pkgutil.ModuleInfo) -> bool:
142166 return (isinstance (module_info .module_finder , FileFinder )
143167 and module_info .module_finder .path == self ._stdlib_path )
144168
169+ def find_attributes (self , path : str , prefix : str ) -> tuple [list [str ], CompletionAction | None ]:
170+ """Find all attributes of module 'path' that start with 'prefix'."""
171+ attributes , action = self ._find_attributes (path , prefix )
172+ # Filter out invalid attribute names
173+ # (for example those containing dashes that cannot be imported with 'import')
174+ return [attr for attr in attributes if attr .isidentifier ()], action
175+
176+ def _find_attributes (self , path : str , prefix : str ) -> tuple [list [str ], CompletionAction | None ]:
177+ path = self ._resolve_relative_path (path ) # type: ignore[assignment]
178+ if path is None :
179+ return [], None
180+
181+ imported_module = sys .modules .get (path )
182+ if not imported_module :
183+ if path in self ._failed_imports : # Do not propose to import again
184+ return [], None
185+ imported_module = self ._maybe_import_module (path )
186+ if not imported_module :
187+ return [], self ._get_import_completion_action (path )
188+ try :
189+ module_attributes = dir (imported_module )
190+ except Exception :
191+ module_attributes = []
192+ return [attr_name for attr_name in module_attributes
193+ if self .is_suggestion_match (attr_name , prefix )], None
194+
145195 def is_suggestion_match (self , module_name : str , prefix : str ) -> bool :
146196 if prefix :
147197 return module_name .startswith (prefix )
@@ -186,6 +236,13 @@ def format_completion(self, path: str, module: str) -> str:
186236 return f'{ path } { module } '
187237 return f'{ path } .{ module } '
188238
239+ def _resolve_relative_path (self , path : str ) -> str | None :
240+ """Resolve a relative import path to absolute. Returns None if unresolvable."""
241+ if path .startswith ('.' ):
242+ package = self .namespace .get ('__package__' , '' )
243+ return self .resolve_relative_name (path , package )
244+ return path
245+
189246 def resolve_relative_name (self , name : str , package : str ) -> str | None :
190247 """Resolve a relative module name to an absolute name.
191248
@@ -210,8 +267,39 @@ def global_cache(self) -> list[pkgutil.ModuleInfo]:
210267 if not self ._global_cache or self ._curr_sys_path != sys .path :
211268 self ._curr_sys_path = sys .path [:]
212269 self ._global_cache = list (pkgutil .iter_modules ())
270+ self ._failed_imports .clear () # retry on sys.path change
213271 return self ._global_cache
214272
273+ def _maybe_import_module (self , fqname : str ) -> ModuleType | None :
274+ if any (pattern .fullmatch (fqname ) for pattern in AUTO_IMPORT_DENYLIST ):
275+ # Special-cased modules with known import side-effects
276+ return None
277+ root = fqname .split ("." )[0 ]
278+ mod_info = next ((m for m in self .global_cache if m .name == root ), None )
279+ if not mod_info or not self ._is_stdlib_module (mod_info ):
280+ # Only import stdlib modules (no risk of import side-effects)
281+ return None
282+ try :
283+ return importlib .import_module (fqname )
284+ except Exception :
285+ sys .modules .pop (fqname , None ) # Clean half-imported module
286+ return None
287+
288+ def _get_import_completion_action (self , path : str ) -> CompletionAction :
289+ prompt = ("[ module not imported, press again to import it "
290+ "and propose attributes ]" )
291+
292+ def _do_import () -> str | None :
293+ try :
294+ importlib .import_module (path )
295+ return None
296+ except Exception as exc :
297+ sys .modules .pop (path , None ) # Clean half-imported module
298+ self ._failed_imports .add (path )
299+ return f"[ error during import: { exc } ]"
300+
301+ return (prompt , _do_import )
302+
215303
216304class ImportParser :
217305 """
0 commit comments