Skip to content

Commit f7e9a00

Browse files
committed
♻️ refactor(tests): stop importing private modules directly
Use from-imports and mocker.patch string paths instead of importing private modules as objects for monkeypatching.
1 parent 3b381df commit f7e9a00

19 files changed

Lines changed: 576 additions & 547 deletions

pyproject.toml

Lines changed: 23 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -74,51 +74,23 @@ lint.select = [
7474
"ALL",
7575
]
7676
lint.ignore = [
77-
"ANN001", # Missing type annotation for function argument
78-
"ANN002", # Missing type annotation for *args
79-
"ANN202", # Missing return type annotation for private function
80-
"ANN204", # Missing return type annotation for special method
81-
"ANN205", # Missing return type annotation for staticmethod
82-
"ANN206", # Missing return type annotation for classmethod
83-
"ANN401", # Dynamically typed expressions (typing.Any)
84-
"ARG001", # Unused function argument
85-
"ARG005", # Unused lambda argument
86-
"B905", # zip-without-explicit-strict (conflicts with ty on 3.8)
87-
"C901", # Too complex
88-
"COM812", # Conflict with formatter
89-
"CPY", # No copyright statements
90-
"D", # Docstring rules
91-
"DOC", # Docstring content rules
92-
"E501", # Line too long (formatter handles it)
93-
"FBT", # Boolean positional arguments
94-
"INP001", # Implicit namespace package
95-
"ISC001", # Conflict with formatter
96-
"PLC0415", # import at top level
97-
"PLR0904", # Too many public methods
98-
"PLR0911", # Too many return statements
99-
"PLR0912", # Too many branches
100-
"PLR0913", # Too many arguments
101-
"PLR0914", # Too many local variables
102-
"PLR0915", # Too many statements
103-
"PLR0917", # Too many positional arguments
104-
"PLR1702", # Too many nested blocks
105-
"PLR2004", # Magic value used in comparison
106-
"PLR6301", # Method could be a function
107-
"PTH", # Use pathlib (existing code uses os.path)
108-
"PYI024", # Use `typing.NamedTuple` instead of `collections.namedtuple`
109-
"RUF067", # __init__ module should only contain re-exports
110-
"S104", # Possible binding to all interface
111-
"S404", # subprocess module is possibly insecure
112-
"S603", # `subprocess` call: check for execution of untrusted input
113-
"SLF001", # Private member accessed
114-
"TID252", # Prefer absolute imports over relative (internal package uses relative)
115-
"TRY301", # Abstract `raise` to an inner function
77+
"COM812", # Conflict with formatter
78+
"CPY", # No copyright statements
79+
]
80+
lint.per-file-ignores."src/py_discovery/_discovery.py" = [
81+
"PTH", # shim resolution uses string-based os.path for consistency with env variables
82+
]
83+
lint.per-file-ignores."src/py_discovery/_py_info.py" = [
84+
"PTH", # must use os.path — file runs as subprocess script with only stdlib
85+
]
86+
lint.per-file-ignores."src/py_discovery/_windows/_pep514.py" = [
87+
"PTH", # os.path.exists is monkeypatched in tests; pathlib.Path.exists bypasses the mock
11688
]
11789
lint.per-file-ignores."tests/**/*.py" = [
11890
"INP001", # no implicit namespace
11991
"PLC2701", # Private imports
92+
"PTH", # os.path used intentionally for string-based path operations
12093
"S101", # asserts allowed in tests
121-
"S102", # use of exec
12294
]
12395
lint.per-file-ignores."tests/windows/winreg_mock_values.py" = [
12496
"F821", # undefined name (winreg available only on Windows)
@@ -173,5 +145,15 @@ paths.source = [
173145
src.exclude = [ "tests/windows/winreg_mock_values.py" ]
174146

175147
[[tool.ty.overrides]]
176-
include = [ "src/py_discovery/_py_info.py", "tests/test_py_info_extra.py" ]
148+
include = [ "src/py_discovery/_py_info.py", "src/py_discovery/_py_spec.py" ]
149+
rules.unused-ignore-comment = "ignore"
150+
rules.invalid-argument-type = "ignore"
151+
rules.invalid-return-type = "ignore"
152+
rules.no-matching-overload = "ignore"
153+
154+
[[tool.ty.overrides]]
155+
include = [ "tests/**/*.py" ]
177156
rules.unused-ignore-comment = "ignore"
157+
rules.invalid-argument-type = "ignore"
158+
rules.no-matching-overload = "ignore"
159+
rules.unresolved-attribute = "ignore"

src/py_discovery/_cache.py

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,13 @@
66
import logging
77
from contextlib import contextmanager, suppress
88
from hashlib import sha256
9-
from typing import TYPE_CHECKING, Protocol, runtime_checkable
9+
from typing import TYPE_CHECKING, Final, Protocol, runtime_checkable
1010

1111
if TYPE_CHECKING:
1212
from collections.abc import Generator
1313
from pathlib import Path
1414

15-
LOGGER = logging.getLogger(__name__)
15+
_LOGGER: Final[logging.Logger] = logging.getLogger(__name__)
1616

1717

1818
@runtime_checkable
@@ -61,9 +61,9 @@ def read(self) -> dict | None:
6161
except ValueError:
6262
bad_format = True
6363
except OSError:
64-
LOGGER.debug("failed to read %s", self._file, exc_info=True)
64+
_LOGGER.debug("failed to read %s", self._file, exc_info=True)
6565
else:
66-
LOGGER.debug("got python info from %s", self._file)
66+
_LOGGER.debug("got python info from %s", self._file)
6767
return data
6868
if bad_format:
6969
with suppress(OSError):
@@ -73,12 +73,12 @@ def read(self) -> dict | None:
7373
def write(self, content: dict) -> None:
7474
self._folder.mkdir(parents=True, exist_ok=True)
7575
self._file.write_text(json.dumps(content, sort_keys=True, indent=2), encoding="utf-8")
76-
LOGGER.debug("wrote python info at %s", self._file)
76+
_LOGGER.debug("wrote python info at %s", self._file)
7777

7878
def remove(self) -> None:
7979
with suppress(OSError):
8080
self._file.unlink()
81-
LOGGER.debug("removed python info at %s", self._file)
81+
_LOGGER.debug("removed python info at %s", self._file)
8282

8383
@contextmanager
8484
def locked(self) -> Generator[None]:
@@ -111,10 +111,10 @@ def py_info(self, path: Path) -> DiskContentStore:
111111
def py_info_clear(self) -> None:
112112
folder = self._py_info_dir
113113
if folder.exists():
114-
for f in folder.iterdir():
115-
if f.suffix == ".json":
114+
for entry in folder.iterdir():
115+
if entry.suffix == ".json":
116116
with suppress(OSError):
117-
f.unlink()
117+
entry.unlink()
118118

119119

120120
class NoOpContentStore:

src/py_discovery/_cached_py_info.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ def _get_via_file_cache(
8080
path_modified = path.stat().st_mtime
8181
except OSError:
8282
path_modified = -1
83-
py_info_script = Path(os.path.abspath(__file__)).parent / "_py_info.py"
83+
py_info_script = Path(Path(__file__).resolve()).parent / "_py_info.py"
8484
try:
8585
py_info_hash: str | None = hashlib.sha256(py_info_script.read_bytes()).hexdigest()
8686
except OSError:
@@ -132,7 +132,7 @@ def _load_cached_py_info(
132132
except (KeyError, TypeError):
133133
py_info_store.remove()
134134
return None
135-
if (sys_exe := py_info.system_executable) is not None and not os.path.exists(sys_exe):
135+
if (sys_exe := py_info.system_executable) is not None and not Path(sys_exe).exists():
136136
py_info_store.remove()
137137
return None
138138
return py_info
@@ -147,7 +147,7 @@ def gen_cookie() -> str:
147147

148148
@contextmanager
149149
def _resolve_py_info_script() -> Generator[Path]:
150-
py_info_script = Path(os.path.abspath(__file__)).parent / "_py_info.py"
150+
py_info_script = Path(Path(__file__).resolve()).parent / "_py_info.py"
151151
if py_info_script.is_file():
152152
yield py_info_script
153153
else:
@@ -161,7 +161,7 @@ def _resolve_py_info_script() -> Generator[Path]:
161161
os.close(fd)
162162
yield Path(tmp)
163163
finally:
164-
os.unlink(tmp)
164+
Path(tmp).unlink()
165165

166166

167167
def _run_subprocess(

src/py_discovery/_compat.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
import functools
66
import logging
7-
import os
7+
import pathlib
88
import tempfile
99
from typing import Final
1010

@@ -14,7 +14,7 @@
1414
@functools.lru_cache(maxsize=1)
1515
def fs_is_case_sensitive() -> bool:
1616
with tempfile.NamedTemporaryFile(prefix="TmP") as tmp_file:
17-
result = not os.path.exists(tmp_file.name.lower())
17+
result = not pathlib.Path(tmp_file.name.lower()).exists()
1818
_LOGGER.debug("filesystem is %scase-sensitive", "" if result else "not ")
1919
return result
2020

src/py_discovery/_discovery.py

Lines changed: 16 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ def _find_interpreter(
4343
) -> PythonInfo | None:
4444
spec = PythonSpec.from_string_spec(key)
4545
_LOGGER.info("find interpreter for spec %r", spec)
46-
proposed_paths: set[tuple[str, bool]] = set()
46+
proposed_paths: set[tuple[str | None, bool]] = set()
4747
env = os.environ if env is None else env
4848
for interpreter, impl_must_match in propose_interpreters(spec, try_first_with, cache, env):
4949
if interpreter is None: # pragma: no cover
@@ -65,12 +65,12 @@ def _check_exe(path: str, tested_exes: set[str]) -> str | None:
6565
os.lstat(path)
6666
except OSError:
6767
return None
68-
exe_raw = os.path.abspath(path)
69-
exe_id = fs_path_id(exe_raw)
68+
resolved = str(Path(path).resolve())
69+
exe_id = fs_path_id(resolved)
7070
if exe_id in tested_exes:
7171
return None
7272
tested_exes.add(exe_id)
73-
return exe_raw
73+
return str(Path(path).absolute())
7474

7575

7676
def _is_new_exe(exe_raw: str, tested_exes: set[str]) -> bool:
@@ -96,7 +96,7 @@ def propose_interpreters(
9696
return
9797

9898
for py_exe in try_first_with:
99-
if exe_raw := _check_exe(os.path.abspath(py_exe), tested_exes):
99+
if exe_raw := _check_exe(str(Path(py_exe).resolve()), tested_exes):
100100
yield PythonInfo.from_exe(exe_raw, cache, env=env), True
101101

102102
if spec.path is not None:
@@ -151,10 +151,10 @@ def get_paths(env: Mapping[str, str]) -> Generator[Path, None, None]:
151151
except (AttributeError, ValueError): # pragma: no cover # Windows only (no confstr)
152152
path = os.defpath
153153
if path:
154-
for p in map(Path, path.split(os.pathsep)):
154+
for entry in map(Path, path.split(os.pathsep)):
155155
with suppress(OSError):
156-
if p.is_dir() and next(p.iterdir(), None):
157-
yield p
156+
if entry.is_dir() and next(entry.iterdir(), None):
157+
yield entry
158158

159159

160160
class LazyPathDump:
@@ -226,7 +226,7 @@ def _resolve_shim(exe_path: str, env: Mapping[str, str]) -> str | None:
226226
def _resolve_shim_to_binary(exe_name: str, versions_dir: str, env: Mapping[str, str]) -> str | None:
227227
for version in _active_versions(env):
228228
resolved = os.path.join(versions_dir, version, "bin", exe_name)
229-
if os.path.isfile(resolved) and os.access(resolved, os.X_OK):
229+
if Path(resolved).is_file() and os.access(resolved, os.X_OK):
230230
return resolved
231231
return None
232232

@@ -236,7 +236,7 @@ def _active_versions(env: Mapping[str, str]) -> Generator[str, None, None]:
236236
if pyenv_version := env.get("PYENV_VERSION"):
237237
yield from pyenv_version.split(":")
238238
return
239-
if versions := _read_python_version_file(os.getcwd()):
239+
if versions := _read_python_version_file(Path.cwd()):
240240
yield from versions
241241
return
242242
if (pyenv_root := env.get("PYENV_ROOT")) and (
@@ -245,18 +245,18 @@ def _active_versions(env: Mapping[str, str]) -> Generator[str, None, None]:
245245
yield from versions
246246

247247

248-
def _read_python_version_file(start: str, *, search_parents: bool = True) -> list[str] | None:
248+
def _read_python_version_file(start: str | Path, *, search_parents: bool = True) -> list[str] | None:
249249
"""Read a ``.python-version`` file, optionally searching parent directories."""
250250
current = start
251251
while True:
252-
candidate = os.path.join(current, ".python-version") if os.path.isdir(current) else current
253-
if os.path.isfile(candidate):
254-
with open(candidate, encoding="utf-8") as f:
255-
if versions := [v for line in f if (v := line.strip()) and not v.startswith("#")]:
252+
candidate = os.path.join(current, ".python-version") if Path(current).is_dir() else current
253+
if Path(candidate).is_file():
254+
with Path(candidate).open(encoding="utf-8") as fh:
255+
if versions := [v for line in fh if (v := line.strip()) and not v.startswith("#")]:
256256
return versions
257257
if not search_parents:
258258
return None
259-
parent = os.path.dirname(current)
259+
parent = Path(current).parent
260260
if parent == current:
261261
return None
262262
current = parent

0 commit comments

Comments
 (0)