Skip to content

Commit b7dc2a9

Browse files
committed
refactor(tests): simplify test utilities and remove unused modules
- Remove unused test utility modules (build_tools, ci, level_validation, mock_superclass, package_detection, patch_everywhere, runtime_detection, runtime_swap, strip_common_prefix, subprocess_output, version_info_mock) - Consolidate tests/utils to only export constants - Update tests to work with simplified utilities - Enhance modules.py with improved package root detection
1 parent 501f68b commit b7dc2a9

20 files changed

+267
-1182
lines changed

src/apathetic_utils/modules.py

Lines changed: 216 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55

66
from pathlib import Path
77

8+
from apathetic_logging import getLogger
9+
810

911
class 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]:

src/apathetic_utils/testing.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import sys
88
from collections.abc import Callable
99
from contextlib import suppress
10+
from pathlib import Path
1011
from types import ModuleType
1112
from typing import Any
1213
from unittest.mock import MagicMock
@@ -21,6 +22,19 @@ class ApatheticUtils_Internal_Testing: # noqa: N801 # pyright: ignore[reportUn
2122
that can be used across multiple projects.
2223
"""
2324

25+
@staticmethod
26+
def _short_path(path: str | None) -> str:
27+
"""Return a shortened version of a path for logging."""
28+
if not path:
29+
return "n/a"
30+
# Use a simple approach: show last MAX_PATH_COMPONENTS or full path if shorter
31+
MAX_PATH_COMPONENTS = 3
32+
path_obj = Path(path)
33+
parts = path_obj.parts
34+
if len(parts) > MAX_PATH_COMPONENTS:
35+
return str(Path(*parts[-MAX_PATH_COMPONENTS:]))
36+
return path
37+
2438
@staticmethod
2539
def is_running_under_pytest() -> bool:
2640
"""Detect if code is running under pytest.
@@ -264,5 +278,8 @@ def patch_everywhere( # noqa: C901, PLR0912
264278
did_patch = True
265279

266280
if did_patch and id(m) not in patched_ids:
267-
safeTrace(f" also patched {name} (path={path})")
281+
safeTrace(
282+
f" also patched {name} "
283+
f"(path={ApatheticUtils_Internal_Testing._short_path(path)})"
284+
)
268285
patched_ids.add(id(m))

tests/30_independant/test_fnmatchcase_portable.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
import pytest
77

88
import apathetic_utils as mod_autils
9-
from tests.utils import patch_everywhere
9+
from tests.utils.constants import PATCH_STITCH_HINTS, PROGRAM_PACKAGE
1010

1111

1212
def test_fnmatchcase_portable_non_recursive_pattern() -> None:
@@ -37,11 +37,13 @@ def test_fnmatchcase_portable_recursive_pattern_py310(
3737
# --- setup ---
3838
# Force Python 3.10
3939
fake_sys = SimpleNamespace(version_info=(3, 10, 0))
40-
patch_everywhere(
40+
mod_autils.patch_everywhere(
4141
monkeypatch,
4242
mod_autils.apathetic_utils,
4343
"get_sys_version_info",
4444
lambda: fake_sys.version_info,
45+
PROGRAM_PACKAGE,
46+
PATCH_STITCH_HINTS,
4547
)
4648

4749
# --- execute and verify ---

tests/30_independant/test_is_excluded_raw.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
import pytest
2525

2626
import apathetic_utils as mod_autils
27-
from tests.utils import patch_everywhere
27+
from tests.utils.constants import PATCH_STITCH_HINTS, PROGRAM_PACKAGE
2828

2929

3030
def test_is_excluded_raw_matches_patterns(tmp_path: Path) -> None:
@@ -210,11 +210,13 @@ def test_gitignore_double_star_backport_py310(
210210
# Force utils to think it's running on Python 3.10
211211
fake_sys = SimpleNamespace(version_info=(3, 10, 0))
212212
# Patch on the namespace class where the method actually exists
213-
patch_everywhere(
213+
mod_autils.patch_everywhere(
214214
monkeypatch,
215215
mod_autils.apathetic_utils,
216216
"get_sys_version_info",
217217
lambda: fake_sys.version_info,
218+
PROGRAM_PACKAGE,
219+
PATCH_STITCH_HINTS,
218220
)
219221
result = mod_autils.is_excluded_raw(nested, ["dir/**/*.py"], root)
220222

0 commit comments

Comments
 (0)