Skip to content

Commit b59b295

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. Signed-off-by: Bernát Gábor <bgabor8@bloomberg.net>
1 parent 3b381df commit b59b295

26 files changed

+1116
-1083
lines changed

.github/workflows/check.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ jobs:
2424
- "3.12"
2525
- "3.11"
2626
- "3.10"
27+
- "3.9"
28+
- "3.8"
2729
- type-3.8
2830
- type-3.14
2931
- dev

docs/conf.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
"""Sphinx configuration for py-discovery documentation."""
2+
13
from __future__ import annotations
24

35
from datetime import datetime, timezone

pyproject.toml

Lines changed: 50 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ license.file = "LICENSE"
1818
maintainers = [
1919
{ name = "Bernát Gábor", email = "gaborjbernat@gmail.com" },
2020
]
21-
requires-python = ">=3.10"
21+
requires-python = ">=3.8"
2222
classifiers = [
2323
"Development Status :: 5 - Production/Stable",
2424
"Intended Audience :: Developers",
@@ -27,6 +27,8 @@ classifiers = [
2727
"Operating System :: Microsoft :: Windows",
2828
"Operating System :: POSIX",
2929
"Programming Language :: Python :: 3 :: Only",
30+
"Programming Language :: Python :: 3.8",
31+
"Programming Language :: Python :: 3.9",
3032
"Programming Language :: Python :: 3.10",
3133
"Programming Language :: Python :: 3.11",
3234
"Programming Language :: Python :: 3.12",
@@ -40,8 +42,8 @@ dynamic = [
4042
"version",
4143
]
4244
dependencies = [
43-
"filelock>=3.24.2",
44-
"platformdirs<5,>=4.9.2",
45+
"filelock>=3.15.4",
46+
"platformdirs<5,>=4.3.6",
4547
]
4648
optional-dependencies.docs = [
4749
"furo>=2025.12.19",
@@ -51,10 +53,10 @@ optional-dependencies.docs = [
5153
]
5254
optional-dependencies.testing = [
5355
"covdefaults>=2.3",
54-
"coverage>=7.13.4",
55-
"pytest>=9.0.2",
56-
"pytest-mock>=3.15.1",
57-
"setuptools>=82",
56+
"coverage>=7.5.4",
57+
"pytest>=8.3.5",
58+
"pytest-mock>=3.14",
59+
"setuptools>=75.1",
5860
]
5961
urls.Changelog = "https://github.com/tox-dev/py-discovery/releases"
6062
urls.Documentation = "https://py-discovery.readthedocs.io"
@@ -74,51 +76,40 @@ lint.select = [
7476
"ALL",
7577
]
7678
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
79+
"COM812", # Conflict with formatter
80+
"CPY", # No copyright statements
81+
"D203", # `one-blank-line-before-class` and `no-blank-line-before-class` are incompatible
82+
"D212", # `multi-line-summary-first-line` and `multi-line-summary-second-line` are incompatible
83+
"DOC201", # `return` is not documented in docstring
84+
"DOC402", # `yield` is not documented in docstring
85+
"DOC501", # `raises` is not documented in docstring
86+
"ISC001", # Conflict with formatter
87+
"S104", # Possible binding to all interface
88+
]
89+
lint.per-file-ignores."docs/**/*.py" = [
90+
"INP001", # no __init__.py in docs directory
91+
]
92+
lint.per-file-ignores."src/py_discovery/_discovery.py" = [
93+
"PTH", # shim resolution uses string-based os.path for consistency with env variables
94+
]
95+
lint.per-file-ignores."src/py_discovery/_py_info.py" = [
96+
"PTH", # must use os.path — file runs as subprocess script with only stdlib
97+
]
98+
lint.per-file-ignores."src/py_discovery/_windows/_pep514.py" = [
99+
"PTH", # os.path.exists is monkeypatched in tests; pathlib.Path.exists bypasses the mock
116100
]
117101
lint.per-file-ignores."tests/**/*.py" = [
102+
"D", # don't care about documentation in tests
103+
"FBT", # don't care about booleans as positional arguments in tests
118104
"INP001", # no implicit namespace
119-
"PLC2701", # Private imports
105+
"PLC0415", # imports inside test functions (conditional on mocking)
106+
"PLC2701", # private imports needed to test internal APIs
107+
"PLR0913", # too many arguments (pytest fixtures)
108+
"PLR2004", # Magic value used in comparison
120109
"S101", # asserts allowed in tests
121-
"S102", # use of exec
110+
"S404", # subprocess import
111+
"S603", # `subprocess` call: check for execution of untrusted input
112+
"SLF001", # private member access needed to test internals
122113
]
123114
lint.per-file-ignores."tests/windows/winreg_mock_values.py" = [
124115
"F821", # undefined name (winreg available only on Windows)
@@ -173,5 +164,15 @@ paths.source = [
173164
src.exclude = [ "tests/windows/winreg_mock_values.py" ]
174165

175166
[[tool.ty.overrides]]
176-
include = [ "src/py_discovery/_py_info.py", "tests/test_py_info_extra.py" ]
167+
include = [ "src/py_discovery/_py_info.py", "src/py_discovery/_py_spec.py" ]
168+
rules.unused-ignore-comment = "ignore"
169+
rules.invalid-argument-type = "ignore"
170+
rules.invalid-return-type = "ignore"
171+
rules.no-matching-overload = "ignore"
172+
173+
[[tool.ty.overrides]]
174+
include = [ "tests/**/*.py" ]
177175
rules.unused-ignore-comment = "ignore"
176+
rules.invalid-argument-type = "ignore"
177+
rules.no-matching-overload = "ignore"
178+
rules.unresolved-attribute = "ignore"

src/py_discovery/_cache.py

Lines changed: 19 additions & 23 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,16 +73,16 @@ 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]:
85-
from filelock import FileLock
85+
from filelock import FileLock # noqa: PLC0415
8686

8787
lock_path = self._folder / f"{self._key}.lock"
8888
lock_path.parent.mkdir(parents=True, exist_ok=True)
@@ -91,11 +91,7 @@ def locked(self) -> Generator[None]:
9191

9292

9393
class DiskCache:
94-
"""File-system based Python interpreter info cache.
95-
96-
Layout: ``<root>/py_info/4/<sha256(str(path))>.json`` -- identical to virtualenv's ``AppDataDiskFolder``.
97-
98-
"""
94+
"""File-system based Python interpreter info cache (``<root>/py_info/4/<sha256>.json``)."""
9995

10096
def __init__(self, root: Path) -> None:
10197
self._root = root
@@ -111,19 +107,19 @@ def py_info(self, path: Path) -> DiskContentStore:
111107
def py_info_clear(self) -> None:
112108
folder = self._py_info_dir
113109
if folder.exists():
114-
for f in folder.iterdir():
115-
if f.suffix == ".json":
110+
for entry in folder.iterdir():
111+
if entry.suffix == ".json":
116112
with suppress(OSError):
117-
f.unlink()
113+
entry.unlink()
118114

119115

120-
class NoOpContentStore:
121-
"""Content store that does nothing."""
116+
class NoOpContentStore(ContentStore):
117+
"""Content store that does nothing -- implements ContentStore protocol."""
122118

123-
def exists(self) -> bool:
119+
def exists(self) -> bool: # noqa: PLR6301
124120
return False
125121

126-
def read(self) -> dict | None:
122+
def read(self) -> dict | None: # noqa: PLR6301
127123
return None
128124

129125
def write(self, content: dict) -> None:
@@ -133,14 +129,14 @@ def remove(self) -> None:
133129
pass
134130

135131
@contextmanager
136-
def locked(self) -> Generator[None]:
132+
def locked(self) -> Generator[None]: # noqa: PLR6301
137133
yield
138134

139135

140-
class NoOpCache:
141-
"""Cache that does nothing -- used when caching is disabled."""
136+
class NoOpCache(PyInfoCache):
137+
"""Cache that does nothing -- implements PyInfoCache protocol."""
142138

143-
def py_info(self, _path: Path) -> NoOpContentStore:
139+
def py_info(self, path: Path) -> NoOpContentStore: # noqa: ARG002, PLR6301
144140
return NoOpContentStore()
145141

146142
def py_info_clear(self) -> None:

0 commit comments

Comments
 (0)