1414# limitations under the License.
1515
1616import functools
17+ import configparser
1718import json
1819import os
1920import platform
@@ -185,7 +186,7 @@ def _normalize_realpath(path: str | None) -> str:
185186 if not path :
186187 return ""
187188 try :
188- return os .path .realpath (path )
189+ return os .path .realpath (os . path . abspath ( path ) )
189190 except Exception :
190191 return path
191192
@@ -194,7 +195,9 @@ def _is_path_under(path: str, root: str) -> bool:
194195 if not path or not root :
195196 return False
196197 try :
197- return os .path .commonpath ([path , root ]) == root
198+ p = _normalize_realpath (path )
199+ r = _normalize_realpath (root )
200+ return os .path .commonpath ([p , r ]) == r
198201 except Exception :
199202 return False
200203
@@ -203,33 +206,116 @@ def _collect_workspace_module_roots(
203206 filtered_files : list [str ], workspace_dir : str | None
204207) -> set [str ]:
205208 """
206- Build a conservative set of internal module roots from workspace files.
207- Handles common src-layout aliases (src/, lib/, python/) .
209+ Build internal module roots from workspace files.
210+ Uses __init__.py package chains, src-layout aliases and project metadata .
208211 """
209212 roots : set [str ] = set ()
210213 ws = _normalize_realpath (workspace_dir )
211- src_aliases = {"src" , "lib" , "python" }
214+ if not ws :
215+ return roots
216+
217+ def _sanitize_module_name (name : str ) -> str :
218+ if not name :
219+ return ""
220+ token = str (name ).strip ().replace ("-" , "_" ).split ("." )[0 ]
221+ return token if token .isidentifier () else ""
222+
223+ def _add_declared_project_names () -> None :
224+ pyproject_path = os .path .join (ws , "pyproject.toml" )
225+ if os .path .isfile (pyproject_path ):
226+ try :
227+ import tomllib
228+
229+ with open (pyproject_path , "rb" ) as f :
230+ data = tomllib .load (f )
231+ candidates = []
232+ project = data .get ("project" , {})
233+ if isinstance (project , dict ):
234+ candidates .append (project .get ("name" , "" ))
235+ tool = data .get ("tool" , {})
236+ if isinstance (tool , dict ):
237+ poetry = tool .get ("poetry" , {})
238+ if isinstance (poetry , dict ):
239+ candidates .append (poetry .get ("name" , "" ))
240+ for candidate in candidates :
241+ module_name = _sanitize_module_name (candidate )
242+ if module_name :
243+ roots .add (module_name )
244+ except Exception :
245+ pass
246+
247+ setup_cfg_path = os .path .join (ws , "setup.cfg" )
248+ if os .path .isfile (setup_cfg_path ):
249+ try :
250+ parser = configparser .ConfigParser ()
251+ parser .read (setup_cfg_path , encoding = "utf-8" )
252+ if parser .has_section ("metadata" ):
253+ module_name = _sanitize_module_name (
254+ parser .get ("metadata" , "name" , fallback = "" )
255+ )
256+ if module_name :
257+ roots .add (module_name )
258+ except Exception :
259+ pass
260+
261+ def _add_chain_from_init (module_dir : str ) -> None :
262+ current_dir = module_dir
263+ top_package_name = ""
264+ while _is_path_under (current_dir , ws ):
265+ init_py = os .path .join (current_dir , "__init__.py" )
266+ if not os .path .isfile (init_py ):
267+ break
268+ rel_dir = os .path .relpath (current_dir , ws )
269+ parts = [p for p in rel_dir .split (os .sep ) if p ]
270+ if parts :
271+ if parts [0 ] in {"src" , "lib" , "python" } and len (parts ) > 1 :
272+ top_package_name = parts [1 ]
273+ else :
274+ top_package_name = parts [0 ]
275+ parent = os .path .dirname (current_dir )
276+ if parent == current_dir :
277+ break
278+ current_dir = parent
279+ if top_package_name :
280+ roots .add (top_package_name )
281+
212282 for f in filtered_files :
213283 try :
214284 abs_f = _normalize_realpath (f )
215- base = os .path .splitext (os .path .basename (abs_f ))[0 ]
216- if base and base != "__init__" :
217- roots .add (base )
218- if not ws or not _is_path_under (abs_f , ws ):
285+ if not _is_path_under (abs_f , ws ):
219286 continue
220287 rel = os .path .relpath (abs_f , ws )
221- parts = rel .split (os .sep )
288+ rel_no_ext = os .path .splitext (rel )[0 ]
289+ parts = [p for p in rel_no_ext .split (os .sep ) if p ]
222290 if not parts :
223291 continue
224- first = parts [0 ]
225- if first and first not in {"" , "." , "__pycache__" }:
226- roots .add (first )
227- if first in src_aliases and len (parts ) > 1 :
228- second = parts [1 ]
229- if second and second not in {"" , "." , "__pycache__" }:
230- roots .add (second )
292+ if parts [0 ] in {"src" , "lib" , "python" } and len (parts ) > 1 :
293+ roots .add (parts [1 ])
294+ roots .add (parts [0 ])
295+ if len (parts ) > 1 and parts [- 1 ] == "__init__" :
296+ roots .add (parts [- 2 ])
297+ _add_chain_from_init (os .path .dirname (abs_f ))
231298 except Exception :
232299 pass
300+
301+ # Extra scan for top-level packages that may not be in the selected file list.
302+ for layout in ("" , "src" , "lib" , "python" ):
303+ base_dir = ws if not layout else os .path .join (ws , layout )
304+ if not os .path .isdir (base_dir ):
305+ continue
306+ try :
307+ for entry in os .listdir (base_dir ):
308+ if entry .startswith ("." ):
309+ continue
310+ entry_path = os .path .join (base_dir , entry )
311+ if not os .path .isdir (entry_path ):
312+ continue
313+ if os .path .isfile (os .path .join (entry_path , "__init__.py" )) and entry .isidentifier ():
314+ roots .add (entry )
315+ except Exception :
316+ pass
317+
318+ _add_declared_project_names ()
233319 return roots
234320
235321
@@ -447,37 +533,38 @@ def _t(_key: str, fr: str, en: str) -> str:
447533 venv_dir = os .path .abspath (self .venv_path_manuel )
448534 else :
449535 venv_dir = os .path .abspath (os .path .join (self .workspace_dir , "venv" ))
450- filtered_files = [
451- f
452- for f in files
453- if not os .path .commonpath ([os .path .abspath (f ), venv_dir ]) == venv_dir
454- and not any (
455- part .startswith ("." )
456- or part
457- == (
458- "**/__pycache__/**" ,
459- "**/*.pyc" ,
460- "**/*.pyo" ,
461- "**/*.pyd" ,
462- ".git/**" ,
463- ".svn/**" ,
464- ".hg/**" ,
465- "venv/**" ,
466- ".venv/**" ,
467- "env/**" ,
468- ".env/**" ,
469- "node_modules/**" ,
470- "build/**" ,
471- "dist/**" ,
472- "*.egg-info/**" ,
473- ".pytest_cache/**" ,
474- ".mypy_cache/**" ,
475- ".tox/**" ,
476- "site-packages/**" ,
477- )
478- for part in f .split (os .sep )
479- )
480- ]
536+ excluded_dir_names = {
537+ "__pycache__" ,
538+ ".git" ,
539+ ".svn" ,
540+ ".hg" ,
541+ "venv" ,
542+ ".venv" ,
543+ "env" ,
544+ ".env" ,
545+ "node_modules" ,
546+ "build" ,
547+ "dist" ,
548+ ".pytest_cache" ,
549+ ".mypy_cache" ,
550+ ".tox" ,
551+ "site-packages" ,
552+ }
553+ filtered_files = []
554+ for file_path in files :
555+ abs_file = os .path .abspath (file_path )
556+ if _is_path_under (abs_file , venv_dir ):
557+ continue
558+ rel_parts = [p for p in abs_file .split (os .sep ) if p ]
559+ if any (p .startswith ("." ) and p not in {"." , ".." } for p in rel_parts ):
560+ continue
561+ if any (p in excluded_dir_names for p in rel_parts ):
562+ continue
563+ if abs_file .endswith ((".pyc" , ".pyo" , ".pyd" )):
564+ continue
565+ if ".egg-info" in abs_file :
566+ continue
567+ filtered_files .append (file_path )
481568
482569 # Créer une barre de progression pour l'analyse
483570 analysis_progress = None
@@ -521,6 +608,11 @@ def _t(_key: str, fr: str, en: str) -> str:
521608 elif isinstance (node , ast .ImportFrom ):
522609 if node .module :
523610 modules .add (node .module .split ("." )[0 ])
611+ elif getattr (node , "level" , 0 ) > 0 :
612+ for alias in getattr (node , "names" , []) or []:
613+ name = getattr (alias , "name" , "" )
614+ if name :
615+ modules .add (name .split ("." )[0 ])
524616 # Imports dynamiques via __import__ ou importlib.import_module
525617 dynamic_imports = re .findall (r"__import__\(['\"]([\w\.]+)['\"]\)" , source )
526618 modules .update ([mod .split ("." )[0 ] for mod in dynamic_imports ])
0 commit comments