55
66from pathlib import Path
77
8+ from apathetic_logging import getLogger
9+
810
911class ApatheticUtils_Internal_Modules : # noqa: N801 # pyright: ignore[reportUnusedClass]
1012 """Mixin class providing module detection utilities.
@@ -13,74 +15,268 @@ class ApatheticUtils_Internal_Modules: # noqa: N801 # pyright: ignore[reportUn
1315 """
1416
1517 @staticmethod
16- def _find_package_root_for_file (file_path : Path ) -> Path | None :
17- """Find the package root for a file by walking up looking for __init__.py.
18+ def _find_package_root_for_file (
19+ file_path : Path ,
20+ * ,
21+ source_bases : list [str ] | None = None ,
22+ _config_dir : Path | None = None ,
23+ ) -> Path | None :
24+ """Find the package root for a file.
1825
19- Starting from the file's directory, walks up the directory tree while
20- we find __init__.py files. The topmost directory with __init__.py is
21- the package root .
26+ First checks for __init__.py files (definitive package marker).
27+ If no __init__.py found and file is under a source_bases directory,
28+ treats everything after the matching base prefix as a package structure .
2229
2330 Args:
2431 file_path: Path to the Python file
32+ source_bases: Optional list of module base directories (absolute paths)
33+ _config_dir: Optional config directory (unused, kept for compatibility)
2534
2635 Returns:
2736 Path to the package root directory, or None if not found
2837 """
29- current_dir = file_path .parent .resolve ()
38+ logger = getLogger ()
39+ file_path_resolved = file_path .resolve ()
40+ current_dir = file_path_resolved .parent
3041 last_package_dir : Path | None = None
3142
32- # Walk up from the file's directory
43+ logger .trace (
44+ "[PKG_ROOT] Finding package root for %s, starting from %s" ,
45+ file_path .name ,
46+ current_dir ,
47+ )
48+
49+ # First, walk up looking for __init__.py (definitive package marker)
50+ # __init__.py always takes precedence
3351 while True :
3452 # Check if current directory has __init__.py
3553 init_file = current_dir / "__init__.py"
3654 if init_file .exists ():
3755 # This directory is part of a package
3856 last_package_dir = current_dir
39- else :
40- # This directory doesn't have __init__.py, so we've gone past
41- # the package. Return the last directory that had __init__.py
57+ logger .trace (
58+ "[PKG_ROOT] Found __init__.py at %s (package root so far: %s)" ,
59+ current_dir ,
60+ last_package_dir ,
61+ )
62+ # This directory doesn't have __init__.py
63+ # If we found a package via __init__.py, return it
64+ elif last_package_dir is not None :
65+ logger .trace (
66+ "[PKG_ROOT] No __init__.py at %s, package root: %s" ,
67+ current_dir ,
68+ last_package_dir ,
69+ )
4270 return last_package_dir
71+ # No __init__.py found yet, continue walking up
72+ # (we'll check source_bases after this loop if needed)
4373
4474 # Move up one level
4575 parent = current_dir .parent
4676 if parent == current_dir :
4777 # Reached filesystem root
48- return last_package_dir
78+ if last_package_dir is not None :
79+ logger .trace (
80+ "[PKG_ROOT] Reached filesystem root, package root: %s" ,
81+ last_package_dir ,
82+ )
83+ return last_package_dir
84+ # No __init__.py found, break to check source_bases
85+ break
4986 current_dir = parent
5087
88+ # If no __init__.py found, check if file is under any source_bases directory
89+ if source_bases and last_package_dir is None :
90+ for base_str in source_bases :
91+ # base_str is already an absolute path
92+ base_path = Path (base_str ).resolve ()
93+ try :
94+ # Check if file is under this base
95+ rel_path = file_path_resolved .relative_to (base_path )
96+ # If file is directly in base (e.g., src/mymodule.py), no package
97+ if len (rel_path .parts ) == 1 :
98+ # Single file in base - not a package
99+ continue
100+ # File is in a subdirectory of base (e.g., src/mypkg/submodule.py)
101+ # The first part after base is the package
102+ package_dir = base_path / rel_path .parts [0 ]
103+ if package_dir .exists () and package_dir .is_dir ():
104+ logger .trace (
105+ "[PKG_ROOT] Found package via source_bases: %s (base: %s)" ,
106+ package_dir ,
107+ base_path ,
108+ )
109+ return package_dir
110+ except ValueError :
111+ # File is not under this base, continue to next base
112+ continue
113+
114+ # Return None if no package found
115+ return last_package_dir
116+
51117 @staticmethod
52- def detect_packages_from_files (
118+ def detect_packages_from_files ( # noqa: C901, PLR0912, PLR0915
53119 file_paths : list [Path ],
54120 package_name : str ,
55- ) -> set [str ]:
56- """Detect packages by walking up from files looking for __init__.py.
121+ * ,
122+ source_bases : list [str ] | None = None ,
123+ _config_dir : Path | None = None ,
124+ ) -> tuple [set [str ], list [str ]]:
125+ """Detect packages from file paths.
57126
58- Follows Python's import rules: only detects regular packages (with
59- __init__.py files). Falls back to configured package_name if none detected.
127+ If files are under source_bases directories, treats everything after the
128+ matching base prefix as a package structure (regardless of __init__.py).
129+ Otherwise, follows Python's import rules: only detects regular packages
130+ (with __init__.py files). Falls back to configured package_name if none
131+ detected.
60132
61133 Args:
62134 file_paths: List of file paths to check
63135 package_name: Configured package name (used as fallback)
136+ source_bases: Optional list of module base directories (absolute paths)
137+ _config_dir: Optional config directory (unused, kept for compatibility)
64138
65139 Returns:
66- Set of detected package names (always includes package_name)
140+ Tuple of (set of detected package names, list of parent directories).
141+ Package names always includes package_name. Parent directories are
142+ returned as absolute paths, deduplicated.
67143 """
144+ logger = getLogger ()
68145 detected : set [str ] = set ()
146+ parent_dirs : list [Path ] = []
147+ seen_parents : set [Path ] = set ()
69148
70- # Detect packages from __init__.py files
149+ # Detect packages from files
71150 for file_path in file_paths :
72151 pkg_root = ApatheticUtils_Internal_Modules ._find_package_root_for_file (
73- file_path
152+ file_path , source_bases = source_bases
74153 )
75154 if pkg_root :
76155 # Extract package name from directory name
77156 pkg_name = pkg_root .name
78157 detected .add (pkg_name )
79158
159+ # Extract parent directory (module base)
160+ parent_dir = pkg_root .parent .resolve ()
161+ # Check if parent is filesystem root (parent of root equals root)
162+ is_root = parent_dir .parent == parent_dir
163+ if not is_root and parent_dir not in seen_parents :
164+ seen_parents .add (parent_dir )
165+ parent_dirs .append (parent_dir )
166+
167+ logger .trace (
168+ "[PKG_DETECT] Detected package %s from %s (root: %s, parent: %s)" ,
169+ pkg_name ,
170+ file_path ,
171+ pkg_root ,
172+ parent_dir ,
173+ )
174+
175+ # Also detect directories in source_bases as packages if they contain
176+ # subdirectories that are packages (namespace packages)
177+ # This must happen BEFORE adding package_name to detected, so we can check
178+ # if base_name == package_name correctly
179+ # Compute common root of all files to avoid detecting it as a package
180+ common_root : Path | None = None
181+ if file_paths :
182+ common_root = file_paths [0 ].parent
183+ for file_path in file_paths [1 :]:
184+ # Find common prefix of paths
185+ common_parts = [
186+ p
187+ for p , q in zip (
188+ common_root .parts , file_path .parent .parts , strict = False
189+ )
190+ if p == q
191+ ]
192+ if common_parts :
193+ common_root = Path (* common_parts )
194+ else :
195+ # No common root, use first file's parent
196+ common_root = file_paths [0 ].parent
197+ break
198+ if source_bases :
199+ for base_str in source_bases :
200+ base_path = Path (base_str ).resolve ()
201+ if not base_path .exists () or not base_path .is_dir ():
202+ continue
203+ # Check if this base contains any detected packages as direct children
204+ base_name = base_path .name
205+ # Skip if base is filesystem root, empty name, already detected,
206+ # is package_name, or is the common root of all files
207+ if (
208+ not base_name
209+ or base_name in detected
210+ or base_name == package_name
211+ or base_path == base_path .parent # filesystem root
212+ or (common_root and base_path == common_root .resolve ())
213+ ):
214+ logger .trace (
215+ "[PKG_DETECT] Skipping base %s: name=%s, in_detected=%s, "
216+ "is_package_name=%s, is_common_root=%s" ,
217+ base_path ,
218+ base_name ,
219+ base_name in detected ,
220+ base_name == package_name ,
221+ common_root and base_path == common_root .resolve (),
222+ )
223+ continue
224+ # Check if any detected package has this base as its parent
225+ for file_path in file_paths :
226+ pkg_root = (
227+ ApatheticUtils_Internal_Modules ._find_package_root_for_file (
228+ file_path , source_bases = source_bases
229+ )
230+ )
231+ if pkg_root :
232+ pkg_parent = pkg_root .parent .resolve ()
233+ logger .trace (
234+ "[PKG_DETECT] Checking base: %s (base_path=%s), "
235+ "pkg_root=%s, pkg_parent=%s, match=%s" ,
236+ base_name ,
237+ base_path ,
238+ pkg_root ,
239+ pkg_parent ,
240+ pkg_parent == base_path ,
241+ )
242+ if pkg_parent == base_path :
243+ # This base contains a detected package,
244+ # so it's also a package
245+ detected .add (base_name )
246+ logger .trace (
247+ "[PKG_DETECT] Detected base directory as package: %s "
248+ "(contains package: %s)" ,
249+ base_name ,
250+ pkg_root .name ,
251+ )
252+ break
253+
80254 # Always include configured package (for fallback and multi-package scenarios)
81255 detected .add (package_name )
82256
83- return detected
257+ # Return parent directories as absolute paths
258+ normalized_parents : list [str ] = []
259+ seen_normalized : set [str ] = set ()
260+
261+ for parent_dir in parent_dirs :
262+ base_str = str (parent_dir )
263+ if base_str not in seen_normalized :
264+ seen_normalized .add (base_str )
265+ normalized_parents .append (base_str )
266+
267+ if len (detected ) == 1 and package_name in detected :
268+ logger .debug (
269+ "Package detection: No packages found, using configured package '%s'" ,
270+ package_name ,
271+ )
272+ else :
273+ logger .debug (
274+ "Package detection: Found %d package(s): %s" ,
275+ len (detected ),
276+ sorted (detected ),
277+ )
278+
279+ return detected , normalized_parents
84280
85281 @staticmethod
86282 def find_all_packages_under_path (root_path : Path ) -> set [str ]:
0 commit comments