From 70c22cab9bbb00a814af89997d5f9ce8cb4fc4de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Fri, 6 Nov 2026 23:52:35 -0800 Subject: [PATCH 01/25] =?UTF-8?q?=F0=9F=94=A7=20build(lint):=20adopt=20sim?= =?UTF-8?q?plint=200.8.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ruff's undocumented-* rules are replaced by simplint's undocumented-public-api, which checks only what the package exports, in reST. The docstring style is reST because the code documents parameters with :param: fields. --- .pre-commit-config.yaml | 25 ++++++++++++++++++++++++- pyproject.toml | 9 +++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index f838f33..de2aae0 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,3 +1,5 @@ +# prek runs lower priorities first and equal ones concurrently, so hooks sharing a priority never write the same +# file; pre-commit ignores the key and runs hooks in file order. ci: autoupdate_schedule: quarterly default_language_version: @@ -7,34 +9,51 @@ repos: rev: 3e8a8703264a2f4a69428a0aa4dcb512790b2c8c # frozen: v6.0.0 hooks: - id: end-of-file-fixer + priority: 1 - id: trailing-whitespace + priority: 2 - repo: https://github.com/python-jsonschema/check-jsonschema rev: a76b92851fc9eede9f9bc4073eb55202f34eb0df # frozen: 0.39.0 hooks: - id: check-github-workflows + priority: 8 args: ["--verbose"] - repo: https://github.com/codespell-project/codespell rev: 57b21406f092110c18776e39b0bda50d37c945c8 # frozen: v2.4.3 hooks: - id: codespell + priority: 8 - repo: https://github.com/tox-dev/pyproject-fmt rev: "50e0f3aee9dc5747a5f173f79a7417403c18dda6" # frozen: v2.30.3 hooks: - id: pyproject-fmt + priority: 3 - repo: https://github.com/astral-sh/ruff-pre-commit rev: "25bcf95026b96e2dd3c0aec3090c3d6ef6ca2e6c" # frozen: v0.17.3 hooks: - - id: ruff-format - id: ruff + priority: 5 args: ["--fix", "--unsafe-fixes", "--exit-non-zero-on-fix"] + - repo: https://github.com/gaborbernat/simplint-pre-commit + rev: "f15525433ac9797aa1580f46fdadfe60dd77923c" # frozen: v0.8.0 + hooks: + - id: simplint + priority: 6 + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: "25bcf95026b96e2dd3c0aec3090c3d6ef6ca2e6c" # frozen: v0.17.3 + hooks: + - id: ruff-format + priority: 7 - repo: https://github.com/google/yamlfmt rev: "21ca5323a9c87ee37a434e0ca908efc0a89daa07" # frozen: v0.21.0 hooks: - id: yamlfmt + priority: 3 - repo: https://github.com/executablebooks/mdformat rev: 82912cdaea4fb830f751504486a7879c70526547 # frozen: 1.0.0 hooks: - id: mdformat + priority: 3 args: ["--wrap", "120"] additional_dependencies: - mdformat-gfm==1.0.0 @@ -52,6 +71,7 @@ repos: rev: c1813841673cdcd6cf602dde1edb78a5d5e45235 # frozen: v2.2.1 hooks: - id: docstrfmt + priority: 3 files: | (?x) ^docs/changelog[.]rst$ # Only the generated release history. @@ -62,7 +82,10 @@ repos: rev: c92afdf2c6ca336aaa8f39e2bd8d48f62dbd8e1f # frozen: v1.31.1 hooks: - id: zizmor + priority: 8 - repo: meta hooks: - id: check-hooks-apply + priority: 8 - id: check-useless-excludes + priority: 8 diff --git a/pyproject.toml b/pyproject.toml index 3244be3..48af5e2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -130,6 +130,12 @@ lint.ignore = [ "missing-trailing-comma", # Conflict with formatter "multi-line-summary-first-line", # `multi-line-summary-first-line` and `multi-line-summary-second-line` are incompatible "single-line-implicit-string-concatenation", # Conflict with formatter + "undocumented-magic-method", # undocumented-public-api (simplint SMP903) covers the public API instead + "undocumented-public-class", # undocumented-public-api (simplint SMP903) covers the public API instead + "undocumented-public-function", # undocumented-public-api (simplint SMP903) covers the public API instead + "undocumented-public-init", # undocumented-public-api (simplint SMP903) covers the public API instead + "undocumented-public-method", # undocumented-public-api (simplint SMP903) covers the public API instead + "undocumented-public-nested-class", # undocumented-public-api (simplint SMP903) covers the public API instead ] lint.per-file-ignores."docs/**/*.py" = [ "implicit-namespace-package", # no __init__.py in docs directory @@ -274,3 +280,6 @@ matrix.platforms = [ "linux_x86_64" ] # release-creator 4.4.0 needs a seven-day cutoff exception. # expires 2026-11-09 packages.release-creator.uploaded-prior-to = 2026-11-02T21:45:00Z + +[tool.simplint] +docstring-style = "rst" From efce059fc93575d598cd3dee61740c2e21a9fd50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Fri, 6 Nov 2026 23:53:00 -0800 Subject: [PATCH 02/25] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor:=20apply=20?= =?UTF-8?q?simplint's=20autofixes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Applied with `simplint check --fix --unsafe-fixes`, then the prek formatters. --- docs/conf.py | 27 +- src/python_discovery/_cache.py | 164 ++--- src/python_discovery/_cached_py_info.py | 243 ++++--- src/python_discovery/_compat.py | 8 +- src/python_discovery/_discovery.py | 550 ++++++++-------- src/python_discovery/_py_info.py | 678 +++++++++++--------- src/python_discovery/_py_info_collect.py | 274 ++++---- src/python_discovery/_py_spec.py | 107 ++- src/python_discovery/_specifier.py | 70 +- src/python_discovery/_windows/_cancel_io.py | 4 +- src/python_discovery/_windows/_pep514.py | 75 +-- src/python_discovery/_windows/_propose.py | 20 +- tasks/old_target_check.py | 3 +- tests/py_info/test_py_info.py | 143 ++--- tests/py_info/test_py_info_exe_based_of.py | 35 +- tests/test_cache.py | 18 +- tests/test_cached_py_info.py | 60 +- tests/test_discovery.py | 84 +-- tests/test_discovery_extra.py | 52 +- tests/test_iter_interpreters.py | 15 +- tests/test_py_info_collect.py | 39 +- tests/test_py_info_extra.py | 153 +++-- tests/test_py_spec.py | 21 +- tests/test_py_spec_extra.py | 15 +- tests/test_specifier.py | 18 +- tests/windows/conftest.py | 122 ++-- tests/windows/test_windows.py | 3 +- 27 files changed, 1486 insertions(+), 1515 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index 8cb145b..b2de111 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -5,15 +5,16 @@ import os from datetime import datetime, timezone from pathlib import Path +from typing import Final from python_discovery import __version__ -company = "tox-dev" +company: Final[str] = "tox-dev" name = "python-discovery" -repository = f"https://github.com/{company}/{name}" +repository: Final[str] = f"https://github.com/{company}/{name}" version = ".".join(__version__.split(".")[:2]) release = __version__ -copyright = f"2026-{datetime.now(tz=timezone.utc).year}, {company}" # ruff:ignore[builtin-variable-shadowing] +copyright: Final[str] = f"2026-{datetime.now(tz=timezone.utc).year}, {company}" # ruff:ignore[builtin-variable-shadowing] extensions = [ "sphinx_llm.txt", @@ -26,10 +27,10 @@ "sphinxcontrib.towncrier.ext", ] -towncrier_draft_autoversion_mode = "draft" -towncrier_draft_include_empty = True +towncrier_draft_autoversion_mode: Final[str] = "draft" +towncrier_draft_include_empty: Final[bool] = True towncrier_draft_working_directory = Path(__file__).parent.parent -autosectionlabel_maxdepth = 2 +autosectionlabel_maxdepth: Final[int] = 2 extlinks = { "issue": (f"{repository}/issues/%s", "#%s"), @@ -43,19 +44,19 @@ } templates_path = [] -source_suffix = ".rst" +source_suffix: Final[str] = ".rst" exclude_patterns = ["_build", "changelog/*"] -main_doc = "index" -pygments_style = "default" -always_document_param_types = True +main_doc: Final[str] = "index" +pygments_style: Final[str] = "default" +always_document_param_types: Final[bool] = True project = name -html_theme = "furo" +html_theme: Final[str] = "furo" html_title = project html_last_updated_fmt = datetime.now(tz=timezone.utc).isoformat() -pygments_dark_style = "monokai" -html_show_sourcelink = False +pygments_dark_style: Final[str] = "monokai" +html_show_sourcelink: Final[bool] = False html_static_path = ["_static"] html_theme_options = { "light_logo": "logo.svg", diff --git a/src/python_discovery/_cache.py b/src/python_discovery/_cache.py index 541af3a..630dcdd 100644 --- a/src/python_discovery/_cache.py +++ b/src/python_discovery/_cache.py @@ -24,11 +24,19 @@ class ContentStore(Protocol): """A store for reading and writing cached content.""" def exists(self) -> bool: - """Return whether the cached content exists.""" + """ + Return whether the cached content exists. + + :returns: + """ ... def read(self) -> dict | None: - """Read the cached content, or ``None`` if unavailable or corrupt.""" + """ + Read the cached content, or ``None`` if unavailable or corrupt. + + :returns: + """ ... def write(self, content: dict) -> None: @@ -45,7 +53,11 @@ def remove(self) -> None: @contextmanager def locked(self) -> Generator[None]: - """Context manager that acquires an exclusive lock on this store.""" + """ + Context manager that acquires an exclusive lock on this store. + + :returns: + """ ... @@ -58,6 +70,7 @@ def py_info(self, path: Path) -> ContentStore: Return the content store for the interpreter at *path*. :param path: absolute path to a Python executable. + :returns: """ ... @@ -66,20 +79,79 @@ def py_info_clear(self) -> None: ... -class DiskContentStore: - """JSON file-based content store with file locking.""" +class DiskCache: + """ + File-system based Python interpreter info cache (``/py_info/4/.json``). + + :param root: root directory for the on-disk cache. + """ + + def __init__(self, root: Path) -> None: + self._root = Path(os.path.abspath(root)) # ruff:ignore[os-path-abspath] # resolve() follows untrusted links + + def py_info(self, path: Path) -> ContentStore: + """ + Return the content store for the interpreter at *path*. + + :param path: absolute path to a Python executable. + :returns: + """ + if not self._trusted_folder(): + return NoOpContentStore() + return DiskContentStore(self._py_info_dir, sha256(fsencode(path)).hexdigest()) + + def _trusted_folder(self) -> bool: + if not hasattr(os, "geteuid"): + return True + user_id = os.geteuid() + folder = self._py_info_dir + private = {self._root, folder.parent, folder} + for candidate in (*reversed(folder.parents), folder): + try: + try: + status = candidate.lstat() + except FileNotFoundError: + candidate.mkdir(exist_ok=True) + status = candidate.lstat() + except OSError: + _LOGGER.warning("not caching in %s", candidate, exc_info=True) + return False + if ( + not stat.S_ISDIR(status.st_mode) + or status.st_uid not in {user_id, 0} + or (status.st_mode & 0o022 and (candidate in private or not status.st_mode & stat.S_ISVTX)) + ): + _LOGGER.warning("not caching in untrusted directory %s", candidate) + return False + return True + + @property + def _py_info_dir(self) -> Path: + return self._root / "py_info" / "4" + + def py_info_clear(self) -> None: + """Remove all cached interpreter information.""" + folder = self._py_info_dir + if folder.exists() and self._trusted_folder(): + for entry in folder.iterdir(): + if entry.suffix == ".json": + with suppress(OSError): + entry.unlink() + +class DiskContentStore: + # JSON file-based content store with file locking. def __init__(self, folder: Path, key: str) -> None: self._folder = folder self._key = key + def exists(self) -> bool: + return self._file.exists() + @property def _file(self) -> Path: return self._folder / f"{self._key}.json" - def exists(self) -> bool: - return self._file.exists() - def read(self) -> dict | None: data, bad_format = None, False try: @@ -116,6 +188,11 @@ def _read_entry(self) -> tuple[dict | None, bool]: def _open_entry(path: str, flags: int) -> int: return os.open(path, flags | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_NONBLOCK", 0)) + def remove(self) -> None: + with suppress(OSError): + self._file.unlink() + _LOGGER.debug("removed python info at %s", self._file) + def write(self, content: dict) -> None: self._folder.mkdir(parents=True, exist_ok=True) temporary: Path | None = None @@ -136,11 +213,6 @@ def write(self, content: dict) -> None: with suppress(OSError): temporary.unlink(missing_ok=True) - def remove(self) -> None: - with suppress(OSError): - self._file.unlink() - _LOGGER.debug("removed python info at %s", self._file) - @contextmanager def locked(self) -> Generator[None]: from filelock import FileLock # ruff:ignore[import-outside-top-level] @@ -157,69 +229,8 @@ def locked(self) -> Generator[None]: yield -class DiskCache: - """ - File-system based Python interpreter info cache (``/py_info/4/.json``). - - :param root: root directory for the on-disk cache. - """ - - def __init__(self, root: Path) -> None: - self._root = Path(os.path.abspath(root)) # ruff:ignore[os-path-abspath] # resolve() follows untrusted links - - @property - def _py_info_dir(self) -> Path: - return self._root / "py_info" / "4" - - def py_info(self, path: Path) -> ContentStore: - """ - Return the content store for the interpreter at *path*. - - :param path: absolute path to a Python executable. - """ - if not self._trusted_folder(): - return NoOpContentStore() - key = sha256(fsencode(path)).hexdigest() - return DiskContentStore(self._py_info_dir, key) - - def _trusted_folder(self) -> bool: - if not hasattr(os, "geteuid"): - return True - user_id = os.geteuid() - folder = self._py_info_dir - private = {self._root, folder.parent, folder} - for candidate in (*reversed(folder.parents), folder): - try: - try: - status = candidate.lstat() - except FileNotFoundError: - candidate.mkdir(exist_ok=True) - status = candidate.lstat() - except OSError: - _LOGGER.warning("not caching in %s", candidate, exc_info=True) - return False - if ( - not stat.S_ISDIR(status.st_mode) - or status.st_uid not in {user_id, 0} - or (status.st_mode & 0o022 and (candidate in private or not status.st_mode & stat.S_ISVTX)) - ): - _LOGGER.warning("not caching in untrusted directory %s", candidate) - return False - return True - - def py_info_clear(self) -> None: - """Remove all cached interpreter information.""" - folder = self._py_info_dir - if folder.exists() and self._trusted_folder(): - for entry in folder.iterdir(): - if entry.suffix == ".json": - with suppress(OSError): - entry.unlink() - - class NoOpContentStore(ContentStore): - """Content store that does nothing -- implements ContentStore protocol.""" - + # Content store that does nothing -- implements ContentStore protocol. def exists(self) -> bool: # ruff:ignore[no-self-use] return False @@ -238,8 +249,7 @@ def locked(self) -> Generator[None]: # ruff:ignore[no-self-use] class NoOpCache(PyInfoCache): - """Cache that does nothing -- implements PyInfoCache protocol.""" - + # Cache that does nothing -- implements PyInfoCache protocol. def py_info(self, path: Path) -> NoOpContentStore: # ruff:ignore[unused-method-argument, no-self-use] return NoOpContentStore() diff --git a/src/python_discovery/_cached_py_info.py b/src/python_discovery/_cached_py_info.py index bb640e9..30fbd4b 100644 --- a/src/python_discovery/_cached_py_info.py +++ b/src/python_discovery/_cached_py_info.py @@ -37,32 +37,22 @@ from ._cache import ContentStore, PyInfoCache class CacheEntryMeta(TypedDict): - """Identity of a cached interrogation result: the queried binary, its arguments and the script.""" - + # Identity of a cached interrogation result: the queried binary, its arguments and the script. st_mtime: float path: str hash: str | None arguments: str | None -_CACHE: OrderedDict[tuple[Path, str | None], PythonInfo | Exception] = OrderedDict() +_CACHE: Final[OrderedDict[tuple[Path, str | None], PythonInfo | Exception]] = OrderedDict() _CACHE[Path(sys.executable), None] = PythonInfo() _LOGGER: Final[logging.Logger] = logging.getLogger(__name__) _PY_INFO_SCRIPT: Final[Path] = Path(__file__).resolve().parent / "_py_info_collect.py" -_DEFAULT_QUERY_TIMEOUT: Final[float] = 15.0 # poll(2) limits milliseconds to a signed C int. _MAX_QUERY_TIMEOUT: Final[float] = (2**31 - 1) / 1000 _MAX_QUERY_OUTPUT: Final[int] = 1 << 20 -class _UnsupportedInterpreterError(RuntimeError): - """The interpreter is older than the interrogation script supports; retrying cannot help.""" - - def __init__(self, message: str, executable: str | None = None) -> None: - super().__init__(message) - self.executable = executable - - def from_exe( # ruff:ignore[too-many-arguments] cls: type[PythonInfo], cache: PyInfoCache | None, @@ -96,8 +86,8 @@ def _get_from_cache( # ruff:ignore[too-many-arguments] exe: str, env: Mapping[str, str], *, - ignore_cache: bool = True, - executable_args: str | None = None, + ignore_cache: bool, + executable_args: str | None, ) -> PythonInfo | Exception: key = Path(exe), executable_args if not ignore_cache and key in _CACHE: @@ -114,21 +104,6 @@ def _get_from_cache( # ruff:ignore[too-many-arguments] return result -def _cacheable_result(path: Path, result: PythonInfo | Exception) -> bool: - if isinstance(result, PythonInfo): - executable = result.original_executable - elif isinstance(result, _UnsupportedInterpreterError): - executable = result.executable - else: - return False - if executable is None: - return False - try: - return path.samefile(executable) - except (OSError, TypeError, ValueError): - return False - - def _get_via_file_cache( # ruff:ignore[too-many-arguments] cls: type[PythonInfo], cache: PyInfoCache | None, @@ -148,8 +123,7 @@ def _get_via_file_cache( # ruff:ignore[too-many-arguments] except OSError: py_info_hash = None - resolved_cache = cache if cache is not None else NoOpCache() - py_info_store = resolved_cache.py_info(path) + py_info_store = (cache if cache is not None else NoOpCache()).py_info(path) entry_meta: CacheEntryMeta = { "st_mtime": path_modified, "path": path_text, @@ -157,8 +131,7 @@ def _get_via_file_cache( # ruff:ignore[too-many-arguments] "arguments": executable_args, } with py_info_store.locked(): - cached = _read_cache_entry(cls, py_info_store, entry_meta) - if cached is not None: + if (cached := _read_cache_entry(cls, py_info_store, entry_meta)) is not None: return cached failure, py_info = _run_subprocess(cls, exe, env, executable_args) if failure is not None and not isinstance(failure, _UnsupportedInterpreterError): @@ -172,13 +145,17 @@ def _get_via_file_cache( # ruff:ignore[too-many-arguments] if failure is not None: return failure if py_info is None: - msg = f"{exe} failed to produce interpreter info" - return RuntimeError(msg) + return RuntimeError(f"{exe} failed to produce interpreter info") if _cacheable_result(path, py_info): py_info_store.write({**entry_meta, "content": py_info.to_dict()}) return py_info +@lru_cache(maxsize=1) +def _script_hash() -> str: + return hashlib.sha256(_PY_INFO_SCRIPT.read_bytes()).hexdigest() + + def _read_cache_entry( cls: type[PythonInfo], py_info_store: ContentStore, @@ -211,50 +188,6 @@ def _load_cached_py_info( return py_info -@lru_cache(maxsize=1) -def _script_hash() -> str: - return hashlib.sha256(_PY_INFO_SCRIPT.read_bytes()).hexdigest() - - -COOKIE_LENGTH: Final[int] = 32 - - -def gen_cookie() -> str: - return secrets.token_hex(COOKIE_LENGTH // 2) - - -@contextmanager -def _resolve_py_info_script() -> Generator[Path]: - if _PY_INFO_SCRIPT.is_file(): - yield _PY_INFO_SCRIPT - else: - data = pkgutil.get_data(__package__ or __name__, _PY_INFO_SCRIPT.name) - if data is None: - msg = f"cannot locate {_PY_INFO_SCRIPT.name} for subprocess interrogation" - raise FileNotFoundError(msg) - # Python prepends the probe script's directory to sys.path, so keep that directory private. - with tempfile.TemporaryDirectory() as private_dir: - script: Final[Path] = Path(private_dir) / _PY_INFO_SCRIPT.name - script.write_bytes(data) - yield script - - -def _extract_between_cookies(out: str, start_cookie: str, end_cookie: str) -> tuple[str, str, int, int]: - """Extract payload between reversed cookie markers, forwarding any surrounding output to stdout.""" - raw_out = out - out_starts = out.find(start_cookie[::-1]) - if out_starts > -1: - if pre_cookie := out[:out_starts]: - sys.stdout.write(pre_cookie) - out = out[out_starts + COOKIE_LENGTH :] - out_ends = out.find(end_cookie[::-1]) - if out_ends > -1: - if post_cookie := out[out_ends + COOKIE_LENGTH :]: - sys.stdout.write(post_cookie) - out = out[:out_ends] - return out, raw_out, out_starts, out_ends - - def _run_subprocess( cls: type[PythonInfo], exe: str, @@ -317,6 +250,55 @@ def _run_subprocess( return None, result +def gen_cookie() -> str: + return secrets.token_hex(_COOKIE_LENGTH // 2) + + +@lru_cache(maxsize=128) +def _query_timeout(raw: str | None) -> float | None: + default_query_timeout: float = 15.0 + if raw is None: + return default_query_timeout + try: + timeout = float(raw) + except ValueError: + timeout = 0.0 + if timeout > 0: + return timeout if timeout <= _MAX_QUERY_TIMEOUT else None + _LOGGER.warning( + "ignoring PY_DISCOVERY_TIMEOUT=%r, not a positive number of seconds; using %s", raw, default_query_timeout + ) + return default_query_timeout + + +@contextmanager +def _resolve_py_info_script() -> Generator[Path]: + if _PY_INFO_SCRIPT.is_file(): + yield _PY_INFO_SCRIPT + else: + data = pkgutil.get_data(__package__ or __name__, _PY_INFO_SCRIPT.name) + if data is None: + msg = f"cannot locate {_PY_INFO_SCRIPT.name} for subprocess interrogation" + raise FileNotFoundError(msg) + # Python prepends the probe script's directory to sys.path, so keep that directory private. + with tempfile.TemporaryDirectory() as private_dir: + script: Final[Path] = Path(private_dir) / _PY_INFO_SCRIPT.name + script.write_bytes(data) + yield script + + +class LogCmd: + def __init__(self, cmd: list[str], env: Mapping[str, str] | None = None) -> None: + self.cmd = cmd + self.env = env + + def __repr__(self) -> str: + cmd_repr = " ".join(quote(str(c)) for c in self.cmd) + if self.env is not None: + cmd_repr = f"{cmd_repr} env of {self.env!r}" + return cmd_repr + + def _collect_query_output(process: Popen[str], timeout: float | None) -> tuple[str, str, int]: stdout, stderr = process.stdout, process.stderr if stdout is None or stderr is None: @@ -355,27 +337,6 @@ class _QueryReadState: read_errors: list[OSError] -def _stop_query_readers(readers: tuple[Thread, Thread]) -> None: - for reader in readers: - if sys.platform == "win32": # pragma: win32 cover - stop_query_reader(reader) - else: - reader.join() - - -def _query_completed(process: Popen[str], readers: tuple[Thread, Thread], timeout: float | None) -> bool: - deadline = None if timeout is None else monotonic() + timeout - for reader in readers: - reader.join(None if deadline is None else max(deadline - monotonic(), 0)) - if any(reader.is_alive() for reader in readers): - return False - try: - process.wait(timeout=None if deadline is None else max(deadline - monotonic(), 0)) - except TimeoutExpired: - return False - return True - - def _drain_query_stream(state: _QueryReadState, stream: IO[str], chunks: list[str]) -> None: size = 0 failed = False @@ -391,8 +352,7 @@ def _drain_query_stream(state: _QueryReadState, stream: IO[str], chunks: list[st state.read_errors.append(error) failed = True break - chunk = decoder.decode(data, final=not data) - if chunk: + if chunk := decoder.decode(data, final=not data): chunks.append(chunk[: _MAX_QUERY_OUTPUT + 1 - size]) size += len(chunk) if size > _MAX_QUERY_OUTPUT: @@ -406,20 +366,25 @@ def _drain_query_stream(state: _QueryReadState, stream: IO[str], chunks: list[st state.process.kill() -@lru_cache(maxsize=128) -def _query_timeout(raw: str | None) -> float | None: - if raw is None: - return _DEFAULT_QUERY_TIMEOUT +def _query_completed(process: Popen[str], readers: tuple[Thread, Thread], timeout: float | None) -> bool: + deadline = None if timeout is None else monotonic() + timeout + for reader in readers: + reader.join(None if deadline is None else max(deadline - monotonic(), 0)) + if any(reader.is_alive() for reader in readers): + return False try: - timeout = float(raw) - except ValueError: - timeout = 0.0 - if timeout > 0: - return timeout if timeout <= _MAX_QUERY_TIMEOUT else None - _LOGGER.warning( - "ignoring PY_DISCOVERY_TIMEOUT=%r, not a positive number of seconds; using %s", raw, _DEFAULT_QUERY_TIMEOUT - ) - return _DEFAULT_QUERY_TIMEOUT + process.wait(timeout=None if deadline is None else max(deadline - monotonic(), 0)) + except TimeoutExpired: + return False + return True + + +def _stop_query_readers(readers: tuple[Thread, Thread]) -> None: + for reader in readers: + if sys.platform == "win32": # pragma: win32 cover + stop_query_reader(reader) + else: + reader.join() def _query_failure(exe: str, out: str, err: str | None, code: int | None) -> RuntimeError: @@ -436,16 +401,45 @@ def _query_failure(exe: str, out: str, err: str | None, code: int | None) -> Run return RuntimeError(f"failed to query {msg}") -class LogCmd: - def __init__(self, cmd: list[str], env: Mapping[str, str] | None = None) -> None: - self.cmd = cmd - self.env = env +def _extract_between_cookies(out: str, start_cookie: str, end_cookie: str) -> tuple[str, str, int, int]: + # Extract payload between reversed cookie markers, forwarding any surrounding output to stdout. + raw_out = out + out_starts = out.find(start_cookie[::-1]) + if out_starts > -1: + if pre_cookie := out[:out_starts]: + sys.stdout.write(pre_cookie) + out = out[out_starts + _COOKIE_LENGTH :] + out_ends = out.find(end_cookie[::-1]) + if out_ends > -1: + if post_cookie := out[out_ends + _COOKIE_LENGTH :]: + sys.stdout.write(post_cookie) + out = out[:out_ends] + return out, raw_out, out_starts, out_ends - def __repr__(self) -> str: - cmd_repr = " ".join(quote(str(c)) for c in self.cmd) - if self.env is not None: - cmd_repr = f"{cmd_repr} env of {self.env!r}" - return cmd_repr + +class _UnsupportedInterpreterError(RuntimeError): + # The interpreter is older than the interrogation script supports; retrying cannot help. + def __init__(self, message: str, executable: str | None = None) -> None: + super().__init__(message) + self.executable = executable + + +def _cacheable_result(path: Path, result: PythonInfo | Exception) -> bool: + if isinstance(result, PythonInfo): + executable = result.original_executable + elif isinstance(result, _UnsupportedInterpreterError): + executable = result.executable + else: + return False + if executable is None: + return False + try: + return path.samefile(executable) + except (OSError, TypeError, ValueError): + return False + + +_COOKIE_LENGTH: Final[int] = 32 def clear(cache: PyInfoCache) -> None: @@ -457,4 +451,5 @@ def clear(cache: PyInfoCache) -> None: "LogCmd", "clear", "from_exe", + "gen_cookie", ] diff --git a/src/python_discovery/_compat.py b/src/python_discovery/_compat.py index f55b089..f3e1688 100644 --- a/src/python_discovery/_compat.py +++ b/src/python_discovery/_compat.py @@ -11,6 +11,10 @@ _LOGGER: Final[logging.Logger] = logging.getLogger(__name__) +def fs_path_id(path: str) -> str: + return path.casefold() if not fs_is_case_sensitive() else path + + @functools.lru_cache(maxsize=1) def fs_is_case_sensitive() -> bool: with tempfile.NamedTemporaryFile(prefix="TmP") as tmp_file: @@ -19,10 +23,6 @@ def fs_is_case_sensitive() -> bool: return result -def fs_path_id(path: str) -> str: - return path.casefold() if not fs_is_case_sensitive() else path - - __all__ = [ "fs_is_case_sensitive", "fs_path_id", diff --git a/src/python_discovery/_discovery.py b/src/python_discovery/_discovery.py index c04f070..3f913fc 100644 --- a/src/python_discovery/_discovery.py +++ b/src/python_discovery/_discovery.py @@ -46,7 +46,7 @@ def get_interpreter( :param env: environment mapping for ``PATH`` and uv store lookup; defaults to :data:`os.environ`. :param predicate: optional callback applied after an interpreter matches the spec. Return ``True`` to accept the interpreter, ``False`` to skip it and continue searching. - :return: the first matching interpreter, or ``None`` if no match is found. + :returns: the first matching interpreter, or ``None`` if no match is found. """ specs = [key] if isinstance(key, str) else key for spec_str, preferred_paths in zip(specs, tee(try_first_with or (), len(specs))): @@ -55,73 +55,6 @@ def get_interpreter( return None -def iter_interpreters( - key: str | Sequence[str] | None = None, - try_first_with: Iterable[str] | None = None, - cache: PyInfoCache | None = None, - env: Mapping[str, str] | None = None, - predicate: Callable[[PythonInfo], bool] | None = None, -) -> Iterator[PythonInfo]: - """ - Yield every interpreter on the system that satisfies *key*. - - Iteration order is discovery order: ``try_first_with`` paths first, then the running interpreter, then ``PATH`` - (left to right), then UV-managed installs. Results are deduplicated by the resolved real path of the underlying - system interpreter, so symlinked aliases (``/bin`` vs ``/usr/bin``) and venvs that symlink to a base interpreter - collapse to a single entry. Callers that want a different ordering should sort the result. - - :param key: interpreter specification — same syntax as :func:`get_interpreter`. ``None`` enumerates every Python - implementation python-discovery knows about (see :data:`KNOWN_IMPLEMENTATIONS`). - :param try_first_with: executables to probe before the normal discovery search. - :param cache: interpreter metadata cache; when ``None`` results are not cached. Strongly recommended for - enumeration, which interrogates every candidate as a subprocess on a cold cache. - :param env: environment mapping for ``PATH`` and uv store lookup; defaults to :data:`os.environ`. - :param predicate: optional filter applied after the spec match; return ``True`` to include the interpreter. - """ - if key is None: - keys: tuple[str | None, ...] = (None,) - elif isinstance(key, str): - keys = (key,) - else: - keys = tuple(key) - first_with = tuple(try_first_with or ()) - env_map = os.environ if env is None else env - seen: set[str] = set() - for spec_str in keys: - yield from _iter_for_spec(spec_str, first_with, cache, env_map, predicate, seen) - - -def _iter_for_spec( # ruff:ignore[too-many-arguments, too-many-positional-arguments] - spec_str: str | None, - try_first_with: tuple[str, ...], - cache: PyInfoCache | None, - env: Mapping[str, str], - predicate: Callable[[PythonInfo], bool] | None, - seen: set[str], -) -> Iterator[PythonInfo]: - if spec_str is None: - spec = PythonSpec("", None, None, None, None, None, None) - wide = True - else: - spec = PythonSpec.from_string_spec(spec_str) - wide = False - for interpreter, impl_must_match in propose_interpreters( - spec, try_first_with, cache, env, all_implementations=wide - ): - if interpreter is None: - continue - if (anchor := interpreter.system_executable or interpreter.executable) is None: - continue - if (real_path := os.path.realpath(anchor)) in seen: - continue - if not interpreter.satisfies(spec, impl_must_match=impl_must_match): - continue - if predicate is not None and not predicate(interpreter): - continue - seen.add(real_path) - yield interpreter - - def _find_interpreter( key: str, try_first_with: Iterable[str], @@ -149,30 +82,6 @@ def _find_interpreter( return None -def _check_exe(path: str, tested_exes: set[str]) -> str | None: - """Resolve *path* to an absolute path and return it if not yet tested, otherwise ``None``.""" - try: - os.lstat(path) - except OSError: - return None - resolved = str(Path(path).resolve()) - exe_id = fs_path_id(resolved) - if exe_id in tested_exes: - return None - tested_exes.add(exe_id) - # unlike Path.absolute(), abspath collapses "..", which PythonInfo._satisfies_path relies on - return os.path.abspath(path) - - -def _is_new_exe(exe_raw: str, tested_exes: set[str]) -> bool: - """Return ``True`` and register *exe_raw* if it hasn't been tested yet.""" - exe_id = fs_path_id(exe_raw) - if exe_id in tested_exes: - return False - tested_exes.add(exe_id) - return True - - def propose_interpreters( spec: PythonSpec, try_first_with: Iterable[str], @@ -181,16 +90,14 @@ def propose_interpreters( *, all_implementations: bool = False, ) -> Generator[tuple[PythonInfo | None, bool], None, None]: - """ - Yield ``(interpreter, impl_must_match)`` candidates for *spec*. - - :param spec: the parsed interpreter specification to match against. - :param try_first_with: executable paths to probe before the standard search. - :param cache: interpreter metadata cache; when ``None`` results are not cached. - :param env: environment mapping for ``PATH`` and uv store lookup; defaults to :data:`os.environ`. - :param all_implementations: when ``True`` and *spec* does not constrain the implementation, also surface - non-CPython binaries on ``PATH`` and under UV's install directory. Used by enumeration APIs. - """ + # Yield ``(interpreter, impl_must_match)`` candidates for *spec*. + # + # :param spec: the parsed interpreter specification to match against. + # :param try_first_with: executable paths to probe before the standard search. + # :param cache: interpreter metadata cache; when ``None`` results are not cached. + # :param env: environment mapping for ``PATH`` and uv store lookup; defaults to :data:`os.environ`. + # :param all_implementations: when ``True`` and *spec* does not constrain the implementation, also surface + # non-CPython binaries on ``PATH`` and under UV's install directory. Used by enumeration APIs. env = os.environ if env is None else env tested_exes: set[str] = set() if spec.is_abs and spec.path is not None: @@ -205,6 +112,20 @@ def propose_interpreters( yield from _propose_from_uv(spec, cache, env, all_implementations=all_implementations) +def _check_exe(path: str, tested_exes: set[str]) -> str | None: + # Resolve *path* to an absolute path and return it if not yet tested, otherwise ``None``. + try: + os.lstat(path) + except OSError: + return None + exe_id = fs_path_id(str(Path(path).resolve())) + if exe_id in tested_exes: + return None + tested_exes.add(exe_id) + # unlike Path.absolute(), abspath collapses "..", which PythonInfo._satisfies_path relies on + return os.path.abspath(path) + + def _propose_explicit( spec: PythonSpec, try_first_with: Iterable[str], @@ -242,13 +163,22 @@ def _propose_current_and_windows( yield interpreter, True +def _is_new_exe(exe_raw: str, tested_exes: set[str]) -> bool: + # Return ``True`` and register *exe_raw* if it hasn't been tested yet. + exe_id = fs_path_id(exe_raw) + if exe_id in tested_exes: + return False + tested_exes.add(exe_id) + return True + + def _propose_from_path( spec: PythonSpec, cache: PyInfoCache | None, env: Mapping[str, str], tested_exes: set[str], *, - all_implementations: bool = False, + all_implementations: bool, ) -> Generator[tuple[PythonInfo | None, bool], None, None]: find_candidates = path_exe_finder(spec, all_implementations=all_implementations) for pos, path in enumerate(get_paths(env)): @@ -260,17 +190,157 @@ def _propose_from_path( exe_raw = resolved if not _is_new_exe(exe_raw, tested_exes): continue - interpreter = PathPythonInfo.from_exe(exe_raw, cache, raise_on_error=False, env=env) - if interpreter is not None: + if (interpreter := PathPythonInfo.from_exe(exe_raw, cache, raise_on_error=False, env=env)) is not None: yield interpreter, impl_must_match +def path_exe_finder( + spec: PythonSpec, *, all_implementations: bool = False +) -> Callable[[Path], Generator[tuple[Path, bool], None, None]]: + # Given a spec, return a function that can be called on a path to find all matching files in it. + pat = spec.generate_re(windows=sys.platform == "win32", all_implementations=all_implementations) + direct = spec.str_spec + implementation = spec.implementation.lower() if spec.implementation is not None else None + impl_must_match = implementation in KNOWN_IMPLEMENTATIONS and implementation != "python" + if sys.platform == "win32": # pragma: win32 cover + direct = f"{direct}.exe" + + def path_exes(path: Path) -> Generator[tuple[Path, bool], None, None]: + # an empty spec (enumerating everything) would make the direct path the PATH directory itself + direct_path = path / direct + if direct_path.is_file(): + yield direct_path, impl_must_match + + for exe in path.iterdir(): + if (match := pat.fullmatch(exe.name)) and exe.is_file(): + yield exe.absolute(), impl_must_match or match["impl"].lower() == "python" + + return path_exes + + +def get_paths(env: Mapping[str, str]) -> Generator[Path, None, None]: + path = env.get("PATH", None) + if path is None: + try: + path = os.confstr("CS_PATH") + except (AttributeError, ValueError): # pragma: no cover # Windows only (no confstr) + path = os.defpath + if path: + # Relative entries can execute an interpreter supplied by the working directory. + for entry in map(Path, filter(None, path.split(os.pathsep))): + with suppress(OSError): + if entry.is_absolute() and entry.is_dir() and next(entry.iterdir(), None): + yield entry + + +class LazyPathDump: + def __init__(self, pos: int, path: Path, env: Mapping[str, str]) -> None: + self.pos = pos + self.path = path + self.env = env + + def __repr__(self) -> str: + content = f"discover PATH[{self.pos}]={self.path}" + if self.env.get("_VIRTUALENV_DEBUG"): + content += " with =>" + for file_path in self.path.iterdir(): + try: + if not self._is_executable(file_path): + continue + except OSError: + pass + content += " " + content += file_path.name + return content + + def _is_executable(self, file_path: Path) -> bool: + if file_path.is_dir(): + return False + if IS_WIN: # pragma: win32 cover + pathext = self.env.get("PATHEXT", ".COM;.EXE;.BAT;.CMD").split(";") + return any(file_path.name.upper().endswith(ext) for ext in pathext) + return bool(file_path.stat().st_mode & os.X_OK) + + +def _resolve_shim(exe_path: str, env: Mapping[str, str]) -> str | None: + # Resolve a version-manager shim to the actual Python binary. + version_manager_layouts: list[tuple[str, tuple[str, ...]]] = [ + ("PYENV_ROOT", ("versions",)), + ("MISE_DATA_DIR", ("installs", "python")), + ("ASDF_DATA_DIR", ("installs", "python")), + ] + for shims_dir_env, versions_path in version_manager_layouts: + if (root := env.get(shims_dir_env)) and os.path.dirname(exe_path) == os.path.join(root, "shims"): + return _resolve_shim_to_binary(os.path.basename(exe_path), os.path.join(root, *versions_path), env) + return None + + +def _resolve_shim_to_binary(exe_name: str, versions_dir: str, env: Mapping[str, str]) -> str | None: + for version in _active_versions(env): + if version in {"", ".", ".."} or basename(version) != version: + _LOGGER.debug("ignoring invalid version name %r", version) + continue + resolved = os.path.join(versions_dir, version, "bin", exe_name) + if Path(resolved).is_file() and os.access(resolved, os.X_OK): + return resolved + return None + + +def _active_versions(env: Mapping[str, str]) -> Generator[str, None, None]: + # Yield active Python version strings by reading version-manager configuration. + if pyenv_version := env.get("PYENV_VERSION"): + yield from pyenv_version.split(":") + return + if versions := _read_python_version_file(Path.cwd()): + yield from versions + return + if (pyenv_root := env.get("PYENV_ROOT")) and ( + versions := _read_python_version_file(os.path.join(pyenv_root, "version"), search_parents=False) + ): + yield from versions + + +def _read_python_version_file(start: str | Path, *, search_parents: bool = True) -> list[str] | None: + # Read a ``.python-version`` file, optionally searching parent directories. + current = start + while True: + # unlike pathlib before 3.14, os.path answers False when a directory on the way can't be searched + candidate = os.path.join(current, ".python-version") if os.path.isdir(current) else current + if os.path.isfile(candidate) and (versions := _versions_in_file(candidate)): + return versions + if not search_parents: + return None + parent = Path(current).parent + if parent == current: + return None + current = parent + + +def _versions_in_file(path: str | Path) -> list[str]: + try: + # pyenv reads raw bytes, so a byte that is not UTF-8 (a Latin-1 comment, say) must not hide the versions + with Path(path).open(encoding="utf-8", errors="replace") as fh: + # Version files can sit in shared parent directories; bound the in-process read. + max_chars = 4096 + content = fh.read(max_chars + 1) + if len(content) > max_chars: + _LOGGER.debug("ignored oversized version file %s", path) + return [] + return [v for line in content.split("\n") if (v := line.strip()) and not v.startswith("#")] + except OSError: # skip a file we cannot read, the same as one that names no version + _LOGGER.debug("failed to read %s", path, exc_info=True) + return [] + + +class PathPythonInfo(PythonInfo): ... + + def _propose_from_uv( spec: PythonSpec, cache: PyInfoCache | None, env: Mapping[str, str], *, - all_implementations: bool = False, + all_implementations: bool, ) -> Generator[tuple[PythonInfo | None, bool], None, None]: seen_uv_paths: set[str] = set() for root in _uv_python_roots(env): @@ -285,7 +355,7 @@ def _propose_from_uv( def _uv_python_roots(env: Mapping[str, str]) -> Generator[Path, None, None]: - """Yield uv's store roots, legacy first; searching both keeps a stale legacy directory from hiding the live one.""" + # Yield uv's store roots, legacy first; searching both keeps a stale legacy directory from hiding the live one. if install_dir := env.get("UV_PYTHON_INSTALL_DIR"): yield Path(install_dir).expanduser() return @@ -295,7 +365,7 @@ def _uv_python_roots(env: Mapping[str, str]) -> Generator[Path, None, None]: def _uv_state_dirs(env: Mapping[str, str]) -> Generator[Path, None, None]: - """Yield uv's legacy state directory then its current one, the order uv prefers them in.""" + # Yield uv's legacy state directory then its current one, the order uv prefers them in. windows: Final = sys.platform == "win32" home: Final = Path(profile) if (profile := env.get("USERPROFILE" if windows else "HOME")) else None if windows: @@ -330,39 +400,6 @@ def _uv_installs(root: Path, spec: PythonSpec, *, all_implementations: bool) -> return matching -def _parse_uv_install(path: Path) -> _UvInstall: - implementation, _, remainder = path.name.partition("-") - version_str, _, variant = remainder.partition("-")[0].partition("+") - try: - version = SimpleVersion.from_string(version_str) - except ValueError: # not a uv key, but the directory may still hold an interpreter worth probing - return _UvInstall(path=path, key=None) - variants = variant.split("+") - key = _UvKey( - implementation=implementation.lower(), - version=version, - free_threaded="freethreaded" in variants, - debug="debug" in variants, - ) - return _UvInstall(path=path, key=key) - - -@dataclass(**DC_KW) -class _UvKey: - """The ``-[+]---`` name uv gives an install directory.""" - - implementation: str - version: SimpleVersion - free_threaded: bool - debug: bool - - -@dataclass(**DC_KW) -class _UvInstall: - path: Path - key: _UvKey | None - - def _uv_install_matches(install: _UvInstall, spec: PythonSpec, *, all_implementations: bool) -> bool: if (key := install.key) is None: return True @@ -404,8 +441,25 @@ def _uv_version_matches(key: _UvKey, spec: PythonSpec) -> bool: return all(req is None or our == req for our, req in zip(key.version.release, (spec.major, spec.minor, spec.micro))) +def _parse_uv_install(path: Path) -> _UvInstall: + implementation, _, remainder = path.name.partition("-") + version_str, _, variant = remainder.partition("-")[0].partition("+") + try: + version = SimpleVersion.from_string(version_str) + except ValueError: # not a uv key, but the directory may still hold an interpreter worth probing + return _UvInstall(path=path, key=None) + variants = variant.split("+") + key = _UvKey( + implementation=implementation.lower(), + version=version, + free_threaded="freethreaded" in variants, + debug="debug" in variants, + ) + return _UvInstall(path=path, key=key) + + def _uv_sort_key(install: _UvInstall) -> tuple[int, ...]: - """Rank like uv does: CPython first, newest version first, default build ahead of free-threaded and debug.""" + # Rank like uv does: CPython first, newest version first, default build ahead of free-threaded and debug. impl_order: Final[tuple[str, ...]] = ("cpython", "pypy", "graalpy", "pyodide") if (key := install.key) is None: return (len(impl_order) + 1,) @@ -440,159 +494,95 @@ def _uv_executables(install: _UvInstall) -> Generator[Path, None, None]: yield from sorted(base.glob(pattern)) -def get_paths(env: Mapping[str, str]) -> Generator[Path, None, None]: - path = env.get("PATH", None) - if path is None: - try: - path = os.confstr("CS_PATH") - except (AttributeError, ValueError): # pragma: no cover # Windows only (no confstr) - path = os.defpath - if path: - # Relative entries can execute an interpreter supplied by the working directory. - for entry in map(Path, filter(None, path.split(os.pathsep))): - with suppress(OSError): - if entry.is_absolute() and entry.is_dir() and next(entry.iterdir(), None): - yield entry - - -class LazyPathDump: - def __init__(self, pos: int, path: Path, env: Mapping[str, str]) -> None: - self.pos = pos - self.path = path - self.env = env - - def __repr__(self) -> str: - content = f"discover PATH[{self.pos}]={self.path}" - if self.env.get("_VIRTUALENV_DEBUG"): - content += " with =>" - for file_path in self.path.iterdir(): - try: - if not self._is_executable(file_path): - continue - except OSError: - pass - content += " " - content += file_path.name - return content - - def _is_executable(self, file_path: Path) -> bool: - if file_path.is_dir(): - return False - if IS_WIN: # pragma: win32 cover - pathext = self.env.get("PATHEXT", ".COM;.EXE;.BAT;.CMD").split(";") - return any(file_path.name.upper().endswith(ext) for ext in pathext) - return bool(file_path.stat().st_mode & os.X_OK) - - -def path_exe_finder( - spec: PythonSpec, *, all_implementations: bool = False -) -> Callable[[Path], Generator[tuple[Path, bool], None, None]]: - """Given a spec, return a function that can be called on a path to find all matching files in it.""" - pat = spec.generate_re(windows=sys.platform == "win32", all_implementations=all_implementations) - direct = spec.str_spec - implementation = spec.implementation.lower() if spec.implementation is not None else None - impl_must_match = implementation in KNOWN_IMPLEMENTATIONS and implementation != "python" - if sys.platform == "win32": # pragma: win32 cover - direct = f"{direct}.exe" - - def path_exes(path: Path) -> Generator[tuple[Path, bool], None, None]: - # an empty spec (enumerating everything) would make the direct path the PATH directory itself - direct_path = path / direct - if direct_path.is_file(): - yield direct_path, impl_must_match - - for exe in path.iterdir(): - if (match := pat.fullmatch(exe.name)) and exe.is_file(): - yield exe.absolute(), impl_must_match or match["impl"].lower() == "python" - - return path_exes - - -def _resolve_shim(exe_path: str, env: Mapping[str, str]) -> str | None: - """Resolve a version-manager shim to the actual Python binary.""" - for shims_dir_env, versions_path in _VERSION_MANAGER_LAYOUTS: - if root := env.get(shims_dir_env): - shims_dir = os.path.join(root, "shims") - if os.path.dirname(exe_path) == shims_dir: - exe_name = os.path.basename(exe_path) - versions_dir = os.path.join(root, *versions_path) - return _resolve_shim_to_binary(exe_name, versions_dir, env) - return None - - -_VERSION_MANAGER_LAYOUTS: list[tuple[str, tuple[str, ...]]] = [ - ("PYENV_ROOT", ("versions",)), - ("MISE_DATA_DIR", ("installs", "python")), - ("ASDF_DATA_DIR", ("installs", "python")), -] +def iter_interpreters( + key: str | Sequence[str] | None = None, + try_first_with: Iterable[str] | None = None, + cache: PyInfoCache | None = None, + env: Mapping[str, str] | None = None, + predicate: Callable[[PythonInfo], bool] | None = None, +) -> Iterator[PythonInfo]: + """ + Yield every interpreter on the system that satisfies *key*. + Iteration order is discovery order: ``try_first_with`` paths first, then the running interpreter, then ``PATH`` + (left to right), then UV-managed installs. Results are deduplicated by the resolved real path of the underlying + system interpreter, so symlinked aliases (``/bin`` vs ``/usr/bin``) and venvs that symlink to a base interpreter + collapse to a single entry. Callers that want a different ordering should sort the result. -def _resolve_shim_to_binary(exe_name: str, versions_dir: str, env: Mapping[str, str]) -> str | None: - for version in _active_versions(env): - if version in {"", ".", ".."} or basename(version) != version: - _LOGGER.debug("ignoring invalid version name %r", version) - continue - resolved = os.path.join(versions_dir, version, "bin", exe_name) - if Path(resolved).is_file() and os.access(resolved, os.X_OK): - return resolved - return None + :param key: interpreter specification — same syntax as :func:`get_interpreter`. ``None`` enumerates every Python + implementation python-discovery knows about (see :data:`KNOWN_IMPLEMENTATIONS`). + :param try_first_with: executables to probe before the normal discovery search. + :param cache: interpreter metadata cache; when ``None`` results are not cached. Strongly recommended for + enumeration, which interrogates every candidate as a subprocess on a cold cache. + :param env: environment mapping for ``PATH`` and uv store lookup; defaults to :data:`os.environ`. + :param predicate: optional filter applied after the spec match; return ``True`` to include the interpreter. + :yields: + """ + if key is None: + keys: tuple[str | None, ...] = (None,) + elif isinstance(key, str): + keys = (key,) + else: + keys = tuple(key) + first_with = tuple(try_first_with or ()) + env_map = os.environ if env is None else env + seen: set[str] = set() + for spec_str in keys: + yield from _iter_for_spec(spec_str, first_with, cache, env_map, predicate, seen) -def _active_versions(env: Mapping[str, str]) -> Generator[str, None, None]: - """Yield active Python version strings by reading version-manager configuration.""" - if pyenv_version := env.get("PYENV_VERSION"): - yield from pyenv_version.split(":") - return - if versions := _read_python_version_file(Path.cwd()): - yield from versions - return - if (pyenv_root := env.get("PYENV_ROOT")) and ( - versions := _read_python_version_file(os.path.join(pyenv_root, "version"), search_parents=False) +def _iter_for_spec( # ruff:ignore[too-many-arguments, too-many-positional-arguments] + spec_str: str | None, + try_first_with: tuple[str, ...], + cache: PyInfoCache | None, + env: Mapping[str, str], + predicate: Callable[[PythonInfo], bool] | None, + seen: set[str], +) -> Iterator[PythonInfo]: + if spec_str is None: + spec = PythonSpec("", None, None, None, None, None, None) + wide = True + else: + spec = PythonSpec.from_string_spec(spec_str) + wide = False + for interpreter, impl_must_match in propose_interpreters( + spec, try_first_with, cache, env, all_implementations=wide ): - yield from versions - - -def _read_python_version_file(start: str | Path, *, search_parents: bool = True) -> list[str] | None: - """Read a ``.python-version`` file, optionally searching parent directories.""" - current = start - while True: - # unlike pathlib before 3.14, os.path answers False when a directory on the way can't be searched - candidate = os.path.join(current, ".python-version") if os.path.isdir(current) else current - if os.path.isfile(candidate) and (versions := _versions_in_file(candidate)): - return versions - if not search_parents: - return None - parent = Path(current).parent - if parent == current: - return None - current = parent + if interpreter is None: + continue + if (anchor := interpreter.system_executable or interpreter.executable) is None: + continue + if (real_path := os.path.realpath(anchor)) in seen: + continue + if not interpreter.satisfies(spec, impl_must_match=impl_must_match): + continue + if predicate is not None and not predicate(interpreter): + continue + seen.add(real_path) + yield interpreter -def _versions_in_file(path: str | Path) -> list[str]: - try: - # pyenv reads raw bytes, so a byte that is not UTF-8 (a Latin-1 comment, say) must not hide the versions - with Path(path).open(encoding="utf-8", errors="replace") as fh: - # Version files can sit in shared parent directories; bound the in-process read. - max_chars = 4096 - content = fh.read(max_chars + 1) - if len(content) > max_chars: - _LOGGER.debug("ignored oversized version file %s", path) - return [] - return [v for line in content.split("\n") if (v := line.strip()) and not v.startswith("#")] - except OSError: # skip a file we cannot read, the same as one that names no version - _LOGGER.debug("failed to read %s", path, exc_info=True) - return [] +@dataclass(**DC_KW) +class _UvKey: + # The ``-[+]---`` name uv gives an install directory. + implementation: str + version: SimpleVersion + free_threaded: bool + debug: bool -class PathPythonInfo(PythonInfo): - """python info from path.""" +@dataclass(**DC_KW) +class _UvInstall: + path: Path + key: _UvKey | None __all__ = [ + "IS_WIN", "LazyPathDump", - "PathPythonInfo", "get_interpreter", "get_paths", "iter_interpreters", + "path_exe_finder", "propose_interpreters", ] diff --git a/src/python_discovery/_py_info.py b/src/python_discovery/_py_info.py index 83bb0fe..d7d6646 100644 --- a/src/python_discovery/_py_info.py +++ b/src/python_discovery/_py_info.py @@ -45,7 +45,44 @@ def _get_path_extensions() -> list[str]: class PythonInfo: # ruff:ignore[too-many-public-methods] - """Contains information for a Python interpreter.""" + """ + Contains information for a Python interpreter. + + :ivar platform: + :ivar implementation: + :ivar pypy_version_info: + :ivar version_info: + :ivar architecture: + :ivar sysconfig_platform: + :ivar version_nodot: + :ivar version: + :ivar os: + :ivar free_threaded: + :ivar debug_build: + :ivar marker_environment: + :ivar prefix: + :ivar base_prefix: + :ivar real_prefix: + :ivar base_exec_prefix: + :ivar exec_prefix: + :ivar executable: + :ivar original_executable: + :ivar system_executable: + :ivar has_venv: + :ivar path: + :ivar file_system_encoding: + :ivar stdout_encoding: + :ivar sysconfig_scheme: + :ivar sysconfig_paths: + :ivar distutils_install: + :ivar sysconfig: + :ivar sysconfig_vars: + :ivar tcl_lib: + :ivar tk_lib: + :ivar system_stdlib: + :ivar system_stdlib_platform: + :ivar max_size: + """ platform: str implementation: str @@ -82,23 +119,244 @@ class PythonInfo: # ruff:ignore[too-many-public-methods] system_stdlib_platform: str max_size: int + _current_system = None + + _current = None + + _cache_exe_discovery: ClassVar[dict[tuple[str, bool, VersionInfo], PythonInfo]] = {} + def __init__(self) -> None: self.__dict__.update(vars(self.from_dict(PythonInfoCollector().to_dict()))) - def install_path(self, key: str) -> str: + @classmethod + def from_dict(cls, data: dict[str, InfoValue]) -> PythonInfo: """ - Return the relative installation path for a given installation scheme *key*. + Reconstruct a :class:`PythonInfo` from a plain dictionary. - :param key: sysconfig installation scheme key (e.g. ``"scripts"``, ``"purelib"``). + :param data: dictionary produced by :meth:`to_dict`. + :returns: + :raises ValueError: """ - result = self.distutils_install.get(key) - if result is None: # pragma: >=3.11 cover # distutils is empty when "venv" scheme is available - # set prefixes to empty => result is relative from cwd - prefixes = self.prefix, self.exec_prefix, self.base_prefix, self.base_exec_prefix - config_var = {k: "" if v in prefixes else v for k, v in self.sysconfig_vars.items()} - result = self.sysconfig_path(key, config_var=config_var).lstrip(os.sep) + if missing := PythonInfo.__annotations__.keys() - data.keys() - {"_cache_exe_discovery", "pypy_version_info"}: + msg = f"missing interpreter information: {', '.join(sorted(missing))}" + raise ValueError(msg) + data["version_info"] = VersionInfo(**data["version_info"]) # restore this to a named tuple structure + result = cls.__new__(cls) # skip __init__, data replaces the full state + result.__dict__ = data.copy() return result + @classmethod + def current(cls, cache: PyInfoCache | None = None) -> PythonInfo: + """ + Locate the current host interpreter information. + + :param cache: interpreter metadata cache; when ``None`` results are not cached. + :returns: + :raises RuntimeError: + """ + if cls._current is None: + result = cls.from_exe(sys.executable, cache, raise_on_error=True, resolve_to_host=False) + if result is None: + msg = "failed to query current Python interpreter" + raise RuntimeError(msg) + cls._current = result + return cls._current + + @classmethod + def from_exe( # ruff:ignore[too-many-arguments] + cls, + exe: str, + cache: PyInfoCache | None = None, + *, + raise_on_error: bool = True, + ignore_cache: bool = False, + resolve_to_host: bool = True, + env: Mapping[str, str] | None = None, + ) -> PythonInfo | None: + """ + Get the python information for a given executable path. + + :param exe: path to the Python executable. + :param cache: interpreter metadata cache; when ``None`` results are not cached. + :param raise_on_error: raise on failure instead of returning ``None``. + :param ignore_cache: bypass the cache and re-query the interpreter. + :param resolve_to_host: resolve through virtualenv layers to the system interpreter. + :param env: environment mapping; defaults to :data:`os.environ`. + :returns: + """ + return cls._from_exe_with_args( + exe, + cache, + executable_args=None, + raise_on_error=raise_on_error, + ignore_cache=ignore_cache, + resolve_to_host=resolve_to_host, + env=env, + ) + + @classmethod + def _from_exe_with_args( # ruff:ignore[too-many-arguments] + cls, + exe: str, + cache: PyInfoCache | None = None, + *, + executable_args: str | None, + raise_on_error: bool = True, + ignore_cache: bool = False, + resolve_to_host: bool = True, + env: Mapping[str, str] | None = None, + ) -> PythonInfo | None: + # Interrogate an executable with optional platform-specific leading arguments. + from ._cached_py_info import from_exe # ruff:ignore[import-outside-top-level] + + env = os.environ if env is None else env + proposed = from_exe( + cls, + cache, + exe, + env=env, + raise_on_error=raise_on_error, + ignore_cache=ignore_cache, + executable_args=executable_args, + ) + + if isinstance(proposed, PythonInfo) and resolve_to_host: + try: + proposed = proposed.resolve_to_system(cache, proposed, env=env) + except Exception as exception: + if raise_on_error: + raise + _LOGGER.info("ignore %s due cannot resolve system due to %r", proposed.original_executable, exception) + proposed = None + return proposed + + @classmethod + def current_system(cls, cache: PyInfoCache | None = None) -> PythonInfo: + """ + Locate the current system interpreter information, resolving through any virtualenv layers. + + :param cache: interpreter metadata cache; when ``None`` results are not cached. + :returns: + :raises RuntimeError: + """ + if cls._current_system is None: + result = cls.from_exe(sys.executable, cache, raise_on_error=True, resolve_to_host=True) + if result is None: + msg = "failed to query current system Python interpreter" + raise RuntimeError(msg) + cls._current_system = result + return cls._current_system + + @classmethod + def from_json(cls, payload: str) -> PythonInfo: + """ + Deserialize interpreter information from a JSON string. + + :param payload: JSON produced by :meth:`to_json`. + :returns: + """ + return cls.from_dict(json.loads(payload).copy()) + + @classmethod + def resolve_to_system( + cls, cache: PyInfoCache | None, target: PythonInfo, *, env: Mapping[str, str] | None = None + ) -> PythonInfo: + """ + Walk virtualenv/venv prefix chains to find the underlying system interpreter. + + :param cache: interpreter metadata cache; when ``None`` results are not cached. + :param target: the interpreter to resolve. + :param env: environment mapping; defaults to :data:`os.environ`. + :returns: + :raises RuntimeError: + """ + start_executable = target.executable + prefixes = OrderedDict() + while target.system_executable is None: + prefix = target.real_prefix or target.base_prefix or target.prefix + prefix_key = normcase(realpath(prefix)) + if prefix_key in prefixes: + if len(prefixes) == 1: + _LOGGER.info("%r links back to itself via prefixes", target) + target.system_executable = target.executable + break + for at, (p, t) in enumerate(prefixes.items(), start=1): + _LOGGER.error("%d: prefix=%s, info=%r", at, p, t) + _LOGGER.error("%d: prefix=%s, info=%r", len(prefixes) + 1, prefix, target) + msg = "prefixes are causing a circle {}".format("|".join(prefixes.keys())) + raise RuntimeError(msg) + prefixes[prefix_key] = target + target = target.discover_exe(cache, prefix=prefix, exact=False, env=env) + if ( + target.executable != target.system_executable + and (resolved := cls.from_exe(target.system_executable, cache, env=env)) is not None + ): + target = resolved + if target.executable != start_executable: + # the cache hands every lookup of this base the same object, so point a copy at the start executable + target = copy.copy(target) + target.executable = start_executable + return target + + def __repr__(self) -> str: + return "{}({!r})".format( + self.__class__.__name__, + {k: v for k, v in self.__dict__.items() if not k.startswith("_")}, + ) + + def __str__(self) -> str: + return "{}({})".format( + self.__class__.__name__, + ", ".join( + f"{k}={v}" + for k, v in ( + ("spec", self.spec), + ( + "system" + if self.system_executable is not None and self.system_executable != self.executable + else None, + self.system_executable, + ), + ( + "original" + if self.original_executable not in {self.system_executable, self.executable} + else None, + self.original_executable, + ), + ("exe", self.executable), + ("platform", self.platform), + ("version", repr(self.version)), + ("encoding_fs_io", f"{self.file_system_encoding}-{self.stdout_encoding}"), + ) + if k is not None + ), + ) + + @property + def spec(self) -> str: + """A specification string identifying this interpreter (e.g. ``CPython3.13.2-64-arm64``).""" + return "{}{}{}{}-{}-{}".format( + self.implementation, + ".".join(str(i) for i in self.version_info), + "t" if self.free_threaded else "", + "d" if self.debug_build else "", + self.architecture, + self.machine, + ) + + @property + def machine(self) -> str: + """The instruction set architecture (ISA) derived from :func:`sysconfig.get_platform`.""" + plat = self.sysconfig_platform + if plat is None: + return "unknown" + if plat == "win32": + return "x86" + isa = plat.rsplit("-", 1)[-1] + if isa == "universal2": + isa = platform.machine().lower() + return normalize_isa(isa) + @property def version_str(self) -> str: """The full version as ``major.minor.micro`` string (e.g. ``3.13.2``).""" @@ -125,6 +383,23 @@ def is_venv(self) -> bool: """``True`` if this interpreter runs inside a PEP 405 venv (has ``base_prefix``).""" return self.base_prefix is not None + @property + def system_include(self) -> str: + """The path to the system include directory for C headers.""" + path = self.sysconfig_path( + "include", + { + k: (self.system_prefix if isinstance(v, str) and v.startswith(self.prefix) else v) + for k, v in self.sysconfig_vars.items() + }, + ) + # An empty headers path would make the fallback resolve to the existing prefix. + if not os.path.exists(path) and (headers := os.path.dirname(self.install_path("headers"))): + fallback = os.path.join(self.prefix, headers) + if os.path.exists(fallback): + path = fallback + return path + def sysconfig_path(self, key: str, config_var: dict[str, str | int | None] | None = None, sep: str = os.sep) -> str: """ Return the sysconfig install path for a scheme *key*, optionally substituting config variables. @@ -132,6 +407,8 @@ def sysconfig_path(self, key: str, config_var: dict[str, str | int | None] | Non :param key: sysconfig path key (e.g. ``"purelib"``, ``"include"``). :param config_var: replacement mapping for sysconfig variables; when ``None`` uses the interpreter's own values. :param sep: path separator to use in the result. + :returns: + :raises ValueError: """ pattern = self.sysconfig_paths.get(key) if pattern is None: @@ -147,28 +424,26 @@ def sysconfig_path(self, key: str, config_var: dict[str, str | int | None] | Non config_var = base return pattern.format(**config_var).replace("/", sep) - @property - def system_include(self) -> str: - """The path to the system include directory for C headers.""" - path = self.sysconfig_path( - "include", - { - k: (self.system_prefix if isinstance(v, str) and v.startswith(self.prefix) else v) - for k, v in self.sysconfig_vars.items() - }, - ) - # An empty headers path would make the fallback resolve to the existing prefix. - if not os.path.exists(path) and (headers := os.path.dirname(self.install_path("headers"))): - fallback = os.path.join(self.prefix, headers) - if os.path.exists(fallback): - path = fallback - return path - @property def system_prefix(self) -> str: """The prefix of the system Python this interpreter is based on.""" return self.real_prefix or self.base_prefix or self.prefix + def install_path(self, key: str) -> str: + """ + Return the relative installation path for a given installation scheme *key*. + + :param key: sysconfig installation scheme key (e.g. ``"scripts"``, ``"purelib"``). + :returns: + """ + result = self.distutils_install.get(key) + if result is None: # pragma: >=3.11 cover # distutils is empty when "venv" scheme is available + # set prefixes to empty => result is relative from cwd + prefixes = self.prefix, self.exec_prefix, self.base_prefix, self.base_exec_prefix + config_var = {k: "" if v in prefixes else v for k, v in self.sysconfig_vars.items()} + result = self.sysconfig_path(key, config_var=config_var).lstrip(os.sep) + return result + @property def system_exec_prefix(self) -> str: """The exec prefix of the system Python this interpreter is based on.""" @@ -181,68 +456,9 @@ def system_exe(self) -> str: :attr:`system_executable` stays ``None`` until :meth:`resolve_to_system` walks the prefix chain, so reading it forces every caller to narrow a value discovery has already settled. The fallback here matches what resolution - itself writes when a prefix links back to its own interpreter. - """ - return self.system_executable or self.executable - - def __repr__(self) -> str: - return "{}({!r})".format( - self.__class__.__name__, - {k: v for k, v in self.__dict__.items() if not k.startswith("_")}, - ) - - def __str__(self) -> str: - return "{}({})".format( - self.__class__.__name__, - ", ".join( - f"{k}={v}" - for k, v in ( - ("spec", self.spec), - ( - "system" - if self.system_executable is not None and self.system_executable != self.executable - else None, - self.system_executable, - ), - ( - "original" - if self.original_executable not in {self.system_executable, self.executable} - else None, - self.original_executable, - ), - ("exe", self.executable), - ("platform", self.platform), - ("version", repr(self.version)), - ("encoding_fs_io", f"{self.file_system_encoding}-{self.stdout_encoding}"), - ) - if k is not None - ), - ) - - @property - def machine(self) -> str: - """The instruction set architecture (ISA) derived from :func:`sysconfig.get_platform`.""" - plat = self.sysconfig_platform - if plat is None: - return "unknown" - if plat == "win32": - return "x86" - isa = plat.rsplit("-", 1)[-1] - if isa == "universal2": - isa = platform.machine().lower() - return normalize_isa(isa) - - @property - def spec(self) -> str: - """A specification string identifying this interpreter (e.g. ``CPython3.13.2-64-arm64``).""" - return "{}{}{}{}-{}-{}".format( - self.implementation, - ".".join(str(i) for i in self.version_info), - "t" if self.free_threaded else "", - "d" if self.debug_build else "", - self.architecture, - self.machine, - ) + itself writes when a prefix links back to its own interpreter. + """ + return self.system_executable or self.executable @classmethod def clear_cache(cls, cache: PyInfoCache) -> None: @@ -262,6 +478,7 @@ def satisfies(self, spec: PythonSpec, *, impl_must_match: bool) -> bool: # ruff :param spec: the specification to check against. :param impl_must_match: when ``True``, the implementation name must match exactly. + :returns: """ if spec.path and not self._satisfies_path(spec): return False @@ -321,182 +538,25 @@ def _satisfies_version_specifier(self, spec: PythonSpec) -> bool: return False return True - _current_system = None - _current = None - - @classmethod - def current(cls, cache: PyInfoCache | None = None) -> PythonInfo: + def to_json(self) -> str: """ - Locate the current host interpreter information. + Serialize this interpreter information to a JSON string. - :param cache: interpreter metadata cache; when ``None`` results are not cached. + :returns: """ - if cls._current is None: - result = cls.from_exe(sys.executable, cache, raise_on_error=True, resolve_to_host=False) - if result is None: - msg = "failed to query current Python interpreter" - raise RuntimeError(msg) - cls._current = result - return cls._current + return json.dumps(self.to_dict(), indent=2) - @classmethod - def current_system(cls, cache: PyInfoCache | None = None) -> PythonInfo: + def to_dict(self) -> dict[str, InfoValue]: """ - Locate the current system interpreter information, resolving through any virtualenv layers. + Convert this interpreter information to a plain dictionary. - :param cache: interpreter metadata cache; when ``None`` results are not cached. + :returns: """ - if cls._current_system is None: - result = cls.from_exe(sys.executable, cache, raise_on_error=True, resolve_to_host=True) - if result is None: - msg = "failed to query current system Python interpreter" - raise RuntimeError(msg) - cls._current_system = result - return cls._current_system - - def to_json(self) -> str: - """Serialize this interpreter information to a JSON string.""" - return json.dumps(self.to_dict(), indent=2) - - def to_dict(self) -> dict[str, InfoValue]: - """Convert this interpreter information to a plain dictionary.""" data = {name: None if name == "_creators" else value for name, value in vars(self).items()} version_info = data["version_info"] data["version_info"] = version_info._asdict() if hasattr(version_info, "_asdict") else version_info return data - @classmethod - def from_exe( # ruff:ignore[too-many-arguments] - cls, - exe: str, - cache: PyInfoCache | None = None, - *, - raise_on_error: bool = True, - ignore_cache: bool = False, - resolve_to_host: bool = True, - env: Mapping[str, str] | None = None, - ) -> PythonInfo | None: - """ - Get the python information for a given executable path. - - :param exe: path to the Python executable. - :param cache: interpreter metadata cache; when ``None`` results are not cached. - :param raise_on_error: raise on failure instead of returning ``None``. - :param ignore_cache: bypass the cache and re-query the interpreter. - :param resolve_to_host: resolve through virtualenv layers to the system interpreter. - :param env: environment mapping; defaults to :data:`os.environ`. - """ - return cls._from_exe_with_args( - exe, - cache, - executable_args=None, - raise_on_error=raise_on_error, - ignore_cache=ignore_cache, - resolve_to_host=resolve_to_host, - env=env, - ) - - @classmethod - def _from_exe_with_args( # ruff:ignore[too-many-arguments] - cls, - exe: str, - cache: PyInfoCache | None = None, - *, - executable_args: str | None, - raise_on_error: bool = True, - ignore_cache: bool = False, - resolve_to_host: bool = True, - env: Mapping[str, str] | None = None, - ) -> PythonInfo | None: - """Interrogate an executable with optional platform-specific leading arguments.""" - from ._cached_py_info import from_exe # ruff:ignore[import-outside-top-level] - - env = os.environ if env is None else env - proposed = from_exe( - cls, - cache, - exe, - env=env, - raise_on_error=raise_on_error, - ignore_cache=ignore_cache, - executable_args=executable_args, - ) - - if isinstance(proposed, PythonInfo) and resolve_to_host: - try: - proposed = proposed.resolve_to_system(cache, proposed, env=env) - except Exception as exception: - if raise_on_error: - raise - _LOGGER.info("ignore %s due cannot resolve system due to %r", proposed.original_executable, exception) - proposed = None - return proposed - - @classmethod - def from_json(cls, payload: str) -> PythonInfo: - """ - Deserialize interpreter information from a JSON string. - - :param payload: JSON produced by :meth:`to_json`. - """ - raw = json.loads(payload) - return cls.from_dict(raw.copy()) - - @classmethod - def from_dict(cls, data: dict[str, InfoValue]) -> PythonInfo: - """ - Reconstruct a :class:`PythonInfo` from a plain dictionary. - - :param data: dictionary produced by :meth:`to_dict`. - """ - if missing := PythonInfo.__annotations__.keys() - data.keys() - {"_cache_exe_discovery", "pypy_version_info"}: - msg = f"missing interpreter information: {', '.join(sorted(missing))}" - raise ValueError(msg) - data["version_info"] = VersionInfo(**data["version_info"]) # restore this to a named tuple structure - result = cls.__new__(cls) # skip __init__, data replaces the full state - result.__dict__ = data.copy() - return result - - @classmethod - def resolve_to_system( - cls, cache: PyInfoCache | None, target: PythonInfo, *, env: Mapping[str, str] | None = None - ) -> PythonInfo: - """ - Walk virtualenv/venv prefix chains to find the underlying system interpreter. - - :param cache: interpreter metadata cache; when ``None`` results are not cached. - :param target: the interpreter to resolve. - :param env: environment mapping; defaults to :data:`os.environ`. - """ - start_executable = target.executable - prefixes = OrderedDict() - while target.system_executable is None: - prefix = target.real_prefix or target.base_prefix or target.prefix - prefix_key = normcase(realpath(prefix)) - if prefix_key in prefixes: - if len(prefixes) == 1: - _LOGGER.info("%r links back to itself via prefixes", target) - target.system_executable = target.executable - break - for at, (p, t) in enumerate(prefixes.items(), start=1): - _LOGGER.error("%d: prefix=%s, info=%r", at, p, t) - _LOGGER.error("%d: prefix=%s, info=%r", len(prefixes) + 1, prefix, target) - msg = "prefixes are causing a circle {}".format("|".join(prefixes.keys())) - raise RuntimeError(msg) - prefixes[prefix_key] = target - target = target.discover_exe(cache, prefix=prefix, exact=False, env=env) - if target.executable != target.system_executable: - resolved = cls.from_exe(target.system_executable, cache, env=env) - if resolved is not None: - target = resolved - if target.executable != start_executable: - # the cache hands every lookup of this base the same object, so point a copy at the start executable - target = copy.copy(target) - target.executable = start_executable - return target - - _cache_exe_discovery: ClassVar[dict[tuple[str, bool, VersionInfo], PythonInfo]] = {} - def discover_exe( self, cache: PyInfoCache, @@ -512,6 +572,8 @@ def discover_exe( :param prefix: directory prefix to search under. :param exact: when ``True``, require an exact version match. :param env: environment mapping; defaults to :data:`os.environ`. + :returns: + :raises RuntimeError: """ key = prefix, exact, self.version_info if key in self._cache_exe_discovery and prefix: @@ -520,8 +582,8 @@ def discover_exe( _LOGGER.debug("discover exe for %s in %s", self, prefix) possible_names = self._find_possible_exe_names() possible_folders = self._find_possible_folders(prefix) - discovered = [] env = os.environ if env is None else env + discovered = [] for folder in possible_folders: for name in possible_names: info = self._check_exe(cache, folder, name, discovered, env, exact=exact) @@ -537,6 +599,55 @@ def discover_exe( msg = "failed to detect {} in {}".format("|".join(possible_names), os.pathsep.join(possible_folders)) raise RuntimeError(msg) + def _find_possible_exe_names(self) -> list[str]: + name_candidate = OrderedDict() + mods = ["", "t"] if self.free_threaded else [""] + # _d (Windows python_d.exe), -dbg (Debian's own name), d (the abiflags form used by + # upstream/Fedora and also shipped by Debian, so pythonX.Yd / pythonX.Ytd resolve too) + debug_suffixes = ["_d", "-dbg", "d", ""] if self.debug_build else [""] + archs = [f"-{self.architecture}", ""] + for name in self._possible_base(): + for at in (3, 2, 1, 0): + version = ".".join(str(i) for i in self.version_info[:at]) + for mod, debug, arch, ext in product(mods, debug_suffixes, archs, EXTENSIONS): + name_candidate[f"{name}{version}{mod}{debug}{arch}{ext}"] = None + return list(name_candidate.keys()) + + def _possible_base(self) -> Generator[str, None, None]: + possible_base = OrderedDict() + possible_base[os.path.splitext(os.path.basename(self.executable))[0].rstrip(digits)] = None + possible_base[self.implementation] = None + # python is always the final option as in practice is used by multiple implementation as exe name + if "python" in possible_base: + del possible_base["python"] + possible_base["python"] = None + for base in possible_base: + lower = base.lower() + yield lower + from ._compat import fs_is_case_sensitive # ruff:ignore[import-outside-top-level] + + if fs_is_case_sensitive(): # pragma: no branch + if base != lower: + yield base + if (upper := base.upper()) != base: + yield upper + + def _find_possible_folders(self, inside_folder: str) -> list[str]: + candidate_folder = OrderedDict() + executables = OrderedDict() + executables[os.path.realpath(self.executable)] = None + executables[self.executable] = None + executables[os.path.realpath(self.original_executable)] = None + executables[self.original_executable] = None + for exe in executables: + base = os.path.dirname(exe) + if base.startswith(self.prefix): + candidate_folder[f"{inside_folder}{base[len(self.prefix) :]}"] = None + + # or at root level + candidate_folder[inside_folder] = None + return [i for i in candidate_folder if os.path.exists(i)] + def _check_exe( # ruff:ignore[too-many-arguments] self, cache: PyInfoCache | None, @@ -588,58 +699,6 @@ def sort_by(info: PythonInfo) -> int: sorted_discovered = sorted(discovered, key=sort_by, reverse=True) # sort by priority in decreasing order return sorted_discovered[0] - def _find_possible_folders(self, inside_folder: str) -> list[str]: - candidate_folder = OrderedDict() - executables = OrderedDict() - executables[os.path.realpath(self.executable)] = None - executables[self.executable] = None - executables[os.path.realpath(self.original_executable)] = None - executables[self.original_executable] = None - for exe in executables: - base = os.path.dirname(exe) - if base.startswith(self.prefix): - relative = base[len(self.prefix) :] - candidate_folder[f"{inside_folder}{relative}"] = None - - # or at root level - candidate_folder[inside_folder] = None - return [i for i in candidate_folder if os.path.exists(i)] - - def _find_possible_exe_names(self) -> list[str]: - name_candidate = OrderedDict() - mods = ["", "t"] if self.free_threaded else [""] - # _d (Windows python_d.exe), -dbg (Debian's own name), d (the abiflags form used by - # upstream/Fedora and also shipped by Debian, so pythonX.Yd / pythonX.Ytd resolve too) - debug_suffixes = ["_d", "-dbg", "d", ""] if self.debug_build else [""] - archs = [f"-{self.architecture}", ""] - for name in self._possible_base(): - for at in (3, 2, 1, 0): - version = ".".join(str(i) for i in self.version_info[:at]) - for mod, debug, arch, ext in product(mods, debug_suffixes, archs, EXTENSIONS): - name_candidate[f"{name}{version}{mod}{debug}{arch}{ext}"] = None - return list(name_candidate.keys()) - - def _possible_base(self) -> Generator[str, None, None]: - possible_base = OrderedDict() - basename = os.path.splitext(os.path.basename(self.executable))[0].rstrip(digits) - possible_base[basename] = None - possible_base[self.implementation] = None - # python is always the final option as in practice is used by multiple implementation as exe name - if "python" in possible_base: - del possible_base["python"] - possible_base["python"] = None - for base in possible_base: - lower = base.lower() - yield lower - from ._compat import fs_is_case_sensitive # ruff:ignore[import-outside-top-level] - - if fs_is_case_sensitive(): # pragma: no branch - if base != lower: - yield base - upper = base.upper() - if upper != base: - yield upper - class MarkerEnvironment(TypedDict): implementation_name: str @@ -655,7 +714,7 @@ class MarkerEnvironment(TypedDict): sys_platform: str -KNOWN_ARCHITECTURES: frozenset[str] = frozenset({ +KNOWN_ARCHITECTURES: Final[frozenset[str]] = frozenset({ "arm64", "loongarch64", "ppc", @@ -680,6 +739,9 @@ def normalize_isa(isa: str) -> str: Known aliases are mapped (e.g. ``amd64`` → ``x86_64``, ``aarch64`` → ``arm64``). Unrecognized values are lowercased and returned as-is. + + :param isa: + :returns: """ low = isa.lower() return { @@ -697,7 +759,9 @@ def normalize_isa(isa: str) -> str: __all__ = [ + "EXTENSIONS", "KNOWN_ARCHITECTURES", + "InfoValue", "MarkerEnvironment", "PythonInfo", "VersionInfo", diff --git a/src/python_discovery/_py_info_collect.py b/src/python_discovery/_py_info_collect.py index 58cbbd1..9aab788 100644 --- a/src/python_discovery/_py_info_collect.py +++ b/src/python_discovery/_py_info_collect.py @@ -39,8 +39,7 @@ class PythonInfoCollector: - """Collects information about the currently running Python interpreter.""" - + # Collects information about the currently running Python interpreter. def __init__(self): self._init_identity() self._init_prefixes() @@ -113,123 +112,8 @@ def abs_path(value): self.file_system_encoding = sys.getfilesystemencoding() self.stdout_encoding = getattr(sys.stdout, "encoding", None) - def _init_schemes(self): - scheme_names = sysconfig.get_scheme_names() - - if "venv" in scheme_names: # pragma: >=3.11 cover - self.sysconfig_scheme = "venv" - self.sysconfig_paths = { - i: sysconfig.get_path(i, expand=False, scheme=self.sysconfig_scheme) for i in sysconfig.get_path_names() - } - self.distutils_install = {} - # debian / ubuntu python 3.10 without `python3-distutils` will report mangled `local/bin` / etc. names - elif sys.version_info[:2] == (3, 10) and "deb_system" in scheme_names: # pragma: no cover # Debian/Ubuntu 3.10 - self.sysconfig_scheme = "posix_prefix" - self.sysconfig_paths = { - i: sysconfig.get_path(i, expand=False, scheme=self.sysconfig_scheme) for i in sysconfig.get_path_names() - } - self.distutils_install = {} - else: # pragma: no cover # "venv" scheme always present on Python 3.12+ - self.sysconfig_scheme = None - self.sysconfig_paths = {i: sysconfig.get_path(i, expand=False) for i in sysconfig.get_path_names()} - self.distutils_install = self._distutils_install().copy() - - def _init_sysconfig(self): - makefile = getattr(sysconfig, "get_makefile_filename", getattr(sysconfig, "_get_makefile_filename", None)) - self.sysconfig = { - k: v - for k, v in [ - ("makefile_filename", makefile() if makefile is not None else None), - ] - if k is not None - } - - config_var_keys = set() - for element in self.sysconfig_paths.values(): - config_var_keys.update(k[1:-1] for k in _CONF_VAR_RE.findall(element)) - config_var_keys.add("PYTHONFRAMEWORK") - config_var_keys.update(("Py_ENABLE_SHARED", "INSTSONAME", "LIBDIR")) - - self.sysconfig_vars = {i: sysconfig.get_config_var(i or "") for i in config_var_keys} - - if "TCL_LIBRARY" in os.environ: - self.tcl_lib, self.tk_lib = self._get_tcl_tk_libs() - else: - self.tcl_lib, self.tk_lib = None, None - - system_prefix = self.real_prefix or self.base_prefix or self.prefix - confs = { - k: (system_prefix if isinstance(v, str) and v.startswith(self.prefix) else v) - for k, v in self.sysconfig_vars.items() - } - self.system_stdlib = self._sysconfig_path("stdlib", confs) - self.system_stdlib_platform = self._sysconfig_path("platstdlib", confs) - self.max_size = getattr(sys, "maxsize", getattr(sys, "maxint", None)) - self._creators = None # virtualenv-specific, set via monkey-patch - - def _sysconfig_path(self, key, config_var): - pattern = self.sysconfig_paths.get(key) - if pattern is None: # custom builds may ship schemes without stdlib / platstdlib - return "" - base = self.sysconfig_vars.copy() - base.update(config_var) - return pattern.format(**base).replace("/", os.sep) - - @staticmethod - def _get_tcl_tk_libs(): # pragma: no cover # tkinter availability varies; tested indirectly via __init__ - """Detect the tcl and tk libraries using tkinter.""" - tcl_lib, tk_lib = None, None - try: - import tkinter as tk # ruff:ignore[import-outside-top-level] # novermin # unreached below the version gate - except ImportError: - pass - else: - # Tk.__init__ reads profile scripts from HOME or the working directory; probing needs only library metadata. - class _TclWithoutProfile(tk.Tk): - def readprofile(self, *_args, **_kwargs): # ruff:ignore[no-self-use] # Tk override must be an instance method. - return None - - try: - tcl = _TclWithoutProfile(useTk=False) - tcl_lib = tcl.eval("info library") - tk_lib = PythonInfoCollector._resolve_tk_lib(tcl, tcl_lib) - except tk.TclError: - pass - - return tcl_lib, tk_lib - - @staticmethod - def _query_tk_library(tcl): # pragma: no cover - """Try to get the TK library path directly from Tcl.""" - import tkinter as tk # ruff:ignore[import-outside-top-level] # novermin # unreached below the version gate - - try: - tk_lib = tcl.eval("set tk_library") - if tk_lib and os.path.isdir(tk_lib): - return tk_lib - except tk.TclError: - pass - return None - - @staticmethod - def _resolve_tk_lib(tcl, tcl_lib): # pragma: no cover - """Resolve the TK library path by direct query or path construction.""" - tk_lib = PythonInfoCollector._query_tk_library(tcl) - if tk_lib is not None: - return tk_lib - import _tkinter # ruff:ignore[import-outside-top-level,import-private-name] # novermin # linked Tk version - - # Loading Tk can block on DISPLAY; the linked version is enough to locate its library. - tk_version = _tkinter.TK_VERSION - tcl_parent = os.path.dirname(tcl_lib) - for version in (tk_version, tk_version.split(".")[0]): - tk_lib_path = os.path.join(tcl_parent, "tk{}".format(version)) - if os.path.isdir(tk_lib_path) and os.path.exists(os.path.join(tk_lib_path, "tk.tcl")): - return tk_lib_path - return None - def _fast_get_system_executable(self): - """Try to get the system executable by just looking at properties.""" + # Try to get the system executable by just looking at properties. # Preserve the invoked path because shims may rely on it. if not (self.real_prefix or (self.base_prefix is not None and self.base_prefix != self.prefix)): return self._resolve_executable_symlink(self.original_executable) @@ -260,14 +144,12 @@ def _fast_get_system_executable(self): return self._try_posix_fallback_executable(base_executable) # pragma: >=3.11 cover def _resolve_executable_symlink(self, path): - """ - Resolve symlinks of the executable itself, but never of its parent directories. - - Mirrors CPython's ``getpath.realpath`` (and ``venv`` in python/cpython#115237): an executable-only symlink - resolves to the real interpreter so its home can be located, while a fully symlinked interpreter tree is - kept as-is. Like ``getpath``, resolution stops as soon as the stdlib landmark is reachable from the current - directory - an alias such as Debian's ``/usr/bin/python3`` is a usable home and stays untouched. - """ + # Resolve symlinks of the executable itself, but never of its parent directories. + # + # Mirrors CPython's ``getpath.realpath`` (and ``venv`` in python/cpython#115237): an executable-only symlink + # resolves to the real interpreter so its home can be located, while a fully symlinked interpreter tree is + # kept as-is. Like ``getpath``, resolution stops as soon as the stdlib landmark is reachable from the current + # directory - an alias such as Debian's ``/usr/bin/python3`` is a usable home and stays untouched. result = os.path.abspath(path) if self.os != "posix": # pragma: win32 cover # CPython only does this where HAVE_READLINK return result @@ -294,20 +176,15 @@ def _stdlib_landmark_exists(dir_path): os.path.exists(os.path.join(dir_path, os.pardir, lib, lib_name, "os.py")) for lib in ("lib", "lib64") ) - def _try_posix_fallback_executable(self, base_executable): - """Find a versioned Python binary as fallback for POSIX virtual environments.""" - versioned = self._try_posix_versioned_executable(base_executable) - if versioned is not None: - return versioned - + def _try_posix_versioned_executable(self, base_executable): major, minor = self.version_info.major, self.version_info.minor if self.os != "posix" or (major, minor) < (3, 11): return None base_dir = os.path.dirname(base_executable) - candidates = ["python{}".format(major)] + candidates = ["python{}.{}".format(major, minor)] if self.implementation == "PyPy": - candidates.extend(["pypy", "pypy3", "pypy{}".format(major)]) + candidates.insert(0, "pypy{}.{}".format(major, minor)) for candidate in candidates: full_path = os.path.join(base_dir, candidate) @@ -316,15 +193,20 @@ def _try_posix_fallback_executable(self, base_executable): return None - def _try_posix_versioned_executable(self, base_executable): + def _try_posix_fallback_executable(self, base_executable): + # Find a versioned Python binary as fallback for POSIX virtual environments. + versioned = self._try_posix_versioned_executable(base_executable) + if versioned is not None: + return versioned + major, minor = self.version_info.major, self.version_info.minor if self.os != "posix" or (major, minor) < (3, 11): return None base_dir = os.path.dirname(base_executable) - candidates = ["python{}.{}".format(major, minor)] + candidates = ["python{}".format(major)] if self.implementation == "PyPy": - candidates.insert(0, "pypy{}.{}".format(major, minor)) + candidates.extend(["pypy", "pypy3", "pypy{}".format(major)]) for candidate in candidates: full_path = os.path.join(base_dir, candidate) @@ -333,6 +215,27 @@ def _try_posix_versioned_executable(self, base_executable): return None + def _init_schemes(self): + scheme_names = sysconfig.get_scheme_names() + + if "venv" in scheme_names: # pragma: >=3.11 cover + self.sysconfig_scheme = "venv" + self.sysconfig_paths = { + i: sysconfig.get_path(i, expand=False, scheme=self.sysconfig_scheme) for i in sysconfig.get_path_names() + } + self.distutils_install = {} + # debian / ubuntu python 3.10 without `python3-distutils` will report mangled `local/bin` / etc. names + elif sys.version_info[:2] == (3, 10) and "deb_system" in scheme_names: # pragma: no cover # Debian/Ubuntu 3.10 + self.sysconfig_scheme = "posix_prefix" + self.sysconfig_paths = { + i: sysconfig.get_path(i, expand=False, scheme=self.sysconfig_scheme) for i in sysconfig.get_path_names() + } + self.distutils_install = {} + else: # pragma: no cover # "venv" scheme always present on Python 3.12+ + self.sysconfig_scheme = None + self.sysconfig_paths = {i: sysconfig.get_path(i, expand=False) for i in sysconfig.get_path_names()} + self.distutils_install = self._distutils_install().copy() + @staticmethod def _distutils_install(): # pragma: <3.11 cover # 3.11+ uses the "venv" scheme instead # use distutils primarily because that's what pip does @@ -363,8 +266,102 @@ def _distutils_install(): # pragma: <3.11 cover # 3.11+ uses the "venv" scheme install.finalize_options() return {key: (getattr(install, "install_{}".format(key))[1:]).lstrip(os.sep) for key in SCHEME_KEYS} + def _init_sysconfig(self): + makefile = getattr(sysconfig, "get_makefile_filename", getattr(sysconfig, "_get_makefile_filename", None)) + self.sysconfig = { + k: v + for k, v in [ + ("makefile_filename", makefile() if makefile is not None else None), + ] + if k is not None + } + + config_var_keys = set() + for element in self.sysconfig_paths.values(): + config_var_keys.update(k[1:-1] for k in _CONF_VAR_RE.findall(element)) + config_var_keys.add("PYTHONFRAMEWORK") + config_var_keys.update(("Py_ENABLE_SHARED", "INSTSONAME", "LIBDIR")) + + self.sysconfig_vars = {i: sysconfig.get_config_var(i or "") for i in config_var_keys} + + if "TCL_LIBRARY" in os.environ: + self.tcl_lib, self.tk_lib = self._get_tcl_tk_libs() + else: + self.tcl_lib, self.tk_lib = None, None + + system_prefix = self.real_prefix or self.base_prefix or self.prefix + confs = { + k: (system_prefix if isinstance(v, str) and v.startswith(self.prefix) else v) + for k, v in self.sysconfig_vars.items() + } + self.system_stdlib = self._sysconfig_path("stdlib", confs) + self.system_stdlib_platform = self._sysconfig_path("platstdlib", confs) + self.max_size = getattr(sys, "maxsize", getattr(sys, "maxint", None)) + self._creators = None # virtualenv-specific, set via monkey-patch + + @staticmethod + def _get_tcl_tk_libs(): # pragma: no cover # tkinter availability varies; tested indirectly via __init__ + # Detect the tcl and tk libraries using tkinter. + tcl_lib, tk_lib = None, None + try: + import tkinter as tk # ruff:ignore[import-outside-top-level] # novermin # unreached below the version gate + except ImportError: + pass + else: + # Tk.__init__ reads profile scripts from HOME or the working directory; probing needs only library metadata. + class _TclWithoutProfile(tk.Tk): + def readprofile(self, *_args, **_kwargs): # ruff:ignore[no-self-use] # Tk override must be an instance method. + return None + + try: + tcl = _TclWithoutProfile(useTk=False) + tcl_lib = tcl.eval("info library") + tk_lib = PythonInfoCollector._resolve_tk_lib(tcl, tcl_lib) + except tk.TclError: + pass + + return tcl_lib, tk_lib + + @staticmethod + def _resolve_tk_lib(tcl, tcl_lib): # pragma: no cover + # Resolve the TK library path by direct query or path construction. + tk_lib = PythonInfoCollector._query_tk_library(tcl) + if tk_lib is not None: + return tk_lib + import _tkinter # ruff:ignore[import-outside-top-level,import-private-name] # novermin # linked Tk version + + # Loading Tk can block on DISPLAY; the linked version is enough to locate its library. + tk_version = _tkinter.TK_VERSION + tcl_parent = os.path.dirname(tcl_lib) + for version in (tk_version, tk_version.split(".")[0]): + tk_lib_path = os.path.join(tcl_parent, "tk{}".format(version)) + if os.path.isdir(tk_lib_path) and os.path.exists(os.path.join(tk_lib_path, "tk.tcl")): + return tk_lib_path + return None + + @staticmethod + def _query_tk_library(tcl): # pragma: no cover + # Try to get the TK library path directly from Tcl. + import tkinter as tk # ruff:ignore[import-outside-top-level] # novermin # unreached below the version gate + + try: + tk_lib = tcl.eval("set tk_library") + if tk_lib and os.path.isdir(tk_lib): + return tk_lib + except tk.TclError: + pass + return None + + def _sysconfig_path(self, key, config_var): + pattern = self.sysconfig_paths.get(key) + if pattern is None: # custom builds may ship schemes without stdlib / platstdlib + return "" + base = self.sysconfig_vars.copy() + base.update(config_var) + return pattern.format(**base).replace("/", os.sep) + def to_dict(self): - """Convert the collected information to a plain dictionary.""" + # Convert the collected information to a plain dictionary. data = {var: (getattr(self, var) if var != "_creators" else None) for var in vars(self)} data["version_info"] = self.version_info._asdict() return data @@ -394,6 +391,7 @@ def _main(): # pragma: no cover # exercised via subprocess runs __all__ = [ "MIN_INTERROGATE_VERSION", + "UNSUPPORTED_EXIT_CODE", "UNSUPPORTED_MARKER", "PythonInfoCollector", ] diff --git a/src/python_discovery/_py_spec.py b/src/python_discovery/_py_spec.py index ca17bfc..9bf8765 100644 --- a/src/python_discovery/_py_spec.py +++ b/src/python_discovery/_py_spec.py @@ -16,7 +16,7 @@ (?:-(?P[a-zA-Z0-9_.]+))? # ISA (e.g. arm64, x86_64, i86pc.64bit) $ """ -PATTERN = re.compile( +_PATTERN = re.compile( r""" ^ (?P[a-zA-Z]+)? # implementation (e.g. cpython, pypy) @@ -25,7 +25,7 @@ + _MODIFIERS, re.VERBOSE, ) -SPECIFIER_PATTERN = re.compile( +_SPECIFIER_PATTERN = re.compile( r""" ^ (?:(?P[A-Za-z]+)\s*)? # optional implementation prefix @@ -63,13 +63,11 @@ def _parse_version_parts(version: str) -> tuple[int | None, int | None, int | No if len(versions) == 2: # ruff:ignore[magic-value-comparison] return versions[0], versions[1], None version_data = versions[0] - major = int(str(version_data)[0]) - minor = int(str(version_data)[1:]) if version_data > _SINGLE_DIGIT_MAX else None - return major, minor, None + return int(str(version_data)[0]), (int(str(version_data)[1:]) if version_data > _SINGLE_DIGIT_MAX else None), None def _parse_spec_pattern(string_spec: str) -> PythonSpec | None: - if not (match := re.match(PATTERN, string_spec)): + if not (match := re.match(_PATTERN, string_spec)): return None release, threaded = (None, None, None), None if (version := match["version"]) is not None: @@ -108,7 +106,7 @@ def _spec_from_match( def _parse_specifier(string_spec: str) -> PythonSpec | None: - if not (match := SPECIFIER_PATTERN.match(string_spec.strip())): + if not (match := _SPECIFIER_PATTERN.match(string_spec.strip())): return None spec_text = match["spec"].strip() version_specifier = SpecifierSet.from_string(spec_text) @@ -180,6 +178,7 @@ def from_string_spec(cls, string_spec: str) -> PythonSpec: :param string_spec: an interpreter spec — an absolute path, a version string, an implementation prefix, or a `version specifier `_. + :returns: :raises ValueError: if the spec starts with a version specifier operator but does not parse as one. """ if pathlib.Path(string_spec).is_absolute(): @@ -190,6 +189,22 @@ def from_string_spec(cls, string_spec: str) -> PythonSpec: return result return cls(string_spec, None, None, None, None, None, string_spec) + def __repr__(self) -> str: + name = type(self).__name__ + params = ( + "implementation", + "major", + "minor", + "micro", + "architecture", + "machine", + "path", + "free_threaded", + "debug", + "version_specifier", + ) + return f"{name}({', '.join(f'{k}={getattr(self, k)}' for k in params if getattr(self, k) is not None)})" + def generate_re(self, *, windows: bool, all_implementations: bool = False) -> re.Pattern: """ Generate a regular expression for matching interpreter filenames. @@ -199,6 +214,7 @@ def generate_re(self, *, windows: bool, all_implementations: bool = False) -> re filename in :data:`KNOWN_IMPLEMENTATIONS` instead of only ``python``. A free-threaded or debug build the spec leaves open also matches its marked name, such as ``python3.13t`` or ``python3.13-dbg``. Used by enumeration APIs. + :returns: """ version = r"{}(\.{}(\.{})?)?".format( *(r"\d+" if v is None else v for v in (self.major, self.minor, self.micro)), @@ -219,13 +235,41 @@ def generate_re(self, *, windows: bool, all_implementations: bool = False) -> re flags=re.IGNORECASE, ) + def satisfies(self, spec: PythonSpec) -> bool: # ruff:ignore[too-many-return-statements] + """ + Check if this spec is compatible with the given *spec* (e.g. PEP-514 on Windows). + + :param spec: the requirement to check against. + :returns: + """ + if spec.is_abs and self.is_abs and self.path != spec.path: + return False + if ( + spec.implementation is not None + and self.implementation is not None + and spec.implementation.lower() != self.implementation.lower() + ): + return False + if spec.architecture is not None and spec.architecture != self.architecture: + return False + if spec.machine is not None and self.machine is not None and spec.machine != self.machine: + return False + if spec.free_threaded is not None and spec.free_threaded != self.free_threaded: + return False + if spec.version_specifier is not None and not self._check_version_specifier(spec): + return False + return all( + req is None or our is None or our == req + for our, req in zip((self.major, self.minor, self.micro), (spec.major, spec.minor, spec.micro)) + ) + @property def is_abs(self) -> bool: """``True`` if the spec refers to an absolute filesystem path.""" return self.path is not None and pathlib.Path(self.path).is_absolute() def _check_version_specifier(self, spec: PythonSpec) -> bool: - """Check if version specifier is satisfied.""" + # Check if version specifier is satisfied. components: list[int] = [] for part in (self.major, self.minor, self.micro): if part is None: @@ -260,55 +304,8 @@ def _get_required_precision(item: SimpleSpecifier) -> int | None: return item.wildcard_precision return re.split(r"a|b|rc", item.version_str, maxsplit=1)[0].count(".") + 1 - def satisfies(self, spec: PythonSpec) -> bool: # ruff:ignore[too-many-return-statements] - """ - Check if this spec is compatible with the given *spec* (e.g. PEP-514 on Windows). - - :param spec: the requirement to check against. - """ - if spec.is_abs and self.is_abs and self.path != spec.path: - return False - if ( - spec.implementation is not None - and self.implementation is not None - and spec.implementation.lower() != self.implementation.lower() - ): - return False - if spec.architecture is not None and spec.architecture != self.architecture: - return False - if spec.machine is not None and self.machine is not None and spec.machine != self.machine: - return False - if spec.free_threaded is not None and spec.free_threaded != self.free_threaded: - return False - if spec.version_specifier is not None and not self._check_version_specifier(spec): - return False - return all( - req is None or our is None or our == req - for our, req in zip((self.major, self.minor, self.micro), (spec.major, spec.minor, spec.micro)) - ) - - def __repr__(self) -> str: - name = type(self).__name__ - params = ( - "implementation", - "major", - "minor", - "micro", - "architecture", - "machine", - "path", - "free_threaded", - "debug", - "version_specifier", - ) - return f"{name}({', '.join(f'{k}={getattr(self, k)}' for k in params if getattr(self, k) is not None)})" - __all__ = [ "KNOWN_IMPLEMENTATIONS", - "InvalidSpecifier", - "InvalidVersion", "PythonSpec", - "SpecifierSet", - "Version", ] diff --git a/src/python_discovery/_specifier.py b/src/python_discovery/_specifier.py index 89ce8cd..15a489d 100644 --- a/src/python_discovery/_specifier.py +++ b/src/python_discovery/_specifier.py @@ -37,10 +37,9 @@ """, re.VERBOSE, ) -_PRE_ORDER: Final[dict[str, int]] = {"a": 1, "b": 2, "rc": 3} -@dataclass(**DC_KW) +@dataclass(**DC_KW, frozen=True) class SimpleVersion: """ Simple PEP 440-like version parser using only standard library. @@ -68,6 +67,8 @@ def from_string(cls, version_str: str) -> SimpleVersion: Parse a PEP 440 version string (e.g. ``3.12.1``). :param version_str: the version string to parse. + :returns: + :raises ValueError: """ stripped = version_str.strip() if not (match := _VERSION_RE.match(stripped)): @@ -105,8 +106,9 @@ def __lt__(self, other: object) -> bool: # ruff:ignore[too-many-return-statemen return False if other.pre_type is None: return True - if _PRE_ORDER[self.pre_type] != _PRE_ORDER[other.pre_type]: - return _PRE_ORDER[self.pre_type] < _PRE_ORDER[other.pre_type] + pre_order: dict[str, int] = {"a": 1, "b": 2, "rc": 3} + if pre_order[self.pre_type] != pre_order[other.pre_type]: + return pre_order[self.pre_type] < pre_order[other.pre_type] return (self.pre_num or 0) < (other.pre_num or 0) def __le__(self, other: object) -> bool: @@ -127,7 +129,7 @@ def __repr__(self) -> str: return f"SimpleVersion('{self.version_str}')" -@dataclass(**DC_KW) +@dataclass(**DC_KW, frozen=True) class SimpleSpecifier: """ Simple PEP 440-like version specifier using only standard library. @@ -153,6 +155,8 @@ def from_string(cls, spec_str: str) -> SimpleSpecifier: Parse a single PEP 440 specifier (e.g. ``>=3.10``). :param spec_str: the specifier string to parse. + :returns: + :raises ValueError: """ stripped = spec_str.strip() if not (match := _SPECIFIER_RE.match(stripped)): @@ -178,11 +182,26 @@ def from_string(cls, spec_str: str) -> SimpleSpecifier: version=version, ) + def __eq__(self, other: object) -> bool: + if not isinstance(other, SimpleSpecifier): + return NotImplemented + return self.spec_str == other.spec_str + + def __hash__(self) -> int: + return hash(self.spec_str) + + def __str__(self) -> str: + return self.spec_str + + def __repr__(self) -> str: + return f"SimpleSpecifier('{self.spec_str}')" + def contains(self, version_str: str) -> bool: """ Check if a version string satisfies this specifier. :param version_str: the version string to test. + :returns: """ try: candidate = SimpleVersion.from_string(version_str) if isinstance(version_str, str) else version_str @@ -240,22 +259,8 @@ def _check_compatible_release(self, candidate: SimpleVersion) -> bool: prefix = max(self.version_str.count("."), 1) return candidate.release[:prefix] == self.version.release[:prefix] - def __eq__(self, other: object) -> bool: - if not isinstance(other, SimpleSpecifier): - return NotImplemented - return self.spec_str == other.spec_str - - def __hash__(self) -> int: - return hash(self.spec_str) - - def __str__(self) -> str: - return self.spec_str - - def __repr__(self) -> str: - return f"SimpleSpecifier('{self.spec_str}')" - -@dataclass(**DC_KW) +@dataclass(**DC_KW, frozen=True) class SimpleSpecifierSet: """ Simple PEP 440-like specifier set using only standard library. @@ -273,27 +278,17 @@ def from_string(cls, specifiers_str: str = "") -> SimpleSpecifierSet: Parse a comma-separated PEP 440 specifier string (e.g. ``>=3.10,<4``). :param specifiers_str: the specifier string to parse. + :returns: """ stripped = specifiers_str.strip() specs: list[SimpleSpecifier] = [] if stripped: for spec_item in stripped.split(","): - item = spec_item.strip() - if item: + if item := spec_item.strip(): with contextlib.suppress(ValueError): specs.append(SimpleSpecifier.from_string(item)) return cls(specifiers_str=stripped, specifiers=tuple(specs)) - def contains(self, version_str: str) -> bool: - """ - Check if a version satisfies all specifiers in the set. - - :param version_str: the version string to test. - """ - if not self.specifiers: - return True - return all(spec.contains(version_str) for spec in self.specifiers) - def __iter__(self) -> Iterator[SimpleSpecifier]: return iter(self.specifiers) @@ -311,6 +306,17 @@ def __str__(self) -> str: def __repr__(self) -> str: return f"SimpleSpecifierSet('{self.specifiers_str}')" + def contains(self, version_str: str) -> bool: + """ + Check if a version satisfies all specifiers in the set. + + :param version_str: the version string to test. + :returns: + """ + if not self.specifiers: + return True + return all(spec.contains(version_str) for spec in self.specifiers) + __all__ = [ "DC_KW", diff --git a/src/python_discovery/_windows/_cancel_io.py b/src/python_discovery/_windows/_cancel_io.py index 7816bc7..499a9cf 100644 --- a/src/python_discovery/_windows/_cancel_io.py +++ b/src/python_discovery/_windows/_cancel_io.py @@ -48,4 +48,6 @@ def _cancel_synchronous_io(thread_id: int) -> None: raise WinError(get_last_error()) -__all__ = ["stop_query_reader"] +__all__ = [ + "stop_query_reader", +] diff --git a/src/python_discovery/_windows/_pep514.py b/src/python_discovery/_windows/_pep514.py index 6f094dd..a8234f7 100644 --- a/src/python_discovery/_windows/_pep514.py +++ b/src/python_discovery/_windows/_pep514.py @@ -48,7 +48,7 @@ ) -def enum_keys(key: Any) -> Generator[str, None, None]: # ruff:ignore[any-type] +def _enum_keys(key: Any) -> Generator[str, None, None]: # ruff:ignore[any-type] at = 0 while True: try: @@ -71,10 +71,10 @@ def discover_pythons() -> Generator[_RegistrySpec, None, None]: (winreg.HKEY_LOCAL_MACHINE, "HKEY_LOCAL_MACHINE", r"Software\Python", winreg.KEY_WOW64_64KEY, 64), # ty: ignore[unresolved-attribute] (winreg.HKEY_LOCAL_MACHINE, "HKEY_LOCAL_MACHINE", r"Software\Python", winreg.KEY_WOW64_32KEY, 32), # ty: ignore[unresolved-attribute] ]: - yield from process_set(hive, hive_name, key, flags, default_arch) + yield from _process_set(hive, hive_name, key, flags, default_arch) -def process_set( +def _process_set( hive: int, hive_name: str, key: str, @@ -83,39 +83,36 @@ def process_set( ) -> Generator[_RegistrySpec, None, None]: try: with winreg.OpenKeyEx(hive, key, 0, winreg.KEY_READ | flags) as root_key: # ty: ignore[unresolved-attribute] - for company in enum_keys(root_key): + for company in _enum_keys(root_key): if company == "PyLauncher": # reserved continue - yield from process_company(hive_name, company, root_key, default_arch) + yield from _process_company(hive_name, company, root_key, default_arch) except OSError: pass -def process_company( +def _process_company( hive_name: str, company: str, root_key: Any, # ruff:ignore[any-type] default_arch: int, ) -> Generator[_RegistrySpec, None, None]: with winreg.OpenKeyEx(root_key, company) as company_key: # ty: ignore[unresolved-attribute] - for tag in enum_keys(company_key): - spec = process_tag(hive_name, company, company_key, tag, default_arch) - if spec is not None: + for tag in _enum_keys(company_key): + if (spec := _process_tag(hive_name, company, company_key, tag, default_arch)) is not None: yield spec -def process_tag(hive_name: str, company: str, company_key: Any, tag: str, default_arch: int) -> _RegistrySpec | None: # ruff:ignore[any-type] +def _process_tag(hive_name: str, company: str, company_key: Any, tag: str, default_arch: int) -> _RegistrySpec | None: # ruff:ignore[any-type] with winreg.OpenKeyEx(company_key, tag) as tag_key: # ty: ignore[unresolved-attribute] - version = load_version_data(hive_name, company, tag, tag_key) - if version is not None: # if failed to get version bail + if ( + version := _load_version_data(hive_name, company, tag, tag_key) + ) is not None: # if failed to get version bail major, minor, _ = version - arch = load_arch_data(hive_name, company, tag, tag_key, default_arch) - if arch is not None: - exe_data = load_exe(hive_name, company, company_key, tag) - if exe_data is not None: + if (arch := _load_arch_data(hive_name, company, tag, tag_key, default_arch)) is not None: + if (exe_data := load_exe(hive_name, company, company_key, tag)) is not None: exe, args = exe_data - threaded = load_threaded(hive_name, company, tag, tag_key) - return company, major, minor, arch, threaded, exe, args + return company, major, minor, arch, _load_threaded(hive_name, company, tag, tag_key), exe, args return None return None return None @@ -128,35 +125,34 @@ def load_exe(hive_name: str, company: str, company_key: Any, tag: str) -> tuple[ if (exe := _resolve_exe(ip_key, key_path)) is not None and os.path.exists(exe): if (args := get_value(ip_key, "ExecutableArguments")) is None or isinstance(args, str): return exe, args if args and args.strip() else None - msg(f"{key_path}/ExecutableArguments", f"expected a string, got {type(args).__name__}") + _msg(f"{key_path}/ExecutableArguments", f"expected a string, got {type(args).__name__}") return None - msg(key_path, f"could not load exe with value {exe}") + _msg(key_path, f"could not load exe with value {exe}") except OSError: - msg(f"{key_path}/InstallPath", "missing") + _msg(f"{key_path}/InstallPath", "missing") return None def _resolve_exe(ip_key: Any, key_path: str) -> str | None: # ruff:ignore[any-type] if (exe := get_value(ip_key, "ExecutablePath")) is None: if (exe := get_value(ip_key, None)) is None: - msg(key_path, "no ExecutablePath or default for it") + _msg(key_path, "no ExecutablePath or default for it") return None if isinstance(exe, str): exe = os.path.join(exe, "python.exe") if not isinstance(exe, str) or not PureWindowsPath(exe).is_absolute(): - msg(key_path, f"expected an absolute executable path, got {exe!r}") + _msg(key_path, f"expected an absolute executable path, got {exe!r}") return None return exe -def load_arch_data(hive_name: str, company: str, tag: str, tag_key: Any, default_arch: int) -> int | None: # ruff:ignore[any-type] - arch_str = get_value(tag_key, "SysArchitecture") - if arch_str is not None: +def _load_arch_data(hive_name: str, company: str, tag: str, tag_key: Any, default_arch: int) -> int | None: # ruff:ignore[any-type] + if (arch_str := get_value(tag_key, "SysArchitecture")) is not None: key_path = f"{hive_name}/{company}/{tag}/SysArchitecture" try: return parse_arch(arch_str) except ValueError as sys_arch: - msg(key_path, sys_arch) + _msg(key_path, sys_arch) return default_arch @@ -170,7 +166,7 @@ def parse_arch(arch_str: Any) -> int: # ruff:ignore[any-type] raise ValueError(error) -def load_version_data( +def _load_version_data( hive_name: str, company: str, tag: str, @@ -184,7 +180,7 @@ def load_version_data( try: return parse_version(candidate) except ValueError as sys_version: - msg(key_path, sys_version) + _msg(key_path, sys_version) return None @@ -203,28 +199,33 @@ def parse_version(version_str: Any) -> tuple[int | None, int | None, int | None] raise ValueError(error) -def load_threaded(hive_name: str, company: str, tag: str, tag_key: Any) -> bool: # ruff:ignore[any-type] - display_name = get_value(tag_key, "DisplayName") - if display_name is not None: +def _load_threaded(hive_name: str, company: str, tag: str, tag_key: Any) -> bool: # ruff:ignore[any-type] + if (display_name := get_value(tag_key, "DisplayName")) is not None: if isinstance(display_name, str): if "freethreaded" in display_name.lower(): return True else: - key_path = f"{hive_name}/{company}/{tag}/DisplayName" - msg(key_path, f"display name is not string: {display_name!r}") + _msg(f"{hive_name}/{company}/{tag}/DisplayName", f"display name is not string: {display_name!r}") return bool(_THREADED_TAG_RE.match(tag)) -def msg(path: str, what: object) -> None: +def _msg(path: str, what: object) -> None: _LOGGER.warning("PEP-514 violation in Windows Registry at %s error: %s", path, what) def _run() -> None: basicConfig() - interpreters = [repr(spec) for spec in discover_pythons()] - sys.stdout.write("\n".join(sorted(interpreters))) + sys.stdout.write("\n".join(sorted([repr(spec) for spec in discover_pythons()]))) sys.stdout.write("\n") +__all__ = [ + "discover_pythons", + "load_exe", + "parse_arch", + "parse_version", +] + + if __name__ == "__main__": _run() diff --git a/src/python_discovery/_windows/_propose.py b/src/python_discovery/_windows/_propose.py index 57846cb..1585a6e 100644 --- a/src/python_discovery/_windows/_propose.py +++ b/src/python_discovery/_windows/_propose.py @@ -12,15 +12,6 @@ from python_discovery._cache import PyInfoCache -_IMPLEMENTATION_BY_ORG: dict[str, str] = { - "ContinuumAnalytics": "CPython", - "PythonCore": "CPython", -} - - -class Pep514PythonInfo(PythonInfo): - """A Python information acquired from PEP-514.""" - def propose_interpreters( spec: PythonSpec, @@ -36,8 +27,12 @@ def propose_interpreters( reverse=True, ) + implementation_by_org: dict[str, str] = { + "ContinuumAnalytics": "CPython", + "PythonCore": "CPython", + } for name, major, minor, arch, threaded, exe, executable_args in existing: - implementation = _IMPLEMENTATION_BY_ORG.get(name, name) + implementation = implementation_by_org.get(name, name) skip_pre_filter = implementation.lower() != "cpython" registry_spec = PythonSpec("", implementation, major, minor, None, arch, exe, free_threaded=threaded) @@ -53,6 +48,11 @@ def propose_interpreters( yield interpreter +class Pep514PythonInfo(PythonInfo): + # A Python information acquired from PEP-514. + ... + + __all__ = [ "Pep514PythonInfo", "propose_interpreters", diff --git a/tasks/old_target_check.py b/tasks/old_target_check.py index 9c0a21b..36e6d89 100644 --- a/tasks/old_target_check.py +++ b/tasks/old_target_check.py @@ -16,8 +16,7 @@ def main(version: str, mode: str) -> None: if info is None: msg = f"failed to discover Python {version}" raise SystemExit(msg) - found = f"{info.version_info.major}.{info.version_info.minor}" - if found != version: + if (found := f"{info.version_info.major}.{info.version_info.minor}") != version: msg = f"discovered {found} at {info.executable} instead of {version}" raise SystemExit(msg) sys.stdout.write(f"discovered Python {version} at {info.executable}\n") diff --git a/tests/py_info/test_py_info.py b/tests/py_info/test_py_info.py index 8d4420e..c60ad9e 100644 --- a/tests/py_info/test_py_info.py +++ b/tests/py_info/test_py_info.py @@ -23,15 +23,14 @@ from packaging.markers import Environment from pytest_mock import MockerFixture -IS_PYPY = PythonInfo.current_system().implementation == "PyPy" +_IS_PYPY = PythonInfo.current_system().implementation == "PyPy" -CURRENT = PythonInfo.current_system() +_CURRENT = PythonInfo.current_system() @pytest.mark.graalpy def test_current_as_json() -> None: - result = CURRENT.to_json() - parsed = json.loads(result) + parsed = json.loads(_CURRENT.to_json()) major, minor, micro, releaselevel, serial = sys.version_info free_threaded = sysconfig.get_config_var("Py_GIL_DISABLED") == 1 assert parsed["version_info"] == { @@ -79,37 +78,40 @@ def test_bad_exe_py_info_no_raise( "spec", [sys.executable] + [ - f"{impl}{'.'.join(str(i) for i in ver)}{'t' if CURRENT.free_threaded else ''}{arch}" + f"{impl}{'.'.join(str(i) for i in ver)}{'t' if _CURRENT.free_threaded else ''}{arch}" for impl, ver, arch in itertools.product( ( - [CURRENT.implementation] - + (["python"] if CURRENT.implementation == "CPython" else []) - + ([CURRENT.implementation.lower()] if CURRENT.implementation != CURRENT.implementation.lower() else []) + [_CURRENT.implementation] + + (["python"] if _CURRENT.implementation == "CPython" else []) + + ( + [_CURRENT.implementation.lower()] + if _CURRENT.implementation != _CURRENT.implementation.lower() + else [] + ) ), [sys.version_info[0 : i + 1] for i in range(3)], - ["", f"-{CURRENT.architecture}"], + ["", f"-{_CURRENT.architecture}"], ) ], ) def test_satisfy_py_info(spec: str) -> None: - parsed_spec = PythonSpec.from_string_spec(spec) - matches = CURRENT.satisfies(parsed_spec, impl_must_match=True) + matches = _CURRENT.satisfies(PythonSpec.from_string_spec(spec), impl_must_match=True) assert matches is True def test_satisfy_not_arch() -> None: parsed_spec = PythonSpec.from_string_spec( - f"{CURRENT.implementation}-{64 if CURRENT.architecture == 32 else 32}", + f"{_CURRENT.implementation}-{64 if _CURRENT.architecture == 32 else 32}", ) - matches = CURRENT.satisfies(parsed_spec, impl_must_match=True) + matches = _CURRENT.satisfies(parsed_spec, impl_must_match=True) assert matches is False def test_satisfy_not_threaded() -> None: parsed_spec = PythonSpec.from_string_spec( - f"{CURRENT.implementation}{CURRENT.version_info.major}{'' if CURRENT.free_threaded else 't'}", + f"{_CURRENT.implementation}{_CURRENT.version_info.major}{'' if _CURRENT.free_threaded else 't'}", ) - matches = CURRENT.satisfies(parsed_spec, impl_must_match=True) + matches = _CURRENT.satisfies(parsed_spec, impl_must_match=True) assert matches is False @@ -130,8 +132,7 @@ def _generate_not_match_current_interpreter_version() -> list[str]: @pytest.mark.parametrize("spec", _NON_MATCH_VER) def test_satisfy_not_version(spec: str) -> None: - parsed_spec = PythonSpec.from_string_spec(f"{CURRENT.implementation}{spec}") - matches = CURRENT.satisfies(parsed_spec, impl_must_match=True) + matches = _CURRENT.satisfies(PythonSpec.from_string_spec(f"{_CURRENT.implementation}{spec}"), impl_must_match=True) assert matches is False @@ -207,7 +208,7 @@ def test_system_executable_no_exact_match( caplog.set_level(logging.DEBUG) def _make_py_info(of: PyInfoMock) -> PythonInfo: - base = copy.deepcopy(CURRENT) + base = copy.deepcopy(_CURRENT) base.implementation = of.implementation base.version_info = of.version_info base.architecture = of.architecture @@ -220,7 +221,7 @@ def _make_py_info(of: PyInfoMock) -> PythonInfo: path = tmp_path / str(pos) path.write_text("", encoding="utf-8") py_info = _make_py_info(i) - system_executable = CURRENT.system_executable + system_executable = _CURRENT.system_executable assert system_executable is not None py_info.system_executable = system_executable py_info.executable = system_executable @@ -243,7 +244,7 @@ def func(exe_path: str, _cache: object = None, **_kwargs: object) -> PythonInfo: target_py_info.system_executable = None target_py_info.executable = str(tmp_path) mapped = target_py_info.resolve_to_system(session_cache, target_py_info) - assert mapped.system_executable == CURRENT.system_executable + assert mapped.system_executable == _CURRENT.system_executable found = discovered_with_path[mapped.base_executable] assert found is selected @@ -262,7 +263,7 @@ def test_discover_exe_validates_abi_flag( attr: str, tmp_path: Path, mocker: MockerFixture, session_cache: DiskCache ) -> None: def candidate(*, flag: bool, name: str) -> PythonInfo: - info = copy.deepcopy(CURRENT) + info = copy.deepcopy(_CURRENT) setattr(info, attr, flag) exe = tmp_path / name exe.write_text("", encoding="utf-8") @@ -302,40 +303,40 @@ def test_py_info_ignores_distutils_config(monkeypatch: pytest.MonkeyPatch, tmp_p def test_discover_exe_on_path_non_spec_name_match(mocker: MockerFixture) -> None: - suffixed_name = f"python{CURRENT.version_info.major}.{CURRENT.version_info.minor}m" + suffixed_name = f"python{_CURRENT.version_info.major}.{_CURRENT.version_info.minor}m" if sys.platform == "win32": # pragma: win32 cover - suffixed_name += Path(CURRENT.executable).suffix + suffixed_name += Path(_CURRENT.executable).suffix spec = PythonSpec.from_string_spec(suffixed_name) - mocker.patch.object(CURRENT, "executable", str(Path(CURRENT.executable).parent / suffixed_name)) - assert CURRENT.satisfies(spec, impl_must_match=True) is True + mocker.patch.object(_CURRENT, "executable", str(Path(_CURRENT.executable).parent / suffixed_name)) + assert _CURRENT.satisfies(spec, impl_must_match=True) is True def test_satisfies_debug_spec_requires_debug_build(mocker: MockerFixture) -> None: - spec = PythonSpec.from_string_spec(f"python{CURRENT.version_info.major}.{CURRENT.version_info.minor}-dbg") + spec = PythonSpec.from_string_spec(f"python{_CURRENT.version_info.major}.{_CURRENT.version_info.minor}-dbg") assert spec.debug is True - mocker.patch.object(CURRENT, "free_threaded", spec.free_threaded) + mocker.patch.object(_CURRENT, "free_threaded", spec.free_threaded) - mocker.patch.object(CURRENT, "debug_build", False) - assert CURRENT.satisfies(spec, impl_must_match=True) is False + mocker.patch.object(_CURRENT, "debug_build", False) + assert _CURRENT.satisfies(spec, impl_must_match=True) is False - mocker.patch.object(CURRENT, "debug_build", True) - assert CURRENT.satisfies(spec, impl_must_match=True) is True + mocker.patch.object(_CURRENT, "debug_build", True) + assert _CURRENT.satisfies(spec, impl_must_match=True) is True def test_discover_exe_on_path_non_spec_name_not_match(mocker: MockerFixture) -> None: - suffixed_name = f"python{CURRENT.version_info.major}.{CURRENT.version_info.minor}m" + suffixed_name = f"python{_CURRENT.version_info.major}.{_CURRENT.version_info.minor}m" if sys.platform == "win32": # pragma: win32 cover - suffixed_name += Path(CURRENT.original_executable).suffix + suffixed_name += Path(_CURRENT.original_executable).suffix spec = PythonSpec.from_string_spec(suffixed_name) mocker.patch.object( - CURRENT, + _CURRENT, "original_executable", - str(Path(CURRENT.executable).parent / f"e{suffixed_name}"), + str(Path(_CURRENT.executable).parent / f"e{suffixed_name}"), ) - assert CURRENT.satisfies(spec, impl_must_match=True) is False + assert _CURRENT.satisfies(spec, impl_must_match=True) is False -@pytest.mark.skipif(IS_PYPY, reason="setuptools distutils patching does not work") +@pytest.mark.skipif(_IS_PYPY, reason="setuptools distutils patching does not work") def test_py_info_setuptools() -> None: assert Distribution PythonInfo() @@ -359,18 +360,18 @@ def test_py_info_to_system_raises( # pragma: no cover # skipped in venv environ def test_sysconfig_vars_include_shared_lib_keys() -> None: for key in ("Py_ENABLE_SHARED", "INSTSONAME", "LIBDIR"): - assert key in CURRENT.sysconfig_vars + assert key in _CURRENT.sysconfig_vars def test_py_info_has_sysconfig_platform() -> None: - assert hasattr(CURRENT, "sysconfig_platform") - assert CURRENT.sysconfig_platform is not None - assert isinstance(CURRENT.sysconfig_platform, str) - assert len(CURRENT.sysconfig_platform) > 0 + assert hasattr(_CURRENT, "sysconfig_platform") + assert _CURRENT.sysconfig_platform is not None + assert isinstance(_CURRENT.sysconfig_platform, str) + assert len(_CURRENT.sysconfig_platform) > 0 def test_py_info_machine_property() -> None: - machine = CURRENT.machine + machine = _CURRENT.machine assert machine is not None assert isinstance(machine, str) assert len(machine) > 0 @@ -378,13 +379,13 @@ def test_py_info_machine_property() -> None: def test_py_info_machine_in_spec() -> None: - spec = CURRENT.spec - assert CURRENT.machine in spec - assert f"-{CURRENT.architecture}-{CURRENT.machine}" in spec + spec = _CURRENT.spec + assert _CURRENT.machine in spec + assert f"-{_CURRENT.architecture}-{_CURRENT.machine}" in spec def test_py_info_sysconfig_platform_matches_sysconfig() -> None: - assert CURRENT.sysconfig_platform == sysconfig.get_platform() + assert _CURRENT.sysconfig_platform == sysconfig.get_platform() @pytest.mark.parametrize( @@ -403,41 +404,40 @@ def test_py_info_sysconfig_platform_matches_sysconfig() -> None: ], ) def test_py_info_machine_derivation(platform: str, expected: str) -> None: - info = copy.deepcopy(CURRENT) + info = copy.deepcopy(_CURRENT) info.sysconfig_platform = platform assert info.machine == expected @pytest.mark.parametrize("runtime_isa", ["arm64", "x86_64"]) def test_py_info_machine_derivation_universal2(mocker: MockerFixture, runtime_isa: str) -> None: - info = copy.deepcopy(CURRENT) + info = copy.deepcopy(_CURRENT) info.sysconfig_platform = "macosx-11.0-universal2" mocker.patch("python_discovery._py_info.platform.machine", return_value=runtime_isa) assert info.machine == runtime_isa def test_py_info_satisfies_with_machine() -> None: - threaded = "t" if CURRENT.free_threaded else "" + threaded = "t" if _CURRENT.free_threaded else "" spec_str = ( - f"{CURRENT.implementation}{CURRENT.version_info.major}{threaded}-{CURRENT.architecture}-{CURRENT.machine}" + f"{_CURRENT.implementation}{_CURRENT.version_info.major}{threaded}-{_CURRENT.architecture}-{_CURRENT.machine}" ) parsed_spec = PythonSpec.from_string_spec(spec_str) - assert CURRENT.satisfies(parsed_spec, impl_must_match=True) is True + assert _CURRENT.satisfies(parsed_spec, impl_must_match=True) is True def test_py_info_satisfies_not_machine() -> None: - other_machine = "arm64" if CURRENT.machine != "arm64" else "x86_64" - spec_str = f"{CURRENT.implementation}-{CURRENT.architecture}-{other_machine}" - parsed_spec = PythonSpec.from_string_spec(spec_str) - assert CURRENT.satisfies(parsed_spec, impl_must_match=True) is False + other_machine = "arm64" if _CURRENT.machine != "arm64" else "x86_64" + parsed_spec = PythonSpec.from_string_spec(f"{_CURRENT.implementation}-{_CURRENT.architecture}-{other_machine}") + assert _CURRENT.satisfies(parsed_spec, impl_must_match=True) is False def test_py_info_satisfies_no_machine_in_spec() -> None: - threaded = "t" if CURRENT.free_threaded else "" - spec_str = f"{CURRENT.implementation}{CURRENT.version_info.major}{threaded}-{CURRENT.architecture}" + threaded = "t" if _CURRENT.free_threaded else "" + spec_str = f"{_CURRENT.implementation}{_CURRENT.version_info.major}{threaded}-{_CURRENT.architecture}" parsed_spec = PythonSpec.from_string_spec(spec_str) assert parsed_spec.machine is None - assert CURRENT.satisfies(parsed_spec, impl_must_match=True) is True + assert _CURRENT.satisfies(parsed_spec, impl_must_match=True) is True @pytest.mark.parametrize( @@ -448,7 +448,7 @@ def test_py_info_satisfies_no_machine_in_spec() -> None: ], ) def test_py_info_satisfies_machine_cross_os_normalization(platform: str, spec_machine: str) -> None: - info = copy.deepcopy(CURRENT) + info = copy.deepcopy(_CURRENT) info.sysconfig_platform = platform spec = PythonSpec.from_string_spec(f"{info.implementation}-{info.architecture}-{spec_machine}") assert info.satisfies(spec, impl_must_match=True) is True @@ -456,14 +456,14 @@ def test_py_info_satisfies_machine_cross_os_normalization(platform: str, spec_ma @pytest.mark.parametrize("debug", [True, False], ids=["debug", "release"]) def test_py_info_debug_build_in_spec(*, debug: bool) -> None: - info = copy.deepcopy(CURRENT) + info = copy.deepcopy(_CURRENT) info.debug_build = debug assert ("d-" in info.spec) is debug @pytest.mark.parametrize("debug", [True, False], ids=["debug", "release"]) def test_py_info_debug_build_exe_names(*, debug: bool) -> None: - info = copy.deepcopy(CURRENT) + info = copy.deepcopy(_CURRENT) info.debug_build = debug names = info._find_possible_exe_names() abiflag_name = f"python{info.version_info.major}.{info.version_info.minor}d" @@ -473,31 +473,30 @@ def test_py_info_debug_build_exe_names(*, debug: bool) -> None: def test_py_info_debug_build_json_round_trip() -> None: - info = copy.deepcopy(CURRENT) + info = copy.deepcopy(_CURRENT) info.debug_build = True restored = PythonInfo.from_json(info.to_json()) assert restored.debug_build is True def test_py_info_to_dict_includes_sysconfig_platform() -> None: - data = CURRENT.to_dict() + data = _CURRENT.to_dict() assert "sysconfig_platform" in data assert data["sysconfig_platform"] == sysconfig.get_platform() def test_py_info_to_dict_preserves_property_named_answer_key() -> None: - data = CURRENT.to_dict() + data = _CURRENT.to_dict() data["system_include"] = "supplied path" assert PythonInfo.from_dict(data).to_dict()["system_include"] == "supplied path" def test_py_info_json_round_trip() -> None: - json_str = CURRENT.to_json() + json_str = _CURRENT.to_json() parsed = json.loads(json_str) assert "sysconfig_platform" in parsed restored = PythonInfo.from_json(json_str) - assert restored.sysconfig_platform == CURRENT.sysconfig_platform - assert restored.machine == CURRENT.machine + assert (restored.sysconfig_platform, restored.machine) == (_CURRENT.sysconfig_platform, _CURRENT.machine) def test_marker_environment_matches_target_interpreter() -> None: @@ -533,7 +532,7 @@ def test_marker_environment_uses_target_platform(tmp_path: Path) -> None: def test_marker_environment_survives_json_round_trip() -> None: - assert PythonInfo.from_json(CURRENT.to_json()).marker_environment == CURRENT.marker_environment + assert PythonInfo.from_json(_CURRENT.to_json()).marker_environment == _CURRENT.marker_environment def test_marker_environment_formats_prerelease(mocker: MockerFixture) -> None: @@ -557,9 +556,9 @@ def test_select_most_likely_prefers_machine_match( discovered_platforms: list[str], expected_idx: int, ) -> None: - target = copy.deepcopy(CURRENT) + target = copy.deepcopy(_CURRENT) target.sysconfig_platform = target_platform - discovered = [copy.deepcopy(CURRENT) for _ in discovered_platforms] + discovered = [copy.deepcopy(_CURRENT) for _ in discovered_platforms] for d, plat in zip(discovered, discovered_platforms): d.sysconfig_platform = plat result = PythonInfo._select_most_likely(discovered, target) @@ -574,7 +573,7 @@ def test_select_most_likely_prefers_machine_match( ], ) def test_py_info_system_exe(system_executable: str | None, expected: str) -> None: - info = copy.deepcopy(CURRENT) + info = copy.deepcopy(_CURRENT) info.executable = "/venv/bin/python" info.system_executable = system_executable assert info.system_exe == expected diff --git a/tests/py_info/test_py_info_exe_based_of.py b/tests/py_info/test_py_info_exe_based_of.py index 21e19fc..22b3268 100644 --- a/tests/py_info/test_py_info_exe_based_of.py +++ b/tests/py_info/test_py_info_exe_based_of.py @@ -10,7 +10,7 @@ from python_discovery._discovery import IS_WIN from python_discovery._py_info import EXTENSIONS -CURRENT = PythonInfo.current() +_CURRENT = PythonInfo.current() def _fs_supports_symlink() -> bool: @@ -19,29 +19,29 @@ def _fs_supports_symlink() -> bool: def test_discover_empty_folder(tmp_path: Path, session_cache: DiskCache) -> None: with pytest.raises(RuntimeError): - CURRENT.discover_exe(session_cache, prefix=str(tmp_path)) + _CURRENT.discover_exe(session_cache, prefix=str(tmp_path)) def _discover_base_folders() -> tuple[str, ...]: - exe_dir = str(Path(CURRENT.executable).parent) + exe_dir = str(Path(_CURRENT.executable).parent) folders: dict[str, None] = {} - if exe_dir.startswith(CURRENT.prefix): # pragma: no branch - relative = exe_dir[len(CURRENT.prefix) :].lstrip("/\\") + if exe_dir.startswith(_CURRENT.prefix): # pragma: no branch + relative = exe_dir[len(_CURRENT.prefix) :].lstrip("/\\") if relative: # pragma: no branch folders[relative] = None folders["."] = None return tuple(folders) -BASE = _discover_base_folders() +_BASE = _discover_base_folders() @pytest.mark.skipif(not _fs_supports_symlink(), reason="symlink is not supported") @pytest.mark.parametrize("suffix", sorted({".exe", ""} & set(EXTENSIONS) if IS_WIN else [""])) -@pytest.mark.parametrize("into", BASE) -@pytest.mark.parametrize("arch", [CURRENT.architecture, ""]) -@pytest.mark.parametrize("version", [".".join(str(i) for i in CURRENT.version_info[0:i]) for i in range(3, 0, -1)]) -@pytest.mark.parametrize("impl", [CURRENT.implementation, "python"]) +@pytest.mark.parametrize("into", _BASE) +@pytest.mark.parametrize("arch", [_CURRENT.architecture, ""]) +@pytest.mark.parametrize("version", [".".join(str(i) for i in _CURRENT.version_info[0:i]) for i in range(3, 0, -1)]) +@pytest.mark.parametrize("impl", [_CURRENT.implementation, "python"]) def test_discover_ok( tmp_path: Path, suffix: str, @@ -56,18 +56,17 @@ def test_discover_ok( caplog.set_level(logging.DEBUG) folder = tmp_path / into folder.mkdir(parents=True, exist_ok=True) - name = f"{impl}{version}{'t' if CURRENT.free_threaded else ''}" + name = f"{impl}{version}{'t' if _CURRENT.free_threaded else ''}" if arch: name += f"-{arch}" name += suffix dest = folder / name - Path(str(dest)).symlink_to(CURRENT.executable) - pyvenv = Path(CURRENT.executable).parents[1] / "pyvenv.cfg" + Path(str(dest)).symlink_to(_CURRENT.executable) + pyvenv = Path(_CURRENT.executable).parents[1] / "pyvenv.cfg" if pyvenv.exists(): # pragma: no branch (folder / pyvenv.name).write_text(pyvenv.read_text(encoding="utf-8"), encoding="utf-8") inside_folder = str(tmp_path) - base = CURRENT.discover_exe(session_cache, inside_folder) - found = base.executable + found = _CURRENT.discover_exe(session_cache, inside_folder).executable dest_str = str(dest) if not fs_is_case_sensitive(): # pragma: win32 cover found = found.lower() @@ -76,7 +75,7 @@ def test_discover_ok( assert len(caplog.messages) >= 1, caplog.text assert "get interpreter info via cmd: " in caplog.text - dest.rename(dest.parent / (dest.name + "-1")) - CURRENT._cache_exe_discovery.clear() + dest.rename(dest.parent / (f"{dest.name}-1")) + _CURRENT._cache_exe_discovery.clear() with pytest.raises(RuntimeError): - CURRENT.discover_exe(session_cache, inside_folder) + _CURRENT.discover_exe(session_cache, inside_folder) diff --git a/tests/test_cache.py b/tests/test_cache.py index 66ceceb..c868f2c 100644 --- a/tests/test_cache.py +++ b/tests/test_cache.py @@ -82,8 +82,7 @@ def test_disk_content_store_remove(tmp_path: Path) -> None: def test_disk_content_store_remove_missing_file(tmp_path: Path) -> None: - store = DiskContentStore(tmp_path, "test") - store.remove() + DiskContentStore(tmp_path, "test").remove() def test_disk_content_store_locked(tmp_path: Path) -> None: @@ -135,8 +134,7 @@ def acquire() -> None: def test_disk_content_store_locks_legacy_path_without_posix(tmp_path: Path, mocker: MockerFixture) -> None: - platform_os = mocker.patch("python_discovery._cache.os", autospec=True) - platform_os.name = "nt" + mocker.patch("python_discovery._cache.os", autospec=True).name = "nt" with DiskContentStore(tmp_path, "test").locked(), pytest.raises(Timeout): FileLock(str(tmp_path / "test.lock")).acquire(timeout=0) assert not (tmp_path / "locks-v2").exists() @@ -150,8 +148,7 @@ def test_disk_content_store_exists(tmp_path: Path) -> None: def test_disk_cache_py_info(tmp_path: Path) -> None: - cache = DiskCache(tmp_path) - store = cache.py_info(Path("/some/path")) + store = DiskCache(tmp_path).py_info(Path("/some/path")) assert isinstance(store, DiskContentStore) @@ -178,8 +175,7 @@ def test_disk_cache_py_info_clear(tmp_path: Path) -> None: def test_disk_cache_py_info_clear_empty(tmp_path: Path) -> None: - cache = DiskCache(tmp_path) - cache.py_info_clear() + DiskCache(tmp_path).py_info_clear() def test_disk_cache_py_info_clear_skips_non_json(tmp_path: Path) -> None: @@ -303,8 +299,7 @@ def test_disk_content_store_rejects_untrusted_entry( def test_disk_content_store_does_not_follow_entry_link(tmp_path: Path) -> None: target = tmp_path / "target.json" target.write_text('{"key": "target"}', encoding="utf-8") - entry = tmp_path / "entry.json" - entry.symlink_to(target) + (tmp_path / "entry.json").symlink_to(target) store = DiskContentStore(tmp_path, "entry") assert store.read() is None @@ -372,8 +367,7 @@ def test_noop_content_store_locked() -> None: def test_noop_cache_py_info() -> None: - cache = NoOpCache() - store = cache.py_info(Path("/some/path")) + store = NoOpCache().py_info(Path("/some/path")) assert isinstance(store, NoOpContentStore) diff --git a/tests/test_cached_py_info.py b/tests/test_cached_py_info.py index 3dae27e..77bf89d 100644 --- a/tests/test_cached_py_info.py +++ b/tests/test_cached_py_info.py @@ -48,8 +48,7 @@ def test_log_cmd_repr() -> None: def test_log_cmd_repr_with_env() -> None: - cmd = LogCmd(["python", "-c", "print('hello')"], env={"FOO": "bar"}) - result = repr(cmd) + result = repr(LogCmd(["python", "-c", "print('hello')"], env={"FOO": "bar"})) assert "python" in result assert "env of" in result assert "FOO" in result @@ -101,6 +100,16 @@ def test_run_subprocess_invalid_json(mocker: MockerFixture) -> None: assert result is None +def _mock_process(out: str, err: str, code: int) -> MagicMock: + streams: list[IO[str]] = [] + for value in (out, err): + read_fd, write_fd = os.pipe() + with os.fdopen(write_fd, "wb") as writer: + writer.write(value.encode()) + streams.append(os.fdopen(read_fd, "r", encoding="utf-8")) + return MagicMock(stdout=streams[0], stderr=streams[1], returncode=code, **{"wait.return_value": code}) + + @pytest.mark.parametrize( "output", [ @@ -132,21 +141,10 @@ def test_from_exe_rejects_json_without_cookies(tmp_path: Path, mocker: MockerFix assert PythonInfo.from_exe(str(tmp_path / "python"), raise_on_error=False, ignore_cache=True) is None -def _mock_process(out: str, err: str, code: int) -> MagicMock: - streams: list[IO[str]] = [] - for value in (out, err): - read_fd, write_fd = os.pipe() - with os.fdopen(write_fd, "wb") as writer: - writer.write(value.encode()) - streams.append(os.fdopen(read_fd, "r", encoding="utf-8")) - return MagicMock(stdout=streams[0], stderr=streams[1], returncode=code, **{"wait.return_value": code}) - - def test_run_subprocess_with_cookies(mocker: MockerFixture) -> None: start_cookie = "a" * 32 end_cookie = "b" * 32 - payload = json.dumps(PythonInfo().to_dict()) - out = f"pre{start_cookie[::-1]}{payload}{end_cookie[::-1]}post" + out = f"pre{start_cookie[::-1]}{json.dumps(PythonInfo().to_dict())}{end_cookie[::-1]}post" mocker.patch("python_discovery._cached_py_info.Popen", return_value=_mock_process(out, "", 0)) mocker.patch("python_discovery._cached_py_info.gen_cookie", side_effect=[start_cookie, end_cookie]) @@ -276,9 +274,7 @@ def test_collect_query_output_requires_pipes(stdout: StringIO | None, stderr: St def test_load_cached_py_info_valid() -> None: - store = MagicMock() - content = PythonInfo().to_dict() - result = _load_cached_py_info(PythonInfo, store, content) + result = _load_cached_py_info(PythonInfo, MagicMock(), PythonInfo().to_dict()) assert result is not None assert isinstance(result, PythonInfo) @@ -333,8 +329,7 @@ def test_get_via_file_cache_stale_hash(tmp_path: Path) -> None: def test_get_via_file_cache_nonexistent_path(tmp_path: Path) -> None: cache = DiskCache(tmp_path) path = Path(tmp_path / "nonexistent") - env = dict(os.environ) - result = _get_via_file_cache(PythonInfo, cache, path, str(path), env) + result = _get_via_file_cache(PythonInfo, cache, path, str(path), dict(os.environ)) assert isinstance(result, (PythonInfo, Exception)) @@ -371,18 +366,6 @@ def test_get_via_file_cache_py_info_none(tmp_path: Path, mocker: MockerFixture) assert isinstance(result, RuntimeError) -def _write_fake_python(tmp_path: Path, stderr: str, code: int = 79) -> Path: - """A stand-in interpreter: logs every invocation, then fails interrogation with the given stderr and exit code.""" - exe = tmp_path / "python" - exe.write_text(f'#!/bin/sh\necho "$@" >> "$0.log"\necho "{stderr}" >&2\nexit {code}\n') - exe.chmod(0o755) - return exe - - -def _interrogations(exe: Path) -> list[str]: - return Path(f"{exe}.log").read_text(encoding="utf-8").splitlines() - - @pytest.mark.skipif(sys.platform == "win32", reason="POSIX shell fake interpreter") def test_from_exe_too_old_python_warns(tmp_path: Path, caplog: pytest.LogCaptureFixture) -> None: caplog.set_level(logging.INFO) @@ -393,6 +376,14 @@ def test_from_exe_too_old_python_warns(tmp_path: Path, caplog: pytest.LogCapture assert record.getMessage() == f"{exe} is Python 3.5.10, older than the minimum 3.6 python-discovery can query" +def _write_fake_python(tmp_path: Path, stderr: str, code: int = 79) -> Path: + # A stand-in interpreter: logs every invocation, then fails interrogation with the given stderr and exit code. + exe = tmp_path / "python" + exe.write_text(f'#!/bin/sh\necho "$@" >> "$0.log"\necho "{stderr}" >&2\nexit {code}\n') + exe.chmod(0o755) + return exe + + @pytest.mark.skipif(sys.platform == "win32", reason="POSIX shell fake interpreter") def test_from_exe_too_old_python_skips_retry(tmp_path: Path) -> None: exe = _write_fake_python(tmp_path, stderr="unsupported Python version 3.5.10") @@ -400,6 +391,10 @@ def test_from_exe_too_old_python_skips_retry(tmp_path: Path) -> None: assert len(_interrogations(exe)) == 1 +def _interrogations(exe: Path) -> list[str]: + return Path(f"{exe}.log").read_text(encoding="utf-8").splitlines() + + @pytest.mark.skipif(sys.platform == "win32", reason="POSIX shell fake interpreter") def test_from_exe_too_old_python_raises_by_default(tmp_path: Path) -> None: exe = _write_fake_python(tmp_path, stderr="unsupported Python version 3.5.10") @@ -538,8 +533,7 @@ def popen(mocker: MockerFixture) -> MagicMock: mocker.patch("python_discovery._cached_py_info.gen_cookie", side_effect=[start, end]) info = PythonInfo() info.executable = _SELECTED - out = f"{start[::-1]}{info.to_json()}{end[::-1]}" - process = _mock_process(out, "", 0) + process = _mock_process(f"{start[::-1]}{info.to_json()}{end[::-1]}", "", 0) return mocker.patch("python_discovery._cached_py_info.Popen", return_value=process) diff --git a/tests/test_discovery.py b/tests/test_discovery.py index d498fa9..475f1df 100644 --- a/tests/test_discovery.py +++ b/tests/test_discovery.py @@ -56,13 +56,11 @@ def test_discovery_via_path( exe_name = f"{name}{exe_ver}{'.exe' if sys.platform == 'win32' else ''}" target = tmp_path / current.install_path("scripts") target.mkdir(parents=True) - executable = target / exe_name - Path(str(executable)).symlink_to(sys.executable) + Path(str(target / exe_name)).symlink_to(sys.executable) pyvenv_cfg = Path(sys.executable).parents[1] / "pyvenv.cfg" if pyvenv_cfg.exists(): # pragma: no branch (target / pyvenv_cfg.name).write_bytes(pyvenv_cfg.read_bytes()) - new_path = os.pathsep.join([str(target), *os.environ.get("PATH", "").split(os.pathsep)]) - monkeypatch.setenv("PATH", new_path) + monkeypatch.setenv("PATH", os.pathsep.join([str(target), *os.environ.get("PATH", "").split(os.pathsep)])) interpreter = get_interpreter(core, []) assert interpreter is not None @@ -173,8 +171,7 @@ def test_relative_path(session_cache: DiskCache, monkeypatch: pytest.MonkeyPatch sys_executable = Path(PythonInfo.current_system(session_cache).system_executable) cwd = sys_executable.parents[1] monkeypatch.chdir(str(cwd)) - relative = str(sys_executable.relative_to(cwd)) - result = get_interpreter(relative, [], session_cache) + result = get_interpreter(str(sys_executable.relative_to(cwd)), [], session_cache) assert result is not None @@ -209,8 +206,7 @@ def test_relative_path_with_parent_reference( ) -> None: sys_executable = Path(PythonInfo.current_system(session_cache).system_executable) monkeypatch.chdir(str(sys_executable.parent)) - relative = sep.join((os.pardir, sys_executable.parent.name, sys_executable.name)) - result = get_interpreter(relative, [], session_cache) + result = get_interpreter(sep.join((os.pardir, sys_executable.parent.name, sys_executable.name)), [], session_cache) assert result is not None assert os.pardir not in Path(result.executable).parts assert Path(result.executable).samefile(sys_executable) @@ -403,7 +399,7 @@ def test_get_paths_skips_relative_default(tmp_path: Path, monkeypatch: pytest.Mo ], ) def test_get_paths_skips_empty_entries(tmp_path: Path, raw_path: str, expected: list[str]) -> None: - """An empty PATH entry means "current directory" - it must never be searched for interpreters.""" + # An empty PATH entry means "current directory" - it must never be searched for interpreters. dirs = {} for name in ("first", "second"): dirs[name] = tmp_path / name @@ -416,9 +412,8 @@ def test_get_paths_skips_empty_entries(tmp_path: Path, raw_path: str, expected: def test_get_paths_empty_entry_does_not_yield_cwd(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - """Reproduces the exploit: an empty PATH entry used to resolve to the caller's CWD.""" - cwd_marker = tmp_path / "python3.11" - cwd_marker.touch(mode=0o755) + # Reproduces the exploit: an empty PATH entry used to resolve to the caller's CWD. + (tmp_path / "python3.11").touch(mode=0o755) monkeypatch.chdir(tmp_path) paths = list(get_paths({"PATH": f"{os.pathsep}"})) @@ -432,8 +427,7 @@ def test_lazy_path_dump_debug(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) - executable_file = "a_file.exe" if IS_WIN else "a_file" (a_dir / executable_file).touch(mode=0o755) (a_dir / "b_file").touch(mode=0o644) - dumper = LazyPathDump(0, a_dir, os.environ) - output = repr(dumper) + output = repr(LazyPathDump(0, a_dir, os.environ)) assert executable_file in output assert "b_file" not in output @@ -466,22 +460,6 @@ def test_discovery_via_version_specifier(session_cache: DiskCache) -> None: ) -def _create_version_manager(tmp_path: Path, env_var: str) -> Path: - root = tmp_path / env_var.lower() - root.mkdir() - (root / "shims").mkdir() - return root - - -def _create_versioned_binary(root: Path, versions_path: tuple[str, ...], version: str, exe_name: str) -> Path: - bin_dir = root.joinpath(*versions_path, version, "bin") - bin_dir.mkdir(parents=True, exist_ok=True) - exe = bin_dir / (f"{exe_name}.exe" if IS_WIN else exe_name) - exe.touch() - exe.chmod(exe.stat().st_mode | stat.S_IEXEC) - return exe - - @pytest.mark.parametrize( ("env_var", "versions_path"), [ @@ -498,8 +476,7 @@ def test_shim_resolved_to_real_binary( ) -> None: root = _create_version_manager(tmp_path, env_var) real_binary = _create_versioned_binary(root, versions_path, "2.7.18", "python2.7") - shim = root / "shims" / ("python2.7.exe" if IS_WIN else "python2.7") - shim.touch(mode=0o755) + (root / "shims" / ("python2.7.exe" if IS_WIN else "python2.7")).touch(mode=0o755) monkeypatch.setenv("PATH", str(root / "shims")) monkeypatch.setenv(env_var, str(root)) @@ -514,6 +491,22 @@ def test_shim_resolved_to_real_binary( assert mock_from_exe.call_args_list[0][0][0] == str(real_binary) +def _create_version_manager(tmp_path: Path, env_var: str) -> Path: + root = tmp_path / env_var.lower() + root.mkdir() + (root / "shims").mkdir() + return root + + +def _create_versioned_binary(root: Path, versions_path: tuple[str, ...], version: str, exe_name: str) -> Path: + bin_dir = root.joinpath(*versions_path, version, "bin") + bin_dir.mkdir(parents=True, exist_ok=True) + exe = bin_dir / (f"{exe_name}.exe" if IS_WIN else exe_name) + exe.touch() + exe.chmod(exe.stat().st_mode | stat.S_IEXEC) + return exe + + def test_shim_not_resolved_without_version_manager_env(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: shims_dir = tmp_path / "shims" shims_dir.mkdir() @@ -549,8 +542,7 @@ def test_shim_falls_through_when_binary_missing(tmp_path: Path, monkeypatch: pyt def test_shim_uses_python_version_file(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: root = _create_version_manager(tmp_path, "PYENV_ROOT") real_binary = _create_versioned_binary(root, ("versions",), "2.7.18", "python2.7") - shim = root / "shims" / ("python2.7.exe" if IS_WIN else "python2.7") - shim.touch(mode=0o755) + (root / "shims" / ("python2.7.exe" if IS_WIN else "python2.7")).touch(mode=0o755) (tmp_path / ".python-version").write_text(encoding="utf-8", data="2.7.18\n") monkeypatch.setenv("PATH", str(root / "shims")) @@ -587,8 +579,7 @@ def test_shim_skips_path_like_version( "nested": root / "versions" / "2.7.18" / "subdir", }[target] _create_versioned_binary(attack_root, (), "", "python2.7") - shim = root / "shims" / ("python2.7.exe" if IS_WIN else "python2.7") - shim.touch(mode=0o755) + (root / "shims" / ("python2.7.exe" if IS_WIN else "python2.7")).touch(mode=0o755) (tmp_path / ".python-version").write_text(f"{version.format(outside=outside)}\n2.7.18\n", encoding="utf-8") monkeypatch.setenv("PATH", str(root / "shims")) @@ -606,8 +597,7 @@ def test_shim_skips_empty_environment_version(tmp_path: Path, monkeypatch: pytes root = _create_version_manager(tmp_path, "PYENV_ROOT") real_binary = _create_versioned_binary(root, ("versions",), "2.7.18", "python2.7") _create_versioned_binary(root / "versions", (), "", "python2.7") - shim = root / "shims" / ("python2.7.exe" if IS_WIN else "python2.7") - shim.touch(mode=0o755) + (root / "shims" / ("python2.7.exe" if IS_WIN else "python2.7")).touch(mode=0o755) monkeypatch.setenv("PATH", str(root / "shims")) monkeypatch.setenv("PYENV_ROOT", str(root)) @@ -633,7 +623,7 @@ def test_shim_oversized_python_version_file_is_skipped( root = Path(os.environ["PYENV_ROOT"]) _create_versioned_binary(root, ("versions",), "2.7.15", "python2.7") (root / "version").write_text("2.7.15\n", encoding="utf-8") - (tmp_path / ".python-version").write_text("2.7.18\n#" + "x" * (length - 8), encoding="utf-8") + (tmp_path / ".python-version").write_text(f"2.7.18\n#{'x' * (length - 8)}", encoding="utf-8") monkeypatch.chdir(tmp_path) assert pyenv_probed() == ("versions", version) @@ -707,8 +697,7 @@ def test_shim_pyenv_version_env_takes_priority_over_file(tmp_path: Path, monkeyp root = _create_version_manager(tmp_path, "PYENV_ROOT") _create_versioned_binary(root, ("versions",), "2.7.18", "python2.7") env_binary = _create_versioned_binary(root, ("versions",), "2.7.15", "python2.7") - shim = root / "shims" / ("python2.7.exe" if IS_WIN else "python2.7") - shim.touch(mode=0o755) + (root / "shims" / ("python2.7.exe" if IS_WIN else "python2.7")).touch(mode=0o755) (tmp_path / ".python-version").write_text(encoding="utf-8", data="2.7.18\n") monkeypatch.setenv("PATH", str(root / "shims")) @@ -725,8 +714,7 @@ def test_shim_pyenv_version_env_takes_priority_over_file(tmp_path: Path, monkeyp def test_shim_uses_global_version_file(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: root = _create_version_manager(tmp_path, "PYENV_ROOT") real_binary = _create_versioned_binary(root, ("versions",), "2.7.18", "python2.7") - shim = root / "shims" / ("python2.7.exe" if IS_WIN else "python2.7") - shim.touch(mode=0o755) + (root / "shims" / ("python2.7.exe" if IS_WIN else "python2.7")).touch(mode=0o755) (root / "version").write_text(encoding="utf-8", data="2.7.18\n") workdir = tmp_path / "workdir" workdir.mkdir() @@ -749,8 +737,7 @@ def test_shim_colon_separated_pyenv_version_picks_first_match( root = _create_version_manager(tmp_path, "PYENV_ROOT") _create_versioned_binary(root, ("versions",), "2.7.18", "python2.7") second_binary = _create_versioned_binary(root, ("versions",), "2.7.15", "python2.7") - shim = root / "shims" / ("python2.7.exe" if IS_WIN else "python2.7") - shim.touch(mode=0o755) + (root / "shims" / ("python2.7.exe" if IS_WIN else "python2.7")).touch(mode=0o755) monkeypatch.setenv("PATH", str(root / "shims")) monkeypatch.setenv("PYENV_ROOT", str(root)) @@ -803,7 +790,7 @@ def reject_first(info: PythonInfo) -> bool: @pytest.fixture def uv_home(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Path: - """A throwaway home, with every directory variable uv consults pointed into it or unset.""" + # A throwaway home, with every directory variable uv consults pointed into it or unset. for var in ("UV_PYTHON_INSTALL_DIR", "XDG_DATA_HOME", "APPDATA"): monkeypatch.delenv(var, raising=False) for var in ("HOME", "USERPROFILE"): @@ -814,8 +801,7 @@ def uv_home(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Path: @pytest.fixture def uv_plant() -> Callable[[Path], str]: - """Create a uv install under a store root and return the executable discovery should reach for.""" - + # Create a uv install under a store root and return the executable discovery should reach for. def _plant(root: Path) -> str: (bin_dir := root / "some-py-impl" / "bin").mkdir(parents=True) (executable := bin_dir / "python").touch() @@ -826,7 +812,7 @@ def _plant(root: Path) -> str: @pytest.fixture def uv_probed(mocker: MockerFixture) -> Callable[..., list[str]]: - """Run a discovery with the interrogation subprocess stubbed out, reporting the executables it reached for.""" + # Run a discovery with the interrogation subprocess stubbed out, reporting the executables it reached for. from_exe = mocker.patch("python_discovery._discovery.PathPythonInfo.from_exe", return_value=None) def _probed(**kwargs: object) -> list[str]: diff --git a/tests/test_discovery_extra.py b/tests/test_discovery_extra.py index 2c17fd3..6ace9b0 100644 --- a/tests/test_discovery_extra.py +++ b/tests/test_discovery_extra.py @@ -23,21 +23,17 @@ def test_propose_interpreters_abs_path_oserror(tmp_path: Path) -> None: - spec = PythonSpec.from_string_spec(str(tmp_path / "nonexistent")) - results = list(propose_interpreters(spec, [])) + results = list(propose_interpreters(PythonSpec.from_string_spec(str(tmp_path / "nonexistent")), [])) assert results == [] def test_propose_interpreters_try_first_with_valid(session_cache: DiskCache) -> None: - spec = PythonSpec.from_string_spec("python") - results = list(propose_interpreters(spec, [sys.executable], session_cache)) + results = list(propose_interpreters(PythonSpec.from_string_spec("python"), [sys.executable], session_cache)) assert len(results) >= 1 def test_propose_interpreters_try_first_with_missing(tmp_path: Path) -> None: - spec = PythonSpec.from_string_spec("python") - bad_path = str(tmp_path / "nonexistent") - gen = propose_interpreters(spec, [bad_path]) + gen = propose_interpreters(PythonSpec.from_string_spec("python"), [str(tmp_path / "nonexistent")]) results = [] for result in gen: # pragma: no branch results.append(result) @@ -48,11 +44,7 @@ def test_propose_interpreters_try_first_with_missing(tmp_path: Path) -> None: def test_propose_interpreters_try_first_with_duplicate(session_cache: DiskCache) -> None: spec = PythonSpec.from_string_spec("python") results = list(propose_interpreters(spec, [sys.executable, sys.executable], session_cache)) - exes = [r[0].executable for r in results if r[0] is not None] - seen = set() - for exe in exes[:2]: - assert True - seen.add(exe) + set().update([r[0].executable for r in results if r[0] is not None][:2]) def test_propose_interpreters_relative_path( @@ -71,39 +63,29 @@ def test_propose_interpreters_relative_path( def test_lazy_path_dump_basic(tmp_path: Path) -> None: - dumper = LazyPathDump(0, tmp_path, {}) - result = repr(dumper) + result = repr(LazyPathDump(0, tmp_path, {})) assert "PATH[0]" in result assert "with =>" not in result def test_lazy_path_dump_debug_with_dir(tmp_path: Path) -> None: - env = {"_VIRTUALENV_DEBUG": "1"} - sub = tmp_path / "subdir" - sub.mkdir() - dumper = LazyPathDump(0, tmp_path, env) - result = repr(dumper) + (tmp_path / "subdir").mkdir() + result = repr(LazyPathDump(0, tmp_path, {"_VIRTUALENV_DEBUG": "1"})) assert "subdir" not in result @pytest.mark.skipif(IS_WIN, reason="POSIX test") def test_lazy_path_dump_debug_non_executable(tmp_path: Path) -> None: - env = {"_VIRTUALENV_DEBUG": "1"} - non_exec = tmp_path / "not_executable" - non_exec.touch(mode=0o644) - dumper = LazyPathDump(0, tmp_path, env) - result = repr(dumper) + (tmp_path / "not_executable").touch(mode=0o644) + result = repr(LazyPathDump(0, tmp_path, {"_VIRTUALENV_DEBUG": "1"})) assert "not_executable" not in result def test_lazy_path_dump_debug_oserror(tmp_path: Path, mocker: MockerFixture) -> None: - env = {"_VIRTUALENV_DEBUG": "1"} - bad_file = tmp_path / "bad_file" - bad_file.touch() + (tmp_path / "bad_file").touch() mocker.patch("pathlib.Path.is_dir", side_effect=[False, False]) mocker.patch("pathlib.Path.stat", side_effect=OSError("permission denied")) - dumper = LazyPathDump(0, tmp_path, env) - repr(dumper) + repr(LazyPathDump(0, tmp_path, {"_VIRTUALENV_DEBUG": "1"})) def test_active_versions_pyenv_version(monkeypatch: pytest.MonkeyPatch) -> None: @@ -173,8 +155,7 @@ def test_resolve_shim_no_match() -> None: def test_path_exe_finder_returns_callable(tmp_path: Path) -> None: from python_discovery._discovery import path_exe_finder - spec = PythonSpec.from_string_spec("python3.12") - finder = path_exe_finder(spec) + finder = path_exe_finder(PythonSpec.from_string_spec("python3.12")) assert callable(finder) results = list(finder(tmp_path)) assert results == [] @@ -188,8 +169,7 @@ def test_get_paths_no_path_env() -> None: def test_propose_interpreters_abs_path_exists(session_cache: DiskCache) -> None: - spec = PythonSpec.from_string_spec(sys.executable) - results = list(propose_interpreters(spec, [], session_cache)) + results = list(propose_interpreters(PythonSpec.from_string_spec(sys.executable), [], session_cache)) assert len(results) >= 1 @@ -205,11 +185,9 @@ def test_propose_interpreters_relative_spec_is_abs(tmp_path: Path, session_cache def test_resolve_shim_match_no_binary(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: shims = tmp_path / "shims" shims.mkdir() - versions = tmp_path / "versions" - versions.mkdir() + (tmp_path / "versions").mkdir() monkeypatch.setenv("PYENV_ROOT", str(tmp_path)) - exe_path = str(shims / "python3") - result = _resolve_shim(exe_path, dict(os.environ)) + result = _resolve_shim(str(shims / "python3"), dict(os.environ)) assert result is None diff --git a/tests/test_iter_interpreters.py b/tests/test_iter_interpreters.py index 7fca0e8..085f5c2 100644 --- a/tests/test_iter_interpreters.py +++ b/tests/test_iter_interpreters.py @@ -20,7 +20,7 @@ @pytest.fixture def uv_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: - """Empty uv install root with environment variables pointing at it.""" + # Empty uv install root with environment variables pointing at it. monkeypatch.delenv("XDG_DATA_HOME", raising=False) root = tmp_path / "uv-python" root.mkdir() @@ -42,7 +42,7 @@ def _install(*relative: str) -> None: def uv_probed( uv_dir: Path, monkeypatch: pytest.MonkeyPatch, mocker: MockerFixture ) -> Callable[[str | None], list[str]]: - """Stub out the interrogation subprocess, so a run reports the executables discovery reached for.""" + # Stub out the interrogation subprocess, so a run reports the executables discovery reached for. monkeypatch.setenv("PATH", "") from_exe = mocker.patch("python_discovery._discovery.PathPythonInfo.from_exe", return_value=None) @@ -216,9 +216,7 @@ def test_iter_interpreters_skips_when_no_executable( session_cache: DiskCache, mocker: MockerFixture, ) -> None: - bogus = mocker.MagicMock(spec=PythonInfo) - bogus.system_executable = None - bogus.executable = None + bogus = mocker.MagicMock(spec=PythonInfo, system_executable=None, executable=None) mocker.patch( "python_discovery._discovery.propose_interpreters", return_value=iter([(bogus, True)]), @@ -237,8 +235,7 @@ def test_iter_interpreters_uv_yields_when_interrogation_succeeds( bin_path.mkdir(parents=True) (bin_path / "python").touch() - fake_info = mocker.MagicMock(spec=PythonInfo) - fake_info.system_executable = str(uv_dir / "fake-unique" / "python") + fake_info = mocker.MagicMock(spec=PythonInfo, system_executable=str(uv_dir / "fake-unique" / "python")) fake_info.executable = fake_info.system_executable fake_info.satisfies.return_value = True mocker.patch("python_discovery._discovery.PathPythonInfo.from_exe", return_value=fake_info) @@ -471,7 +468,7 @@ def test_iter_interpreters_uv_probe_order( def test_iter_interpreters_uv_minor_version_link_loses_to_its_target( uv_dir: Path, uv_install: Callable[..., None], uv_probed: Callable[[str | None], list[str]] ) -> None: - """uv links `cpython-3.15` at the concrete install, and repoints it on the next patch release.""" + # uv links `cpython-3.15` at the concrete install, and repoints it on the next patch release. uv_install("cpython-3.15.0b3-macos-aarch64-none/bin/python") Path(uv_dir / "cpython-3.15-macos-aarch64-none").symlink_to(uv_dir / "cpython-3.15.0b3-macos-aarch64-none") @@ -517,7 +514,7 @@ def _junction(target: str, link: str) -> None: def test_iter_interpreters_uv_probes_one_executable_per_install( uv_dir: Path, uv_install: Callable[..., None], monkeypatch: pytest.MonkeyPatch, mocker: MockerFixture ) -> None: - """GraalPy ships bin/python as a copy of bin/graalpy, so the realpath dedup alone would spawn it twice.""" + # GraalPy ships bin/python as a copy of bin/graalpy, so the realpath dedup alone would spawn it twice. monkeypatch.setenv("PATH", "") uv_install("graalpy-3.11.0-macos-aarch64-none/bin/python", "graalpy-3.11.0-macos-aarch64-none/bin/graalpy") executable = str(uv_dir / "graalpy-3.11.0-macos-aarch64-none" / "bin" / "python") diff --git a/tests/test_py_info_collect.py b/tests/test_py_info_collect.py index eda865f..edb8897 100644 --- a/tests/test_py_info_collect.py +++ b/tests/test_py_info_collect.py @@ -23,11 +23,11 @@ from pytest_mock import MockerFixture -SCRIPT = Path(python_discovery.__file__).parent / "_py_info_collect.py" +_SCRIPT = Path(python_discovery.__file__).parent / "_py_info_collect.py" def test_script_parses_down_to_python27() -> None: - py2, py3 = vermin.detect(SCRIPT.read_text(encoding="utf-8")) + py2, py3 = vermin.detect(_SCRIPT.read_text(encoding="utf-8")) assert py2 is not None, "script no longer parses on Python 2.7, the version gate cannot run there" assert py2 <= (2, 7) assert py3 is not None @@ -35,7 +35,7 @@ def test_script_parses_down_to_python27() -> None: def test_script_version_gate_precedes_imports() -> None: - body = ast.parse(SCRIPT.read_text(encoding="utf-8")).body + body = ast.parse(_SCRIPT.read_text(encoding="utf-8")).body before_gate = takewhile(lambda node: not isinstance(node, ast.If), body) imported = [ alias.name for node in before_gate if isinstance(node, (ast.Import, ast.ImportFrom)) for alias in node.names @@ -43,10 +43,6 @@ def test_script_version_gate_precedes_imports() -> None: assert imported == ["sys"], "only sys may load before the version gate; Python 3.0/3.1 lack sysconfig" -def _run_script(*args: str) -> str: - return run([sys.executable, str(SCRIPT), *args], capture_output=True, text=True, check=True).stdout - - def test_script_version_gate_reports_executable() -> None: result = run( [ @@ -56,14 +52,13 @@ def test_script_version_gate_reports_executable() -> None: "from runpy import run_path; import sys; sys.version_info = (3, 5, 10, 'final', 0); " "run_path(sys.argv[1], run_name='__main__')" ), - str(SCRIPT), + str(_SCRIPT), ], capture_output=True, text=True, check=False, ) - assert result.returncode == 79 - assert result.stderr == f"unsupported Python version 3.5.10\n{sys.executable}" + assert (result.returncode, result.stderr) == (79, f"unsupported Python version 3.5.10\n{sys.executable}") @pytest.mark.parametrize( @@ -83,6 +78,10 @@ def test_script_wraps_payload_in_reversed_cookies(args: tuple[str, ...], start: assert payload["version_info"]["major"] == sys.version_info.major +def _run_script(*args: str) -> str: + return run([sys.executable, str(_SCRIPT), *args], capture_output=True, text=True, check=True).stdout + + def test_script_payload_matches_in_process_collection() -> None: assert set(json.loads(_run_script())) == set(PythonInfo().to_dict()) @@ -320,8 +319,13 @@ def test_tcl_tk_libs_none_without_env(mocker: MockerFixture) -> None: assert (info.tcl_lib, info.tk_lib) == (None, None) -class _TclError(Exception): - """Stands in for tkinter.TclError; an except clause needs a real exception type.""" +def test_tcl_tk_libs_queried_with_env(tmp_path: Path, mocker: MockerFixture) -> None: + tk_dir = tmp_path / "tk8.6" + tk_dir.mkdir() + _fake_tkinter(mocker, {"info library": "/tcl-lib", "set tk_library": str(tk_dir)}.__getitem__) + mocker.patch.dict(os.environ, {"TCL_LIBRARY": str(tmp_path)}) + info = PythonInfo() + assert (info.tcl_lib, info.tk_lib) == ("/tcl-lib", str(tk_dir)) def _fake_tkinter(mocker: MockerFixture, eval_side_effect: Callable[[str], str] | Exception) -> Mock: @@ -336,14 +340,9 @@ def _fake_tkinter(mocker: MockerFixture, eval_side_effect: Callable[[str], str] return profile -def test_tcl_tk_libs_queried_with_env(tmp_path: Path, mocker: MockerFixture) -> None: - tk_dir = tmp_path / "tk8.6" - tk_dir.mkdir() - responses = {"info library": "/tcl-lib", "set tk_library": str(tk_dir)} - _fake_tkinter(mocker, responses.__getitem__) - mocker.patch.dict(os.environ, {"TCL_LIBRARY": str(tmp_path)}) - info = PythonInfo() - assert (info.tcl_lib, info.tk_lib) == ("/tcl-lib", str(tk_dir)) +class _TclError(Exception): + # Stands in for tkinter.TclError; an except clause needs a real exception type. + ... @pytest.mark.parametrize("folder", [pytest.param("tk8.6", id="minor"), pytest.param("tk8", id="major")]) diff --git a/tests/test_py_info_extra.py b/tests/test_py_info_extra.py index ed68ec9..265dace 100644 --- a/tests/test_py_info_extra.py +++ b/tests/test_py_info_extra.py @@ -16,15 +16,14 @@ if TYPE_CHECKING: from pytest_mock import MockerFixture -CURRENT = PythonInfo.current_system() +_CURRENT = PythonInfo.current_system() def test_py_info_pypy_version(mocker: MockerFixture) -> None: mocker.patch("platform.python_implementation", return_value="PyPy") mocker.patch.object(sys, "pypy_version_info", (7, 3, 11, "final", 0), create=True) info = PythonInfo() - assert info.implementation == "PyPy" - assert info.pypy_version_info == (7, 3, 11, "final", 0) + assert (info.implementation, info.pypy_version_info) == ("PyPy", (7, 3, 11, "final", 0)) def test_py_info_canonicalizes_graalvm(mocker: MockerFixture) -> None: @@ -39,12 +38,12 @@ def test_has_venv_attribute() -> None: @pytest.mark.skipif(sys.platform == "win32", reason="POSIX only") -@pytest.mark.skipif(bool(CURRENT.sysconfig_vars.get("PYTHONFRAMEWORK")), reason="framework builds keep recorded path") +@pytest.mark.skipif(bool(_CURRENT.sysconfig_vars.get("PYTHONFRAMEWORK")), reason="framework builds keep recorded path") def test_from_exe_resolves_executable_only_symlink( # pragma: no cover # skipped on framework interpreter hosts tmp_path: Path, session_cache: DiskCache, ) -> None: - system_exe = CURRENT.system_executable + system_exe = _CURRENT.system_executable assert system_exe is not None link = tmp_path / "python3" link.symlink_to(system_exe) @@ -56,19 +55,19 @@ def test_from_exe_resolves_executable_only_symlink( # pragma: no cover # skippe def test_version_str() -> None: - assert CURRENT.version_str == ".".join(str(i) for i in sys.version_info[:3]) + assert _CURRENT.version_str == ".".join(str(i) for i in sys.version_info[:3]) def test_version_release_str() -> None: - assert CURRENT.version_release_str == ".".join(str(i) for i in sys.version_info[:2]) + assert _CURRENT.version_release_str == ".".join(str(i) for i in sys.version_info[:2]) def test_python_name() -> None: - assert CURRENT.python_name == f"python{sys.version_info.major}.{sys.version_info.minor}" + assert _CURRENT.python_name == f"python{sys.version_info.major}.{sys.version_info.minor}" def test_is_old_virtualenv() -> None: - info = copy.deepcopy(CURRENT) + info = copy.deepcopy(_CURRENT) info.real_prefix = "/some/prefix" assert info.is_old_virtualenv is True info.real_prefix = None @@ -76,11 +75,11 @@ def test_is_old_virtualenv() -> None: def test_is_venv() -> None: - assert CURRENT.is_venv == (CURRENT.base_prefix is not None) + assert _CURRENT.is_venv == (_CURRENT.base_prefix is not None) def test_system_prefix() -> None: - info = copy.deepcopy(CURRENT) + info = copy.deepcopy(_CURRENT) info.real_prefix = "/real" assert info.system_prefix == "/real" info.real_prefix = None @@ -89,7 +88,7 @@ def test_system_prefix() -> None: def test_system_exec_prefix() -> None: - info = copy.deepcopy(CURRENT) + info = copy.deepcopy(_CURRENT) info.real_prefix = "/real" assert info.system_exec_prefix == "/real" info.real_prefix = None @@ -97,48 +96,45 @@ def test_system_exec_prefix() -> None: def test_repr() -> None: - result = repr(CURRENT) + result = repr(_CURRENT) assert "PythonInfo" in result def test_str() -> None: - result = str(CURRENT) + result = str(_CURRENT) assert "PythonInfo" in result assert "spec=" in result def test_machine_none_platform() -> None: - info = copy.deepcopy(CURRENT) + info = copy.deepcopy(_CURRENT) info.sysconfig_platform = None assert info.machine == "unknown" def test_from_json_round_trip() -> None: - json_str = CURRENT.to_json() - restored = PythonInfo.from_json(json_str) - assert restored.version_info == CURRENT.version_info - assert restored.implementation == CURRENT.implementation + restored = PythonInfo.from_json(_CURRENT.to_json()) + assert (restored.version_info, restored.implementation) == (_CURRENT.version_info, _CURRENT.implementation) def test_from_dict() -> None: - data = CURRENT.to_dict() - restored = PythonInfo.from_dict(data) - assert restored.version_info == CURRENT.version_info + restored = PythonInfo.from_dict(_CURRENT.to_dict()) + assert restored.version_info == _CURRENT.version_info def test_resolve_to_system_circle(mocker: MockerFixture, caplog: pytest.LogCaptureFixture) -> None: caplog.set_level(logging.DEBUG) - target = copy.deepcopy(CURRENT) + target = copy.deepcopy(_CURRENT) target.system_executable = None target.real_prefix = None target.base_prefix = "/prefix_a" - second = copy.deepcopy(CURRENT) + second = copy.deepcopy(_CURRENT) second.system_executable = None second.real_prefix = None second.base_prefix = "/prefix_b" - third = copy.deepcopy(CURRENT) + third = copy.deepcopy(_CURRENT) third.system_executable = None third.real_prefix = None third.base_prefix = "/prefix_a" @@ -151,12 +147,12 @@ def test_resolve_to_system_circle(mocker: MockerFixture, caplog: pytest.LogCaptu def test_resolve_to_system_single_prefix_self_link(mocker: MockerFixture, caplog: pytest.LogCaptureFixture) -> None: caplog.set_level(logging.INFO) - target = copy.deepcopy(CURRENT) + target = copy.deepcopy(_CURRENT) target.system_executable = None target.real_prefix = None target.base_prefix = "/prefix_a" - second = copy.deepcopy(CURRENT) + second = copy.deepcopy(_CURRENT) second.system_executable = None second.real_prefix = None second.base_prefix = "/prefix_a" @@ -184,8 +180,8 @@ def test_resolve_to_system_repeated_prefix_with_parent_segments(tmp_path: Path, def test_discover_exe_cache_hit() -> None: - info = copy.deepcopy(CURRENT) - cached = copy.deepcopy(CURRENT) + info = copy.deepcopy(_CURRENT) + cached = copy.deepcopy(_CURRENT) PythonInfo._cache_exe_discovery["/some/prefix", True, info.version_info] = cached try: result = info.discover_exe(MagicMock(), prefix="/some/prefix", exact=True) @@ -195,14 +191,14 @@ def test_discover_exe_cache_hit() -> None: def test_discover_exe_cache_keeps_requested_version(tmp_path: Path) -> None: - prefix = CURRENT.prefix + prefix = _CURRENT.prefix assert prefix is not None cache = DiskCache(tmp_path) PythonInfo.clear_cache(cache) try: - found = CURRENT.discover_exe(cache, prefix) - assert found.version_info == CURRENT.version_info - other = copy.deepcopy(CURRENT) + found = _CURRENT.discover_exe(cache, prefix) + assert found.version_info == _CURRENT.version_info + other = copy.deepcopy(_CURRENT) other.version_info = other.version_info._replace(major=0) with pytest.raises(RuntimeError): other.discover_exe(cache, prefix) @@ -211,37 +207,37 @@ def test_discover_exe_cache_keeps_requested_version(tmp_path: Path) -> None: def test_check_exe_none_path(tmp_path: Path) -> None: - info = copy.deepcopy(CURRENT) + info = copy.deepcopy(_CURRENT) result = info._check_exe(MagicMock(), str(tmp_path), "nonexistent", [], dict(os.environ), exact=True) assert result is None def test_satisfies_version_specifier() -> None: spec = PythonSpec.from_string_spec(f">={sys.version_info.major}.{sys.version_info.minor}") - assert CURRENT.satisfies(spec, impl_must_match=False) is True + assert _CURRENT.satisfies(spec, impl_must_match=False) is True def test_satisfies_version_specifier_fails() -> None: spec = PythonSpec.from_string_spec(f">{sys.version_info.major + 1}") - assert CURRENT.satisfies(spec, impl_must_match=False) is False + assert _CURRENT.satisfies(spec, impl_must_match=False) is False @pytest.mark.parametrize( ("suffix", "expected"), [ - pytest.param("t", CURRENT.free_threaded, id="free-threaded"), + pytest.param("t", _CURRENT.free_threaded, id="free-threaded"), pytest.param("", True, id="any-build"), ], ) def test_satisfies_version_specifier_free_threaded(suffix: str, expected: bool) -> None: spec = PythonSpec.from_string_spec(f">={sys.version_info.major}.{sys.version_info.minor}{suffix}") - assert CURRENT.satisfies(spec, impl_must_match=False) is expected + assert _CURRENT.satisfies(spec, impl_must_match=False) is expected def test_satisfies_version_specifier_unparsable_version() -> None: specifier = SimpleSpecifierSet.from_string(">=3.x") spec = PythonSpec(">=3.x", None, None, None, None, None, None, version_specifier=specifier) - assert CURRENT.satisfies(spec, impl_must_match=False) is False + assert _CURRENT.satisfies(spec, impl_must_match=False) is False @pytest.mark.parametrize( @@ -267,31 +263,29 @@ def test_satisfies_version_specifier_unparsable_version() -> None: ], ) def test_satisfies_version_specifier_prerelease(version_info: VersionInfo, spec_str: str, expected: bool) -> None: - info = copy.deepcopy(CURRENT) + info = copy.deepcopy(_CURRENT) info.version_info = version_info spec = PythonSpec.from_string_spec(spec_str) assert info.satisfies(spec, impl_must_match=False) is expected def test_satisfies_path_not_abs_basename_match() -> None: - info = copy.deepcopy(CURRENT) + info = copy.deepcopy(_CURRENT) # stem drops everything from the first dot, so python3.15t arrives as python3 and stops naming a free-threaded # build; only the Windows .exe belongs off the end. - name = Path(info.original_executable).name - basename = name.removesuffix(".exe") - spec = PythonSpec.from_string_spec(basename) + spec = PythonSpec.from_string_spec(Path(info.original_executable).name.removesuffix(".exe")) assert info.satisfies(spec, impl_must_match=False) is True def test_satisfies_path_not_abs_basename_no_match() -> None: - info = copy.deepcopy(CURRENT) + info = copy.deepcopy(_CURRENT) spec = PythonSpec.from_string_spec("completely_different_name") assert info.satisfies(spec, impl_must_match=False) is False @pytest.mark.skipif(sys.platform == "win32", reason="win32 tested separately") def test_satisfies_path_win32(mocker: MockerFixture) -> None: - info = copy.deepcopy(CURRENT) + info = copy.deepcopy(_CURRENT) mocker.patch.object(sys, "platform", "win32") info.executable = "/some/path/python.exe" spec = PythonSpec.from_string_spec("python") @@ -300,11 +294,11 @@ def test_satisfies_path_win32(mocker: MockerFixture) -> None: def test_install_path() -> None: - assert isinstance(CURRENT.install_path("purelib"), str) + assert isinstance(_CURRENT.install_path("purelib"), str) def test_install_path_rejects_format_width() -> None: - info = copy.deepcopy(CURRENT) + info = copy.deepcopy(_CURRENT) info.distutils_install = {} info.sysconfig_paths["purelib"] = "{base:>1000000}/lib" with pytest.raises(ValueError, match="format specifiers"): @@ -312,7 +306,7 @@ def test_install_path_rejects_format_width() -> None: def test_system_include() -> None: - result = CURRENT.system_include + result = _CURRENT.system_include assert isinstance(result, str) @@ -328,7 +322,7 @@ def test_system_include() -> None: def test_system_include_fallback( tmp_path: Path, distutils_install: dict[str, str], existing: list[str], expected: str ) -> None: - info = copy.deepcopy(CURRENT) + info = copy.deepcopy(_CURRENT) info.prefix, info.base_prefix, info.real_prefix = str(tmp_path), None, None info.sysconfig_vars = {"base": str(tmp_path)} info.sysconfig_paths = {"include": "{base}/include"} @@ -339,11 +333,11 @@ def test_system_include_fallback( def test_sysconfig_path_missing_key() -> None: - assert not CURRENT.sysconfig_path("nonexistent_key") + assert not _CURRENT.sysconfig_path("nonexistent_key") def test_sysconfig_path_with_config_var() -> None: - result = CURRENT.sysconfig_path("stdlib", {}) + result = _CURRENT.sysconfig_path("stdlib", {}) assert isinstance(result, str) @@ -376,20 +370,20 @@ def test_from_exe_resolve_error(mocker: MockerFixture, caplog: pytest.LogCapture def test_sysconfig_path_no_config_var() -> None: - result = CURRENT.sysconfig_path("stdlib") + result = _CURRENT.sysconfig_path("stdlib") assert isinstance(result, str) assert len(result) > 0 def test_satisfies_abs_spec_path_falls_through() -> None: - info = copy.deepcopy(CURRENT) + info = copy.deepcopy(_CURRENT) spec = PythonSpec("", None, None, None, None, None, "/some/other/python") assert spec.is_abs is True assert info.satisfies(spec, impl_must_match=False) is True def test_satisfies_abs_spec_path_match() -> None: - info = copy.deepcopy(CURRENT) + info = copy.deepcopy(_CURRENT) spec = PythonSpec("", None, None, None, None, None, info.executable) assert info.satisfies(spec, impl_must_match=False) is True @@ -411,19 +405,17 @@ def test_current_system_returns_none_raises(mocker: MockerFixture) -> None: def test_check_exe_from_exe_returns_none(tmp_path: Path, mocker: MockerFixture) -> None: - info = copy.deepcopy(CURRENT) - exe = tmp_path / "python" - exe.touch() + info = copy.deepcopy(_CURRENT) + (tmp_path / "python").touch() mocker.patch.object(PythonInfo, "from_exe", return_value=None) result = info._check_exe(MagicMock(), str(tmp_path), "python", [], dict(os.environ), exact=True) assert result is None def test_check_exe_mismatch_not_exact(tmp_path: Path, mocker: MockerFixture) -> None: - info = copy.deepcopy(CURRENT) - exe = tmp_path / "python" - exe.touch() - other = copy.deepcopy(CURRENT) + info = copy.deepcopy(_CURRENT) + (tmp_path / "python").touch() + other = copy.deepcopy(_CURRENT) other.architecture = 32 if info.architecture == 64 else 64 mocker.patch.object(PythonInfo, "from_exe", return_value=other) discovered: list[PythonInfo] = [] @@ -433,10 +425,9 @@ def test_check_exe_mismatch_not_exact(tmp_path: Path, mocker: MockerFixture) -> def test_check_exe_mismatch_exact(tmp_path: Path, mocker: MockerFixture) -> None: - info = copy.deepcopy(CURRENT) - exe = tmp_path / "python" - exe.touch() - other = copy.deepcopy(CURRENT) + info = copy.deepcopy(_CURRENT) + (tmp_path / "python").touch() + other = copy.deepcopy(_CURRENT) other.architecture = 32 if info.architecture == 64 else 64 mocker.patch.object(PythonInfo, "from_exe", return_value=other) discovered: list[PythonInfo] = [] @@ -446,14 +437,14 @@ def test_check_exe_mismatch_exact(tmp_path: Path, mocker: MockerFixture) -> None def test_find_possible_exe_names_free_threaded() -> None: - info = copy.deepcopy(CURRENT) + info = copy.deepcopy(_CURRENT) info.free_threaded = True names = info._find_possible_exe_names() assert any("t" in n for n in names) def test_possible_base_python_basename() -> None: - info = copy.deepcopy(CURRENT) + info = copy.deepcopy(_CURRENT) info.executable = "/usr/bin/python" info.implementation = "CPython" names = list(info._possible_base()) @@ -462,7 +453,7 @@ def test_possible_base_python_basename() -> None: def test_possible_base_case_sensitive(mocker: MockerFixture) -> None: - info = copy.deepcopy(CURRENT) + info = copy.deepcopy(_CURRENT) info.executable = "/usr/bin/CPython3.12" info.implementation = "CPython" mocker.patch("python_discovery._compat.fs_is_case_sensitive", return_value=True) @@ -474,7 +465,7 @@ def test_possible_base_case_sensitive(mocker: MockerFixture) -> None: def test_possible_base_case_sensitive_upper_equals_base(mocker: MockerFixture) -> None: - info = copy.deepcopy(CURRENT) + info = copy.deepcopy(_CURRENT) info.executable = "/usr/bin/JYTHON" info.implementation = "JYTHON" mocker.patch("python_discovery._compat.fs_is_case_sensitive", return_value=True) @@ -485,11 +476,11 @@ def test_possible_base_case_sensitive_upper_equals_base(mocker: MockerFixture) - def test_resolve_to_system_resolved_from_exe(mocker: MockerFixture, caplog: pytest.LogCaptureFixture) -> None: caplog.set_level(logging.DEBUG) - target = copy.deepcopy(CURRENT) + target = copy.deepcopy(_CURRENT) target.system_executable = "/some/system/python" target.executable = "/some/venv/python" - resolved = copy.deepcopy(CURRENT) + resolved = copy.deepcopy(_CURRENT) resolved.system_executable = "/some/system/python" resolved.executable = "/some/system/python" @@ -502,10 +493,10 @@ def test_from_exe_uses_supplied_environment_for_base_probe( mocker: MockerFixture, monkeypatch: pytest.MonkeyPatch ) -> None: monkeypatch.setenv("PYTHONPATH", "process-only") - target = copy.deepcopy(CURRENT) + target = copy.deepcopy(_CURRENT) target.executable = "venv" target.system_executable = "system" - mapped = copy.deepcopy(CURRENT) + mapped = copy.deepcopy(_CURRENT) mapped.executable = mapped.system_executable = "system" mapped.path = ["mapped"] process_only = copy.deepcopy(mapped) @@ -525,15 +516,15 @@ def test_from_exe_uses_supplied_environment_for_base_probe( def test_resolve_to_system_uses_supplied_environment_for_prefix_probe(mocker: MockerFixture) -> None: - target = copy.deepcopy(CURRENT) + target = copy.deepcopy(_CURRENT) target.system_executable = None target.real_prefix = None target.base_prefix = "/prefix" - mapped = copy.deepcopy(CURRENT) + mapped = copy.deepcopy(_CURRENT) mapped.path = ["mapped"] mapped.system_executable = mapped.executable - fallback = copy.deepcopy(CURRENT) + fallback = copy.deepcopy(_CURRENT) fallback.path = ["process"] fallback.system_executable = fallback.executable mocker.patch.object( @@ -548,7 +539,7 @@ def test_resolve_to_system_uses_supplied_environment_for_prefix_probe(mocker: Mo def test_resolve_to_system_from_exe_returns_none(mocker: MockerFixture) -> None: - target = copy.deepcopy(CURRENT) + target = copy.deepcopy(_CURRENT) target.system_executable = "/some/system/python" target.executable = "/some/venv/python" @@ -565,14 +556,14 @@ def test_resolve_to_system_from_exe_returns_none(mocker: MockerFixture) -> None: ], ) def test_resolve_to_system_leaves_shared_info_untouched(mocker: MockerFixture, system_executable: str | None) -> None: - shared = copy.deepcopy(CURRENT) + shared = copy.deepcopy(_CURRENT) shared.system_executable = shared.executable = "/some/system/python" mocker.patch.object(PythonInfo, "from_exe", return_value=shared) mocker.patch.object(PythonInfo, "discover_exe", return_value=shared) results: list[PythonInfo] = [] for venv in ("/some/venv-a", "/some/venv-b"): - target = copy.deepcopy(CURRENT) + target = copy.deepcopy(_CURRENT) target.system_executable = system_executable target.real_prefix, target.base_prefix, target.executable = None, venv, f"{venv}/python" results.append(PythonInfo.resolve_to_system(None, target)) @@ -585,6 +576,6 @@ def test_resolve_to_system_leaves_shared_info_untouched(mocker: MockerFixture, s def test_resolve_to_system_returns_target_when_executable_unchanged() -> None: - target = copy.deepcopy(CURRENT) + target = copy.deepcopy(_CURRENT) target.system_executable = target.executable = "/some/system/python" assert PythonInfo.resolve_to_system(None, target) is target diff --git a/tests/test_py_spec.py b/tests/test_py_spec.py index d57c26c..def8b30 100644 --- a/tests/test_py_spec.py +++ b/tests/test_py_spec.py @@ -29,8 +29,7 @@ def test_bad_py_spec(text: str) -> None: spec = PythonSpec.from_string_spec(text) assert text in repr(spec) - assert spec.str_spec == text - assert spec.path == text + assert (spec.str_spec, spec.path) == (text, text) content = vars(spec) del content["str_spec"] del content["path"] @@ -82,8 +81,7 @@ def test_specifier_modifiers(text: str, expected: tuple[str, bool | None, bool | def test_py_spec_first_digit_only_major() -> None: spec = PythonSpec.from_string_spec("278") - assert spec.major == 2 - assert spec.minor == 78 + assert (spec.major, spec.minor) == (2, 78) def test_spec_satisfies_path_ok() -> None: @@ -171,8 +169,7 @@ def _version_not_satisfies_pairs() -> list[tuple[str, str]]: temp[patch] += offset if temp[patch] < 0: continue # pragma: no cover - sat = ".".join(str(i) for i in temp) - target.add((req, sat)) + target.add((req, ".".join(str(i) for i in temp))) return sorted(target) @@ -206,14 +203,12 @@ def test_specifier_parsing(text: str, expected: str) -> None: def test_specifier_with_implementation() -> None: spec = PythonSpec.from_string_spec("cpython>=3.12") - assert spec.implementation == "cpython" - assert spec.version_specifier == SpecifierSet.from_string(">=3.12") + assert (spec.implementation, spec.version_specifier) == ("cpython", SpecifierSet.from_string(">=3.12")) def test_specifier_with_graalpy_implementation_alias() -> None: spec = PythonSpec.from_string_spec("graalvm>=3.12") - assert spec.implementation == "graalpy" - assert spec.version_specifier == SpecifierSet.from_string(">=3.12") + assert (spec.implementation, spec.version_specifier) == ("graalpy", SpecifierSet.from_string(">=3.12")) def test_specifier_satisfies_with_partial_information() -> None: @@ -254,8 +249,7 @@ def test_spec_parse_machine(spec_str: str, expected_machine: str | None) -> None ) def test_spec_parse_arch_and_machine_together(spec_str: str, expected_arch: int, expected_machine: str | None) -> None: spec = PythonSpec.from_string_spec(spec_str) - assert spec.architecture == expected_arch - assert spec.machine == expected_machine + assert (spec.architecture, spec.machine) == (expected_arch, expected_machine) @pytest.mark.parametrize( @@ -272,8 +266,7 @@ def test_spec_parse_debug(spec_str: str) -> None: spec = PythonSpec.from_string_spec(spec_str) assert spec.debug is True - assert spec.major == 3 - assert spec.minor == 13 + assert (spec.major, spec.minor) == (3, 13) assert spec.machine is None assert spec.path is None diff --git a/tests/test_py_spec_extra.py b/tests/test_py_spec_extra.py index e8ab73f..cd2aa80 100644 --- a/tests/test_py_spec_extra.py +++ b/tests/test_py_spec_extra.py @@ -75,21 +75,18 @@ def test_get_required_precision_normal() -> None: def test_generate_re_no_threaded() -> None: - spec = PythonSpec.from_string_spec("python3.12") - pat = spec.generate_re(windows=False) + pat = PythonSpec.from_string_spec("python3.12").generate_re(windows=False) assert pat.fullmatch("python3.12") is not None def test_generate_re_with_threaded() -> None: - spec = PythonSpec.from_string_spec("python3.12t") - pat = spec.generate_re(windows=False) + pat = PythonSpec.from_string_spec("python3.12t").generate_re(windows=False) assert pat.fullmatch("python3.12t") is not None @pytest.mark.parametrize("name", ["python3.12-dbg", "python3.12d", "python3.12"]) def test_generate_re_with_debug(name: str) -> None: - spec = PythonSpec.from_string_spec("python3.12-dbg") - pat = spec.generate_re(windows=False) + pat = PythonSpec.from_string_spec("python3.12-dbg").generate_re(windows=False) assert pat.fullmatch(name) is not None @@ -106,8 +103,7 @@ def test_generate_re_with_debug(name: str) -> None: ) @pytest.mark.parametrize("windows", [pytest.param(False, id="posix"), pytest.param(True, id="windows")]) def test_generate_re_all_implementations_matches_variant_names(name: str, windows: bool) -> None: - spec = PythonSpec("", None, None, None, None, None, None) - pat = spec.generate_re(windows=windows, all_implementations=True) + pat = PythonSpec("", None, None, None, None, None, None).generate_re(windows=windows, all_implementations=True) assert pat.fullmatch(f"{name}.exe" if windows else name) is not None @@ -147,8 +143,7 @@ def test_single_digit_version() -> None: def test_two_digit_version() -> None: spec = PythonSpec.from_string_spec("python312") - assert spec.major == 3 - assert spec.minor == 12 + assert (spec.major, spec.minor) == (3, 12) def test_single_digit_major_only() -> None: diff --git a/tests/test_specifier.py b/tests/test_specifier.py index dca3024..36ac508 100644 --- a/tests/test_specifier.py +++ b/tests/test_specifier.py @@ -4,8 +4,6 @@ from python_discovery._specifier import SimpleSpecifier, SimpleSpecifierSet, SimpleVersion -# --- SimpleVersion --- - @pytest.mark.parametrize( ("version_str", "release", "pre_type", "pre_num"), @@ -25,9 +23,7 @@ def test_version_parse( pre_num: int | None, ) -> None: version = SimpleVersion.from_string(version_str) - assert version.release == release - assert version.pre_type == pre_type - assert version.pre_num == pre_num + assert (version.release, version.pre_type, version.pre_num) == (release, pre_type, pre_num) def test_version_invalid_raises() -> None: @@ -100,9 +96,6 @@ def test_version_repr() -> None: assert repr(SimpleVersion.from_string("3.11")) == "SimpleVersion('3.11')" -# --- SimpleSpecifier --- - - def test_specifier_invalid_raises() -> None: with pytest.raises(ValueError, match="Invalid specifier"): SimpleSpecifier.from_string("no_operator") @@ -110,8 +103,7 @@ def test_specifier_invalid_raises() -> None: def test_specifier_parse_gte() -> None: spec = SimpleSpecifier.from_string(">=3.12") - assert spec.operator == ">=" - assert spec.version == SimpleVersion.from_string("3.12") + assert (spec.operator, spec.version) == (">=", SimpleVersion.from_string("3.12")) assert spec.is_wildcard is False @@ -242,9 +234,6 @@ def test_specifier_unknown_operator() -> None: assert spec.contains("3.12.0") is False -# --- SimpleSpecifierSet --- - - @pytest.mark.parametrize( ("spec_str", "version_str", "expected"), [ @@ -263,8 +252,7 @@ def test_specifier_set_contains(spec_str: str, version_str: str, expected: bool) def test_specifier_set_iter() -> None: - spec_set = SimpleSpecifierSet.from_string(">=3.12,<3.14") - specs = list(spec_set) + specs = list(SimpleSpecifierSet.from_string(">=3.12,<3.14")) assert len(specs) == 2 diff --git a/tests/windows/conftest.py b/tests/windows/conftest.py index ebce8b6..672138e 100644 --- a/tests/windows/conftest.py +++ b/tests/windows/conftest.py @@ -16,8 +16,31 @@ from typing_extensions import Self +@pytest.fixture +def _mock_registry(monkeypatch: pytest.MonkeyPatch) -> None: + if sys.platform != "win32": + winreg = _create_winreg_mock() + monkeypatch.setitem(sys.modules, "winreg", winreg) + else: + import winreg + + enum_collect, value_collect, key_open, hive_open = _load_registry_data(winreg) + + monkeypatch.setattr(winreg, "EnumKey", _make_enum_key(enum_collect)) + monkeypatch.setattr(winreg, "QueryValueEx", _make_query_value_ex(value_collect)) + monkeypatch.setattr(winreg, "OpenKeyEx", _make_open_key_ex(key_open, hive_open)) + real_exists = os.path.exists + + def _mock_exists(path: str) -> bool: + if isinstance(path, str) and ("\\" in path or path.startswith(("C:", "Z:"))): + return True + return real_exists(path) + + monkeypatch.setattr("os.path.exists", _mock_exists) + + def _create_winreg_mock() -> ModuleType: - """Create a mock winreg module that works on all platforms.""" + # Create a mock winreg module that works on all platforms. winreg = ModuleType("winreg") winreg.HKEY_CURRENT_USER = 0x80000001 # ty: ignore[unresolved-attribute] winreg.HKEY_LOCAL_MACHINE = 0x80000002 # ty: ignore[unresolved-attribute] @@ -38,7 +61,7 @@ def _load_registry_data( dict[object, dict[str, object]], dict[tuple[object, ...], object], ]: - """Load winreg mock values using the given (possibly mock) winreg module.""" + # Load winreg mock values using the given (possibly mock) winreg module. loc: dict[str, object] = {} glob: dict[str, object] = {"winreg": winreg} mock_value_str = (Path(__file__).parent / "winreg_mock_values.py").read_text(encoding="utf-8") @@ -46,6 +69,16 @@ def _load_registry_data( return loc["enum_collect"], loc["value_collect"], loc["key_open"], loc["hive_open"] # ty: ignore[invalid-return-type] +def _make_enum_key(enum_collect: dict[object, dict[int, object]]) -> object: + def _enum_key(key: object, at: int) -> str: + result = enum_collect[(key.value if isinstance(key, _Key) else key)][at] + if isinstance(result, OSError): + raise result + return result # ty: ignore[invalid-return-type] + + return _enum_key + + class _Key: def __init__(self, value: object) -> None: self.value = value @@ -59,21 +92,9 @@ def __exit__( return None -def _make_enum_key(enum_collect: dict[object, dict[int, object]]) -> object: - def _enum_key(key: object, at: int) -> str: - key_id = key.value if isinstance(key, _Key) else key - result = enum_collect[key_id][at] - if isinstance(result, OSError): - raise result - return result # ty: ignore[invalid-return-type] - - return _enum_key - - def _make_query_value_ex(value_collect: dict[object, dict[str, object]]) -> object: def _query_value_ex(key: object, value_name: str) -> object: - key_id = key.value if isinstance(key, _Key) else key - result = value_collect[key_id][value_name] + result = value_collect[(key.value if isinstance(key, _Key) else key)][value_name] if isinstance(result, OSError): raise result return result @@ -86,8 +107,7 @@ def _make_open_key_ex(key_open: dict[object, dict[str, object]], hive_open: dict def _open_key_ex(*args: object) -> Generator[_Key | object]: if len(args) == 2: key, value = args - key_id = key.value if isinstance(key, _Key) else key - result = _Key(key_open[key_id][value]) + result = _Key(key_open[(key.value if isinstance(key, _Key) else key)][value]) elif len(args) == 4: result = hive_open[args] else: @@ -101,33 +121,33 @@ def _open_key_ex(*args: object) -> Generator[_Key | object]: @pytest.fixture -def _mock_registry(monkeypatch: pytest.MonkeyPatch) -> None: - if sys.platform != "win32": - winreg = _create_winreg_mock() - monkeypatch.setitem(sys.modules, "winreg", winreg) - else: - import winreg - - enum_collect, value_collect, key_open, hive_open = _load_registry_data(winreg) - - monkeypatch.setattr(winreg, "EnumKey", _make_enum_key(enum_collect)) - monkeypatch.setattr(winreg, "QueryValueEx", _make_query_value_ex(value_collect)) - monkeypatch.setattr(winreg, "OpenKeyEx", _make_open_key_ex(key_open, hive_open)) - real_exists = os.path.exists - - def _mock_exists(path: str) -> bool: - if isinstance(path, str) and ("\\" in path or path.startswith(("C:", "Z:"))): - return True - return real_exists(path) +def _populate_pyinfo_cache(monkeypatch: pytest.MonkeyPatch) -> None: + from python_discovery._cached_py_info import _CACHE - monkeypatch.setattr("os.path.exists", _mock_exists) + python_core_path = "C:\\Users\\user\\AppData\\Local\\Programs\\Python" + interpreters = [ + ("ContinuumAnalytics", 3, 10, 32, False, "C:\\Users\\user\\Miniconda3\\python.exe"), + ("ContinuumAnalytics", 3, 10, 64, False, "C:\\Users\\user\\Miniconda3-64\\python.exe"), + ("PythonCore", 3, 9, 64, False, f"{python_core_path}\\Python36\\python.exe"), + ("PythonCore", 3, 9, 64, False, f"{python_core_path}\\Python36\\python.exe"), + ("PythonCore", 3, 5, 64, False, f"{python_core_path}\\Python35\\python.exe"), + ("PythonCore", 3, 9, 64, False, f"{python_core_path}\\Python36\\python.exe"), + ("PythonCore", 3, 7, 32, False, f"{python_core_path}\\Python37-32\\python.exe"), + ("PythonCore", 3, 12, 64, False, f"{python_core_path}\\Python312\\python.exe"), + ("PythonCore", 3, 13, 64, True, f"{python_core_path}\\Python313\\python3.13t.exe"), + ("PythonCore", 2, 7, 64, False, "C:\\Python27\\python.exe"), + ("PythonCore", 3, 4, 64, False, "C:\\Python34\\python.exe"), + ("CompanyA", 3, 6, 64, False, "Z:\\CompanyA\\Python\\3.6\\python.exe"), + ] + for _, major, minor, arch, threaded, exe in interpreters: + info = _mock_pyinfo(major, minor, arch, exe, threaded) + monkeypatch.setitem(_CACHE, (Path(info.executable), None), info) -def _mock_pyinfo(major: int, minor: int, arch: int, exe: str, threaded: bool = False) -> MagicMock: +def _mock_pyinfo(major: int, minor: int, arch: int, exe: str, threaded: bool) -> MagicMock: from python_discovery._py_info import VersionInfo - info = MagicMock() - info.base_prefix = str(Path(exe).parent) + info = MagicMock(base_prefix=str(Path(exe).parent)) info.executable = info.original_executable = info.system_executable = exe info.implementation = "CPython" info.architecture = arch @@ -149,27 +169,3 @@ def satisfies(spec: object, _impl_must_match: bool = False) -> bool: info.satisfies = satisfies return info - - -@pytest.fixture -def _populate_pyinfo_cache(monkeypatch: pytest.MonkeyPatch) -> None: - from python_discovery._cached_py_info import _CACHE - - python_core_path = "C:\\Users\\user\\AppData\\Local\\Programs\\Python" - interpreters = [ - ("ContinuumAnalytics", 3, 10, 32, False, "C:\\Users\\user\\Miniconda3\\python.exe"), - ("ContinuumAnalytics", 3, 10, 64, False, "C:\\Users\\user\\Miniconda3-64\\python.exe"), - ("PythonCore", 3, 9, 64, False, f"{python_core_path}\\Python36\\python.exe"), - ("PythonCore", 3, 9, 64, False, f"{python_core_path}\\Python36\\python.exe"), - ("PythonCore", 3, 5, 64, False, f"{python_core_path}\\Python35\\python.exe"), - ("PythonCore", 3, 9, 64, False, f"{python_core_path}\\Python36\\python.exe"), - ("PythonCore", 3, 7, 32, False, f"{python_core_path}\\Python37-32\\python.exe"), - ("PythonCore", 3, 12, 64, False, f"{python_core_path}\\Python312\\python.exe"), - ("PythonCore", 3, 13, 64, True, f"{python_core_path}\\Python313\\python3.13t.exe"), - ("PythonCore", 2, 7, 64, False, "C:\\Python27\\python.exe"), - ("PythonCore", 3, 4, 64, False, "C:\\Python34\\python.exe"), - ("CompanyA", 3, 6, 64, False, "Z:\\CompanyA\\Python\\3.6\\python.exe"), - ] - for _, major, minor, arch, threaded, exe in interpreters: - info = _mock_pyinfo(major, minor, arch, exe, threaded) - monkeypatch.setitem(_CACHE, (Path(info.executable), None), info) diff --git a/tests/windows/test_windows.py b/tests/windows/test_windows.py index 65c0789..f185ccc 100644 --- a/tests/windows/test_windows.py +++ b/tests/windows/test_windows.py @@ -42,6 +42,5 @@ def test_discovery_via_path_literal_symlink(tmp_path: Path) -> None: def test_propose_interpreters(string_spec: str, expected_exe: str) -> None: from python_discovery._windows import propose_interpreters - spec = PythonSpec.from_string_spec(string_spec) - interpreter = next(propose_interpreters(spec=spec, cache=None, env={})) + interpreter = next(propose_interpreters(spec=PythonSpec.from_string_spec(string_spec), cache=None, env={})) assert interpreter.executable == expected_exe From a01906978c108e269e77e13fd58f6c7354dc8e06 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Fri, 6 Nov 2026 23:53:33 -0800 Subject: [PATCH 03/25] =?UTF-8?q?=F0=9F=90=9B=20fix(specifier):=20drop=20t?= =?UTF-8?q?he=20repeated=20frozen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The autofix added frozen=True beside **DC_KW, which already sets it, so the module raised TypeError on import. --- src/python_discovery/_specifier.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/python_discovery/_specifier.py b/src/python_discovery/_specifier.py index 15a489d..85a549f 100644 --- a/src/python_discovery/_specifier.py +++ b/src/python_discovery/_specifier.py @@ -39,7 +39,7 @@ ) -@dataclass(**DC_KW, frozen=True) +@dataclass(**DC_KW) class SimpleVersion: """ Simple PEP 440-like version parser using only standard library. @@ -129,7 +129,7 @@ def __repr__(self) -> str: return f"SimpleVersion('{self.version_str}')" -@dataclass(**DC_KW, frozen=True) +@dataclass(**DC_KW) class SimpleSpecifier: """ Simple PEP 440-like version specifier using only standard library. @@ -260,7 +260,7 @@ def _check_compatible_release(self, candidate: SimpleVersion) -> bool: return candidate.release[:prefix] == self.version.release[:prefix] -@dataclass(**DC_KW, frozen=True) +@dataclass(**DC_KW) class SimpleSpecifierSet: """ Simple PEP 440-like specifier set using only standard library. From d26c02529f0fe97c02e3dec0aca5681910868f2b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Fri, 6 Nov 2026 23:58:28 -0800 Subject: [PATCH 04/25] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor(discovery):?= =?UTF-8?q?=20drop=20reST=20from=20a=20comment?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The autofix turned the propose_interpreters docstring into comments and kept its :param: lines, which no tool reads. The enumeration note is the part a reader needs. --- src/python_discovery/_discovery.py | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/src/python_discovery/_discovery.py b/src/python_discovery/_discovery.py index 3f913fc..941e60c 100644 --- a/src/python_discovery/_discovery.py +++ b/src/python_discovery/_discovery.py @@ -90,14 +90,7 @@ def propose_interpreters( *, all_implementations: bool = False, ) -> Generator[tuple[PythonInfo | None, bool], None, None]: - # Yield ``(interpreter, impl_must_match)`` candidates for *spec*. - # - # :param spec: the parsed interpreter specification to match against. - # :param try_first_with: executable paths to probe before the standard search. - # :param cache: interpreter metadata cache; when ``None`` results are not cached. - # :param env: environment mapping for ``PATH`` and uv store lookup; defaults to :data:`os.environ`. - # :param all_implementations: when ``True`` and *spec* does not constrain the implementation, also surface - # non-CPython binaries on ``PATH`` and under UV's install directory. Used by enumeration APIs. + # enumeration APIs pass all_implementations to also surface non-CPython binaries on PATH and under UV's install dir env = os.environ if env is None else env tested_exes: set[str] = set() if spec.is_abs and spec.path is not None: From 9ae59bd76de282014df3448dd75dac50c364f47c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Sat, 7 Nov 2026 07:21:33 -0800 Subject: [PATCH 05/25] =?UTF-8?q?=F0=9F=94=A7=20build(lint):=20give=20shfm?= =?UTF-8?q?t=20and=20shellcheck=20prek=20tiers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .pre-commit-config.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index de2aae0..2222e7f 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -62,11 +62,13 @@ repos: rev: 479be5958357ba5ab65ce47172117348516860e9 # frozen: v3.14.1-1 hooks: - id: shfmt + priority: 3 args: ["-i", "2"] - repo: https://github.com/shellcheck-py/shellcheck-py rev: 745eface02aef23e168a8afb6b5737818efbea95 # frozen: v0.11.0.1 hooks: - id: shellcheck + priority: 8 - repo: https://github.com/LilSpazJoekp/docstrfmt rev: c1813841673cdcd6cf602dde1edb78a5d5e45235 # frozen: v2.2.1 hooks: From b5ed2f9df24324ef7fccaa2ef3378c2e33c1d87f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Sat, 7 Nov 2026 07:26:51 -0800 Subject: [PATCH 06/25] =?UTF-8?q?=F0=9F=93=9D=20docs:=20explain=20existing?= =?UTF-8?q?=20lint=20suppressions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit simplint wants a reason after every suppression code. The reasons name the constraint behind each directive; no directive changed. --- docs/conf.py | 2 +- src/python_discovery/_cache.py | 6 ++-- src/python_discovery/_cached_py_info.py | 10 +++--- src/python_discovery/_discovery.py | 8 ++--- src/python_discovery/_py_info.py | 20 +++++------ src/python_discovery/_py_info_collect.py | 12 +++---- src/python_discovery/_py_spec.py | 6 ++-- src/python_discovery/_specifier.py | 10 +++--- src/python_discovery/_windows/_pep514.py | 40 +++++++++++----------- src/python_discovery/_windows/_propose.py | 2 +- tests/conftest.py | 2 +- tests/py_info/test_py_info.py | 2 +- tests/py_info/test_py_info_exe_based_of.py | 6 ++-- tests/test_cached_py_info.py | 2 +- tests/test_discovery.py | 6 ++-- tests/test_discovery_extra.py | 2 +- tests/test_py_spec.py | 2 +- tests/test_specifier.py | 10 +++--- tests/windows/conftest.py | 22 ++++++------ 19 files changed, 85 insertions(+), 85 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index b2de111..c297a7b 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -14,7 +14,7 @@ repository: Final[str] = f"https://github.com/{company}/{name}" version = ".".join(__version__.split(".")[:2]) release = __version__ -copyright: Final[str] = f"2026-{datetime.now(tz=timezone.utc).year}, {company}" # ruff:ignore[builtin-variable-shadowing] +copyright: Final[str] = f"2026-{datetime.now(tz=timezone.utc).year}, {company}" # ruff:ignore[builtin-variable-shadowing] Sphinx reads the name `copyright` extensions = [ "sphinx_llm.txt", diff --git a/src/python_discovery/_cache.py b/src/python_discovery/_cache.py index 630dcdd..be7cece 100644 --- a/src/python_discovery/_cache.py +++ b/src/python_discovery/_cache.py @@ -215,7 +215,7 @@ def write(self, content: dict) -> None: @contextmanager def locked(self) -> Generator[None]: - from filelock import FileLock # ruff:ignore[import-outside-top-level] + from filelock import FileLock # ruff:ignore[import-outside-top-level] filelock is needed only when locking self._folder.mkdir(parents=True, exist_ok=True) if os.name == "posix": @@ -234,7 +234,7 @@ class NoOpContentStore(ContentStore): def exists(self) -> bool: # ruff:ignore[no-self-use] return False - def read(self) -> dict | None: # ruff:ignore[no-self-use] + def read(self) -> dict | None: # ruff:ignore[no-self-use] keeps the cache interface uniform across implementations return None def write(self, content: dict) -> None: @@ -244,7 +244,7 @@ def remove(self) -> None: pass @contextmanager - def locked(self) -> Generator[None]: # ruff:ignore[no-self-use] + def locked(self) -> Generator[None]: # ruff:ignore[no-self-use] keeps the cache interface uniform across implementations yield diff --git a/src/python_discovery/_cached_py_info.py b/src/python_discovery/_cached_py_info.py index 30fbd4b..88ed179 100644 --- a/src/python_discovery/_cached_py_info.py +++ b/src/python_discovery/_cached_py_info.py @@ -18,7 +18,7 @@ from pathlib import Path from select import select from shlex import quote -from subprocess import DEVNULL, PIPE, Popen, TimeoutExpired, list2cmdline # ruff:ignore[suspicious-subprocess-import] +from subprocess import DEVNULL, PIPE, Popen, TimeoutExpired, list2cmdline # ruff:ignore[suspicious-subprocess-import] list2cmdline only formats the command for logging from threading import Event, Thread from time import monotonic from typing import TYPE_CHECKING, Final @@ -53,7 +53,7 @@ class CacheEntryMeta(TypedDict): _MAX_QUERY_OUTPUT: Final[int] = 1 << 20 -def from_exe( # ruff:ignore[too-many-arguments] +def from_exe( # ruff:ignore[too-many-arguments] each argument is a separate probe input cls: type[PythonInfo], cache: PyInfoCache | None, exe: str, @@ -80,7 +80,7 @@ def from_exe( # ruff:ignore[too-many-arguments] return result -def _get_from_cache( # ruff:ignore[too-many-arguments] +def _get_from_cache( # ruff:ignore[too-many-arguments] each argument is a separate probe input cls: type[PythonInfo], cache: PyInfoCache | None, exe: str, @@ -104,7 +104,7 @@ def _get_from_cache( # ruff:ignore[too-many-arguments] return result -def _get_via_file_cache( # ruff:ignore[too-many-arguments] +def _get_via_file_cache( # ruff:ignore[too-many-arguments] each argument is a separate probe input cls: type[PythonInfo], cache: PyInfoCache | None, path: Path, @@ -207,7 +207,7 @@ def _run_subprocess( env["PYTHONUTF8"] = "1" _LOGGER.debug("get interpreter info via cmd: %s", cmd if isinstance(cmd, str) else LogCmd(cmd)) try: - process = Popen( # ruff:ignore[subprocess-without-shell-equals-true] + process = Popen( # ruff:ignore[subprocess-without-shell-equals-true] argv list without a shell, so no shell injection cmd, universal_newlines=True, stdin=DEVNULL, diff --git a/src/python_discovery/_discovery.py b/src/python_discovery/_discovery.py index 941e60c..cc9b4f8 100644 --- a/src/python_discovery/_discovery.py +++ b/src/python_discovery/_discovery.py @@ -67,7 +67,7 @@ def _find_interpreter( proposed_paths: set[tuple[str | None, bool]] = set() env = os.environ if env is None else env for interpreter, impl_must_match in propose_interpreters(spec, try_first_with, cache, env): - if interpreter is None: # pragma: no cover + if interpreter is None: # pragma: no cover, defensive guard the proposer does not currently trigger continue proposed_key = interpreter.system_executable, impl_must_match if proposed_key in proposed_paths: @@ -149,7 +149,7 @@ def _propose_current_and_windows( yield current_python, True if IS_WIN: # pragma: win32 cover - from ._windows import propose_interpreters as win_propose # ruff:ignore[import-outside-top-level] + from ._windows import propose_interpreters as win_propose # ruff:ignore[import-outside-top-level] the module is Windows-only and imported only there for interpreter in win_propose(spec, cache, env): if _is_new_exe(str(interpreter.executable), tested_exes): @@ -426,7 +426,7 @@ def _uv_version_matches(key: _UvKey, spec: PythonSpec) -> bool: precision = numeric_version.count(".") + 1 release = ".".join(str(part) for part in key.version.release[:precision]) if key.version.pre_type is not None and ( - precision == 3 or specifier.version.pre_type is not None # ruff:ignore[magic-value-comparison] + precision == 3 or specifier.version.pre_type is not None # ruff:ignore[magic-value-comparison] 3 is the precision of a full version tuple ): release = f"{release}{key.version.pre_type}{key.version.pre_num}" if not specifier.contains(release): @@ -524,7 +524,7 @@ def iter_interpreters( yield from _iter_for_spec(spec_str, first_with, cache, env_map, predicate, seen) -def _iter_for_spec( # ruff:ignore[too-many-arguments, too-many-positional-arguments] +def _iter_for_spec( # ruff:ignore[too-many-arguments, too-many-positional-arguments] each argument is a separate spec input spec_str: str | None, try_first_with: tuple[str, ...], cache: PyInfoCache | None, diff --git a/src/python_discovery/_py_info.py b/src/python_discovery/_py_info.py index d7d6646..34832cf 100644 --- a/src/python_discovery/_py_info.py +++ b/src/python_discovery/_py_info.py @@ -44,7 +44,7 @@ def _get_path_extensions() -> list[str]: EXTENSIONS: Final[list[str]] = _get_path_extensions() -class PythonInfo: # ruff:ignore[too-many-public-methods] +class PythonInfo: # ruff:ignore[too-many-public-methods] PythonInfo is the public query surface; splitting it breaks callers """ Contains information for a Python interpreter. @@ -163,7 +163,7 @@ def current(cls, cache: PyInfoCache | None = None) -> PythonInfo: return cls._current @classmethod - def from_exe( # ruff:ignore[too-many-arguments] + def from_exe( # ruff:ignore[too-many-arguments] each argument is a separate probe input cls, exe: str, cache: PyInfoCache | None = None, @@ -195,7 +195,7 @@ def from_exe( # ruff:ignore[too-many-arguments] ) @classmethod - def _from_exe_with_args( # ruff:ignore[too-many-arguments] + def _from_exe_with_args( # ruff:ignore[too-many-arguments] each argument is a separate probe input cls, exe: str, cache: PyInfoCache | None = None, @@ -467,12 +467,12 @@ def clear_cache(cls, cache: PyInfoCache) -> None: :param cache: the cache store to clear. """ - from ._cached_py_info import clear # ruff:ignore[import-outside-top-level] + from ._cached_py_info import clear # ruff:ignore[import-outside-top-level] deferred import avoids a cycle with _cached_py_info clear(cache) cls._cache_exe_discovery.clear() - def satisfies(self, spec: PythonSpec, *, impl_must_match: bool) -> bool: # ruff:ignore[too-many-return-statements] + def satisfies(self, spec: PythonSpec, *, impl_must_match: bool) -> bool: # ruff:ignore[too-many-return-statements] one return per distinct satisfaction outcome """ Check if a given specification can be satisfied by this python interpreter instance. @@ -515,7 +515,7 @@ def _satisfies_implementation(self, spec: PythonSpec) -> bool: return spec.implementation is None or spec.implementation.lower() == self.implementation.lower() def _satisfies_version_specifier(self, spec: PythonSpec) -> bool: - if spec.version_specifier is None: # pragma: no cover + if spec.version_specifier is None: # pragma: no cover, defensive guard for specs without a version specifier return True version_info = self.version_info for specifier in spec.version_specifier: @@ -530,7 +530,7 @@ def _satisfies_version_specifier(self, spec: PythonSpec) -> bool: release = ".".join(str(c) for c in [version_info.major, version_info.minor, version_info.micro][:precision]) if ( version_info.releaselevel != "final" - and (precision == 3 or specifier.version.pre_type is not None) # ruff:ignore[magic-value-comparison] + and (precision == 3 or specifier.version.pre_type is not None) # ruff:ignore[magic-value-comparison] 3 is the precision of a full version tuple and (suffix := {"alpha": "a", "beta": "b", "candidate": "rc"}.get(version_info.releaselevel)) ): release = f"{release}{suffix}{version_info.serial}" @@ -624,9 +624,9 @@ def _possible_base(self) -> Generator[str, None, None]: for base in possible_base: lower = base.lower() yield lower - from ._compat import fs_is_case_sensitive # ruff:ignore[import-outside-top-level] + from ._compat import fs_is_case_sensitive # ruff:ignore[import-outside-top-level] imported only when this path is walked - if fs_is_case_sensitive(): # pragma: no branch + if fs_is_case_sensitive(): # pragma: no branch, the branch depends on the host filesystem if base != lower: yield base if (upper := base.upper()) != base: @@ -648,7 +648,7 @@ def _find_possible_folders(self, inside_folder: str) -> list[str]: candidate_folder[inside_folder] = None return [i for i in candidate_folder if os.path.exists(i)] - def _check_exe( # ruff:ignore[too-many-arguments] + def _check_exe( # ruff:ignore[too-many-arguments] each argument is a separate probe input self, cache: PyInfoCache | None, folder: str, diff --git a/src/python_discovery/_py_info_collect.py b/src/python_discovery/_py_info_collect.py index 9aab788..67a756f 100644 --- a/src/python_discovery/_py_info_collect.py +++ b/src/python_discovery/_py_info_collect.py @@ -244,11 +244,11 @@ def _distutils_install(): # pragma: <3.11 cover # 3.11+ uses the "venv" scheme with warnings.catch_warnings(): # disable warning for PEP-632 warnings.simplefilter("ignore") try: - # ruff:ignore[import-outside-top-level] - from distutils import dist # ty: ignore[unresolved-import] + # ruff:ignore[import-outside-top-level] distutils is optional and removed in newer Pythons + from distutils import dist # ty: ignore[unresolved-import] distutils is removed from the stdlib in Python 3.12 - # ruff:ignore[import-outside-top-level] - from distutils.command.install import SCHEME_KEYS # ty: ignore[unresolved-import] + # ruff:ignore[import-outside-top-level] distutils is optional and removed in newer Pythons + from distutils.command.install import SCHEME_KEYS # ty: ignore[unresolved-import] distutils is removed from the stdlib in Python 3.12 except ImportError: # pragma: no cover # if removed or not installed ignore return {} @@ -323,7 +323,7 @@ def readprofile(self, *_args, **_kwargs): # ruff:ignore[no-self-use] # Tk overr return tcl_lib, tk_lib @staticmethod - def _resolve_tk_lib(tcl, tcl_lib): # pragma: no cover + def _resolve_tk_lib(tcl, tcl_lib): # pragma: no cover, Tcl probing needs a Tk install the test runners lack # Resolve the TK library path by direct query or path construction. tk_lib = PythonInfoCollector._query_tk_library(tcl) if tk_lib is not None: @@ -340,7 +340,7 @@ def _resolve_tk_lib(tcl, tcl_lib): # pragma: no cover return None @staticmethod - def _query_tk_library(tcl): # pragma: no cover + def _query_tk_library(tcl): # pragma: no cover, Tcl probing needs a Tk install the test runners lack # Try to get the TK library path directly from Tcl. import tkinter as tk # ruff:ignore[import-outside-top-level] # novermin # unreached below the version gate diff --git a/src/python_discovery/_py_spec.py b/src/python_discovery/_py_spec.py index 9bf8765..1bec978 100644 --- a/src/python_discovery/_py_spec.py +++ b/src/python_discovery/_py_spec.py @@ -60,7 +60,7 @@ def _parse_version_parts(version: str) -> tuple[int | None, int | None, int | No raise ValueError(msg) if len(versions) == _MAX_VERSION_PARTS: return versions[0], versions[1], versions[2] - if len(versions) == 2: # ruff:ignore[magic-value-comparison] + if len(versions) == 2: # ruff:ignore[magic-value-comparison] a two-part version is major.minor return versions[0], versions[1], None version_data = versions[0] return int(str(version_data)[0]), (int(str(version_data)[1:]) if version_data > _SINGLE_DIGIT_MAX else None), None @@ -144,7 +144,7 @@ class PythonSpec: constraints, or ``None``. """ - def __init__( # ruff:ignore[too-many-arguments, too-many-positional-arguments] + def __init__( # ruff:ignore[too-many-arguments, too-many-positional-arguments] making the arguments keyword-only breaks positional callers self, str_spec: str, implementation: str | None, @@ -235,7 +235,7 @@ def generate_re(self, *, windows: bool, all_implementations: bool = False) -> re flags=re.IGNORECASE, ) - def satisfies(self, spec: PythonSpec) -> bool: # ruff:ignore[too-many-return-statements] + def satisfies(self, spec: PythonSpec) -> bool: # ruff:ignore[too-many-return-statements] one return per distinct satisfaction outcome """ Check if this spec is compatible with the given *spec* (e.g. PEP-514 on Windows). diff --git a/src/python_discovery/_specifier.py b/src/python_discovery/_specifier.py index 85a549f..56b96cc 100644 --- a/src/python_discovery/_specifier.py +++ b/src/python_discovery/_specifier.py @@ -95,7 +95,7 @@ def __eq__(self, other: object) -> bool: def __hash__(self) -> int: return hash((self.release, self.pre_type, self.pre_num)) - def __lt__(self, other: object) -> bool: # ruff:ignore[too-many-return-statements] + def __lt__(self, other: object) -> bool: # ruff:ignore[too-many-return-statements] one return per distinct comparison outcome if not isinstance(other, SimpleVersion): return NotImplemented if self.release != other.release: @@ -214,8 +214,8 @@ def contains(self, version_str: str) -> bool: return self._check_standard(candidate) def _check_wildcard(self, candidate: SimpleVersion) -> bool: - if self.version is None: # pragma: no branch - return False # pragma: no cover + if self.version is None: # pragma: no branch, defensive guard; the constructors always set the version + return False # pragma: no cover, defensive guard; the constructors always set the version if self.operator == "==": return candidate.release[: self.wildcard_precision] == self.version.release[: self.wildcard_precision] if self.operator == "!=": @@ -223,8 +223,8 @@ def _check_wildcard(self, candidate: SimpleVersion) -> bool: return False def _check_standard(self, candidate: SimpleVersion) -> bool: - if self.version is None: # pragma: no branch - return False # pragma: no cover + if self.version is None: # pragma: no branch, defensive guard; the constructors always set the version + return False # pragma: no cover, defensive guard; the constructors always set the version if self.operator == "===": return str(candidate) == str(self.version) if self.operator == "~=": diff --git a/src/python_discovery/_windows/_pep514.py b/src/python_discovery/_windows/_pep514.py index a8234f7..4fe645a 100644 --- a/src/python_discovery/_windows/_pep514.py +++ b/src/python_discovery/_windows/_pep514.py @@ -48,28 +48,28 @@ ) -def _enum_keys(key: Any) -> Generator[str, None, None]: # ruff:ignore[any-type] +def _enum_keys(key: Any) -> Generator[str, None, None]: # ruff:ignore[any-type] winreg handles have no importable type on other platforms at = 0 while True: try: - yield winreg.EnumKey(key, at) # ty: ignore[unresolved-attribute] + yield winreg.EnumKey(key, at) # ty: ignore[unresolved-attribute] winreg is Windows-only, so type checkers cannot resolve it except OSError: break at += 1 -def get_value(key: Any, value_name: str | None) -> Any: # ruff:ignore[any-type] +def get_value(key: Any, value_name: str | None) -> Any: # ruff:ignore[any-type] winreg handles have no importable type on other platforms try: - return winreg.QueryValueEx(key, value_name)[0] # ty: ignore[unresolved-attribute] + return winreg.QueryValueEx(key, value_name)[0] # ty: ignore[unresolved-attribute] winreg is Windows-only, so type checkers cannot resolve it except OSError: return None def discover_pythons() -> Generator[_RegistrySpec, None, None]: for hive, hive_name, key, flags, default_arch in [ - (winreg.HKEY_CURRENT_USER, "HKEY_CURRENT_USER", r"Software\Python", 0, 64), # ty: ignore[unresolved-attribute] - (winreg.HKEY_LOCAL_MACHINE, "HKEY_LOCAL_MACHINE", r"Software\Python", winreg.KEY_WOW64_64KEY, 64), # ty: ignore[unresolved-attribute] - (winreg.HKEY_LOCAL_MACHINE, "HKEY_LOCAL_MACHINE", r"Software\Python", winreg.KEY_WOW64_32KEY, 32), # ty: ignore[unresolved-attribute] + (winreg.HKEY_CURRENT_USER, "HKEY_CURRENT_USER", r"Software\Python", 0, 64), # ty: ignore[unresolved-attribute] winreg is Windows-only, so type checkers cannot resolve it + (winreg.HKEY_LOCAL_MACHINE, "HKEY_LOCAL_MACHINE", r"Software\Python", winreg.KEY_WOW64_64KEY, 64), # ty: ignore[unresolved-attribute] winreg is Windows-only, so type checkers cannot resolve it + (winreg.HKEY_LOCAL_MACHINE, "HKEY_LOCAL_MACHINE", r"Software\Python", winreg.KEY_WOW64_32KEY, 32), # ty: ignore[unresolved-attribute] winreg is Windows-only, so type checkers cannot resolve it ]: yield from _process_set(hive, hive_name, key, flags, default_arch) @@ -82,7 +82,7 @@ def _process_set( default_arch: int, ) -> Generator[_RegistrySpec, None, None]: try: - with winreg.OpenKeyEx(hive, key, 0, winreg.KEY_READ | flags) as root_key: # ty: ignore[unresolved-attribute] + with winreg.OpenKeyEx(hive, key, 0, winreg.KEY_READ | flags) as root_key: # ty: ignore[unresolved-attribute] winreg is Windows-only, so type checkers cannot resolve it for company in _enum_keys(root_key): if company == "PyLauncher": # reserved continue @@ -94,17 +94,17 @@ def _process_set( def _process_company( hive_name: str, company: str, - root_key: Any, # ruff:ignore[any-type] + root_key: Any, # ruff:ignore[any-type] winreg handles have no importable type on other platforms default_arch: int, ) -> Generator[_RegistrySpec, None, None]: - with winreg.OpenKeyEx(root_key, company) as company_key: # ty: ignore[unresolved-attribute] + with winreg.OpenKeyEx(root_key, company) as company_key: # ty: ignore[unresolved-attribute] winreg is Windows-only, so type checkers cannot resolve it for tag in _enum_keys(company_key): if (spec := _process_tag(hive_name, company, company_key, tag, default_arch)) is not None: yield spec -def _process_tag(hive_name: str, company: str, company_key: Any, tag: str, default_arch: int) -> _RegistrySpec | None: # ruff:ignore[any-type] - with winreg.OpenKeyEx(company_key, tag) as tag_key: # ty: ignore[unresolved-attribute] +def _process_tag(hive_name: str, company: str, company_key: Any, tag: str, default_arch: int) -> _RegistrySpec | None: # ruff:ignore[any-type] winreg handles have no importable type on other platforms + with winreg.OpenKeyEx(company_key, tag) as tag_key: # ty: ignore[unresolved-attribute] winreg is Windows-only, so type checkers cannot resolve it if ( version := _load_version_data(hive_name, company, tag, tag_key) ) is not None: # if failed to get version bail @@ -118,10 +118,10 @@ def _process_tag(hive_name: str, company: str, company_key: Any, tag: str, defau return None -def load_exe(hive_name: str, company: str, company_key: Any, tag: str) -> tuple[str, str | None] | None: # ruff:ignore[any-type] +def load_exe(hive_name: str, company: str, company_key: Any, tag: str) -> tuple[str, str | None] | None: # ruff:ignore[any-type] winreg handles have no importable type on other platforms key_path = f"{hive_name}/{company}/{tag}" try: - with winreg.OpenKeyEx(company_key, rf"{tag}\InstallPath") as ip_key, ip_key: # ty: ignore[unresolved-attribute] + with winreg.OpenKeyEx(company_key, rf"{tag}\InstallPath") as ip_key, ip_key: # ty: ignore[unresolved-attribute] winreg is Windows-only, so type checkers cannot resolve it if (exe := _resolve_exe(ip_key, key_path)) is not None and os.path.exists(exe): if (args := get_value(ip_key, "ExecutableArguments")) is None or isinstance(args, str): return exe, args if args and args.strip() else None @@ -133,7 +133,7 @@ def load_exe(hive_name: str, company: str, company_key: Any, tag: str) -> tuple[ return None -def _resolve_exe(ip_key: Any, key_path: str) -> str | None: # ruff:ignore[any-type] +def _resolve_exe(ip_key: Any, key_path: str) -> str | None: # ruff:ignore[any-type] winreg handles have no importable type on other platforms if (exe := get_value(ip_key, "ExecutablePath")) is None: if (exe := get_value(ip_key, None)) is None: _msg(key_path, "no ExecutablePath or default for it") @@ -146,7 +146,7 @@ def _resolve_exe(ip_key: Any, key_path: str) -> str | None: # ruff:ignore[any-t return exe -def _load_arch_data(hive_name: str, company: str, tag: str, tag_key: Any, default_arch: int) -> int | None: # ruff:ignore[any-type] +def _load_arch_data(hive_name: str, company: str, tag: str, tag_key: Any, default_arch: int) -> int | None: # ruff:ignore[any-type] winreg handles have no importable type on other platforms if (arch_str := get_value(tag_key, "SysArchitecture")) is not None: key_path = f"{hive_name}/{company}/{tag}/SysArchitecture" try: @@ -156,7 +156,7 @@ def _load_arch_data(hive_name: str, company: str, tag: str, tag_key: Any, defaul return default_arch -def parse_arch(arch_str: Any) -> int: # ruff:ignore[any-type] +def parse_arch(arch_str: Any) -> int: # ruff:ignore[any-type] winreg handles have no importable type on other platforms if isinstance(arch_str, str): if match := _ARCH_RE.match(arch_str): return int(next(iter(match.groups()))) @@ -170,7 +170,7 @@ def _load_version_data( hive_name: str, company: str, tag: str, - tag_key: Any, # ruff:ignore[any-type] + tag_key: Any, # ruff:ignore[any-type] winreg handles have no importable type on other platforms ) -> tuple[int | None, int | None, int | None] | None: for candidate, key_path in [ (get_value(tag_key, "SysVersion"), f"{hive_name}/{company}/{tag}/SysVersion"), @@ -184,7 +184,7 @@ def _load_version_data( return None -def parse_version(version_str: Any) -> tuple[int | None, int | None, int | None]: # ruff:ignore[any-type] +def parse_version(version_str: Any) -> tuple[int | None, int | None, int | None]: # ruff:ignore[any-type] winreg handles have no importable type on other platforms if isinstance(version_str, str): if match := _VERSION_RE.match(version_str): g1, g2, g3 = match.groups() @@ -199,7 +199,7 @@ def parse_version(version_str: Any) -> tuple[int | None, int | None, int | None] raise ValueError(error) -def _load_threaded(hive_name: str, company: str, tag: str, tag_key: Any) -> bool: # ruff:ignore[any-type] +def _load_threaded(hive_name: str, company: str, tag: str, tag_key: Any) -> bool: # ruff:ignore[any-type] winreg handles have no importable type on other platforms if (display_name := get_value(tag_key, "DisplayName")) is not None: if isinstance(display_name, str): if "freethreaded" in display_name.lower(): diff --git a/src/python_discovery/_windows/_propose.py b/src/python_discovery/_windows/_propose.py index 1585a6e..bfe500d 100644 --- a/src/python_discovery/_windows/_propose.py +++ b/src/python_discovery/_windows/_propose.py @@ -37,7 +37,7 @@ def propose_interpreters( skip_pre_filter = implementation.lower() != "cpython" registry_spec = PythonSpec("", implementation, major, minor, None, arch, exe, free_threaded=threaded) if skip_pre_filter or registry_spec.satisfies(spec): - interpreter = Pep514PythonInfo._from_exe_with_args( # ruff:ignore[private-member-access] + interpreter = Pep514PythonInfo._from_exe_with_args( # ruff:ignore[private-member-access] same package; the Windows proposer builds the same info object exe, cache, env=env, diff --git a/tests/conftest.py b/tests/conftest.py index ee7c847..dc09633 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -25,6 +25,6 @@ def _ensure_py_info_cache_empty(session_cache: DiskCache) -> Generator[None]: @pytest.fixture def _skip_if_test_in_system(session_cache: DiskCache) -> None: current = PythonInfo.current(session_cache) - if current.system_executable is not None: # pragma: no cover + if current.system_executable is not None: # pragma: no cover, only set on hosts with a system executable msg = "test not valid if run under system" raise pytest.skip.Exception(msg) diff --git a/tests/py_info/test_py_info.py b/tests/py_info/test_py_info.py index c60ad9e..a7721cd 100644 --- a/tests/py_info/test_py_info.py +++ b/tests/py_info/test_py_info.py @@ -225,7 +225,7 @@ def _make_py_info(of: PyInfoMock) -> PythonInfo: assert system_executable is not None py_info.system_executable = system_executable py_info.executable = system_executable - py_info.base_executable = str(path) # ty: ignore[unresolved-attribute] + py_info.base_executable = str(path) # ty: ignore[unresolved-attribute] the test stub is a plain object, not a PythonInfo if pos == position: selected = py_info discovered_with_path[str(path)] = py_info diff --git a/tests/py_info/test_py_info_exe_based_of.py b/tests/py_info/test_py_info_exe_based_of.py index 22b3268..37c3cfa 100644 --- a/tests/py_info/test_py_info_exe_based_of.py +++ b/tests/py_info/test_py_info_exe_based_of.py @@ -25,9 +25,9 @@ def test_discover_empty_folder(tmp_path: Path, session_cache: DiskCache) -> None def _discover_base_folders() -> tuple[str, ...]: exe_dir = str(Path(_CURRENT.executable).parent) folders: dict[str, None] = {} - if exe_dir.startswith(_CURRENT.prefix): # pragma: no branch + if exe_dir.startswith(_CURRENT.prefix): # pragma: no branch, the branch depends on the host interpreter layout relative = exe_dir[len(_CURRENT.prefix) :].lstrip("/\\") - if relative: # pragma: no branch + if relative: # pragma: no branch, the branch depends on the host interpreter layout folders[relative] = None folders["."] = None return tuple(folders) @@ -63,7 +63,7 @@ def test_discover_ok( dest = folder / name Path(str(dest)).symlink_to(_CURRENT.executable) pyvenv = Path(_CURRENT.executable).parents[1] / "pyvenv.cfg" - if pyvenv.exists(): # pragma: no branch + if pyvenv.exists(): # pragma: no branch, the branch depends on the host interpreter layout (folder / pyvenv.name).write_text(pyvenv.read_text(encoding="utf-8"), encoding="utf-8") inside_folder = str(tmp_path) found = _CURRENT.discover_exe(session_cache, inside_folder).executable diff --git a/tests/test_cached_py_info.py b/tests/test_cached_py_info.py index 77bf89d..eb23163 100644 --- a/tests/test_cached_py_info.py +++ b/tests/test_cached_py_info.py @@ -76,7 +76,7 @@ def test_resolve_py_info_script_pkgutil_returns_none(mocker: MockerFixture) -> N mocker.patch("python_discovery._cached_py_info.Path.is_file", return_value=False) mocker.patch("pkgutil.get_data", return_value=None) with pytest.raises(FileNotFoundError, match="cannot locate"), _resolve_py_info_script(): - pass # pragma: no cover + pass # pragma: no cover, the cache file is absent on this path def test_run_subprocess_success() -> None: diff --git a/tests/test_discovery.py b/tests/test_discovery.py index 475f1df..4fe9c56 100644 --- a/tests/test_discovery.py +++ b/tests/test_discovery.py @@ -49,7 +49,7 @@ def test_discovery_via_path( elif specificity == "less": core_ver = ".".join(str(i) for i in current.version_info[0:3]) exe_ver = current.version_info.major - elif specificity == "none": # pragma: no branch + elif specificity == "none": # pragma: no branch, the branch depends on the interpreter's own version core_ver = ".".join(str(i) for i in current.version_info[0:3]) exe_ver = "" core = "" if specificity == "none" else f"{name}{core_ver}{threaded}" @@ -58,7 +58,7 @@ def test_discovery_via_path( target.mkdir(parents=True) Path(str(target / exe_name)).symlink_to(sys.executable) pyvenv_cfg = Path(sys.executable).parents[1] / "pyvenv.cfg" - if pyvenv_cfg.exists(): # pragma: no branch + if pyvenv_cfg.exists(): # pragma: no branch, the branch depends on the host interpreter layout (target / pyvenv_cfg.name).write_bytes(pyvenv_cfg.read_bytes()) monkeypatch.setenv("PATH", os.pathsep.join([str(target), *os.environ.get("PATH", "").split(os.pathsep)])) interpreter = get_interpreter(core, []) @@ -450,7 +450,7 @@ def test_discovery_via_version_specifier(session_cache: DiskCache) -> None: spec = f"cpython>={major}.{minor}" interpreter = get_interpreter(spec, [], session_cache) - if current.implementation == "CPython": # pragma: no branch + if current.implementation == "CPython": # pragma: no branch, the branch depends on the host interpreter assert interpreter is not None assert interpreter.implementation == "CPython" diff --git a/tests/test_discovery_extra.py b/tests/test_discovery_extra.py index 6ace9b0..b9c4f5f 100644 --- a/tests/test_discovery_extra.py +++ b/tests/test_discovery_extra.py @@ -35,7 +35,7 @@ def test_propose_interpreters_try_first_with_valid(session_cache: DiskCache) -> def test_propose_interpreters_try_first_with_missing(tmp_path: Path) -> None: gen = propose_interpreters(PythonSpec.from_string_spec("python"), [str(tmp_path / "nonexistent")]) results = [] - for result in gen: # pragma: no branch + for result in gen: # pragma: no branch, the loop runs zero or more times depending on the host results.append(result) break assert len(results) >= 0 diff --git a/tests/test_py_spec.py b/tests/test_py_spec.py index def8b30..8fd5e24 100644 --- a/tests/test_py_spec.py +++ b/tests/test_py_spec.py @@ -168,7 +168,7 @@ def _version_not_satisfies_pairs() -> list[tuple[str, str]]: temp = sat_ver.copy() temp[patch] += offset if temp[patch] < 0: - continue # pragma: no cover + continue # pragma: no cover, the branch needs an interpreter the test runners lack target.add((req, ".".join(str(i) for i in temp))) return sorted(target) diff --git a/tests/test_specifier.py b/tests/test_specifier.py index 36ac508..29f987b 100644 --- a/tests/test_specifier.py +++ b/tests/test_specifier.py @@ -40,7 +40,7 @@ def test_version_eq_different() -> None: def test_version_eq_not_implemented() -> None: - result = SimpleVersion.from_string("3.11").__eq__("3.11") # ruff:ignore[unnecessary-dunder-call] + result = SimpleVersion.from_string("3.11").__eq__("3.11") # ruff:ignore[unnecessary-dunder-call] the test checks the dunder method itself assert result is NotImplemented @@ -65,7 +65,7 @@ def test_version_lt(left: str, right: str, expected: bool) -> None: def test_version_lt_not_implemented() -> None: - result = SimpleVersion.from_string("3.11").__lt__("3.12") # ruff:ignore[unnecessary-dunder-call] + result = SimpleVersion.from_string("3.11").__lt__("3.12") # ruff:ignore[unnecessary-dunder-call] the test checks the dunder method itself assert result is NotImplemented @@ -79,7 +79,7 @@ def test_version_gt() -> None: def test_version_gt_not_implemented() -> None: - result = SimpleVersion.from_string("3.11").__gt__("3.11") # ruff:ignore[unnecessary-dunder-call] + result = SimpleVersion.from_string("3.11").__gt__("3.11") # ruff:ignore[unnecessary-dunder-call] the test checks the dunder method itself assert result is NotImplemented @@ -201,7 +201,7 @@ def test_specifier_eq() -> None: def test_specifier_eq_not_implemented() -> None: - result = SimpleSpecifier.from_string(">=3.12").__eq__(">=3.12") # ruff:ignore[unnecessary-dunder-call] + result = SimpleSpecifier.from_string(">=3.12").__eq__(">=3.12") # ruff:ignore[unnecessary-dunder-call] the test checks the dunder method itself assert result is NotImplemented @@ -261,7 +261,7 @@ def test_specifier_set_eq() -> None: def test_specifier_set_eq_not_implemented() -> None: - result = SimpleSpecifierSet.from_string(">=3.12").__eq__(">=3.12") # ruff:ignore[unnecessary-dunder-call] + result = SimpleSpecifierSet.from_string(">=3.12").__eq__(">=3.12") # ruff:ignore[unnecessary-dunder-call] the test checks the dunder method itself assert result is NotImplemented diff --git a/tests/windows/conftest.py b/tests/windows/conftest.py index 672138e..d0ab255 100644 --- a/tests/windows/conftest.py +++ b/tests/windows/conftest.py @@ -42,14 +42,14 @@ def _mock_exists(path: str) -> bool: def _create_winreg_mock() -> ModuleType: # Create a mock winreg module that works on all platforms. winreg = ModuleType("winreg") - winreg.HKEY_CURRENT_USER = 0x80000001 # ty: ignore[unresolved-attribute] - winreg.HKEY_LOCAL_MACHINE = 0x80000002 # ty: ignore[unresolved-attribute] - winreg.KEY_READ = 0x20019 # ty: ignore[unresolved-attribute] - winreg.KEY_WOW64_64KEY = 0x0100 # ty: ignore[unresolved-attribute] - winreg.KEY_WOW64_32KEY = 0x0200 # ty: ignore[unresolved-attribute] - winreg.EnumKey = MagicMock() # ty: ignore[unresolved-attribute] - winreg.QueryValueEx = MagicMock() # ty: ignore[unresolved-attribute] - winreg.OpenKeyEx = MagicMock() # ty: ignore[unresolved-attribute] + winreg.HKEY_CURRENT_USER = 0x80000001 # ty: ignore[unresolved-attribute] winreg is Windows-only, so type checkers cannot resolve it + winreg.HKEY_LOCAL_MACHINE = 0x80000002 # ty: ignore[unresolved-attribute] winreg is Windows-only, so type checkers cannot resolve it + winreg.KEY_READ = 0x20019 # ty: ignore[unresolved-attribute] winreg is Windows-only, so type checkers cannot resolve it + winreg.KEY_WOW64_64KEY = 0x0100 # ty: ignore[unresolved-attribute] winreg is Windows-only, so type checkers cannot resolve it + winreg.KEY_WOW64_32KEY = 0x0200 # ty: ignore[unresolved-attribute] winreg is Windows-only, so type checkers cannot resolve it + winreg.EnumKey = MagicMock() # ty: ignore[unresolved-attribute] winreg is Windows-only, so type checkers cannot resolve it + winreg.QueryValueEx = MagicMock() # ty: ignore[unresolved-attribute] winreg is Windows-only, so type checkers cannot resolve it + winreg.OpenKeyEx = MagicMock() # ty: ignore[unresolved-attribute] winreg is Windows-only, so type checkers cannot resolve it return winreg @@ -65,8 +65,8 @@ def _load_registry_data( loc: dict[str, object] = {} glob: dict[str, object] = {"winreg": winreg} mock_value_str = (Path(__file__).parent / "winreg_mock_values.py").read_text(encoding="utf-8") - exec(mock_value_str, glob, loc) # ruff:ignore[exec-builtin] - return loc["enum_collect"], loc["value_collect"], loc["key_open"], loc["hive_open"] # ty: ignore[invalid-return-type] + exec(mock_value_str, glob, loc) # ruff:ignore[exec-builtin] the mock values are Python source kept in a separate file + return loc["enum_collect"], loc["value_collect"], loc["key_open"], loc["hive_open"] # ty: ignore[invalid-return-type] the namespace is built by exec, so it has no static types def _make_enum_key(enum_collect: dict[object, dict[int, object]]) -> object: @@ -74,7 +74,7 @@ def _enum_key(key: object, at: int) -> str: result = enum_collect[(key.value if isinstance(key, _Key) else key)][at] if isinstance(result, OSError): raise result - return result # ty: ignore[invalid-return-type] + return result # ty: ignore[invalid-return-type] the namespace is built by exec, so it has no static types return _enum_key From 4ed307273fac0e34815c7bb4c855d8bf53cadbff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Sat, 7 Nov 2026 07:29:33 -0800 Subject: [PATCH 07/25] =?UTF-8?q?=F0=9F=93=9D=20docs:=20describe=20the=20e?= =?UTF-8?q?mpty=20reST=20fields?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fields such as `:returns:` and `:ivar:` with no text documented nothing. Each now states what the value is, so the reST reads on its own. --- src/python_discovery/_cache.py | 10 +-- src/python_discovery/_discovery.py | 2 +- src/python_discovery/_py_info.py | 108 ++++++++++++++--------------- src/python_discovery/_py_spec.py | 6 +- src/python_discovery/_specifier.py | 14 ++-- 5 files changed, 70 insertions(+), 70 deletions(-) diff --git a/src/python_discovery/_cache.py b/src/python_discovery/_cache.py index be7cece..16a07b1 100644 --- a/src/python_discovery/_cache.py +++ b/src/python_discovery/_cache.py @@ -27,7 +27,7 @@ def exists(self) -> bool: """ Return whether the cached content exists. - :returns: + :returns: ``True`` if the content is cached, ``False`` otherwise. """ ... @@ -35,7 +35,7 @@ def read(self) -> dict | None: """ Read the cached content, or ``None`` if unavailable or corrupt. - :returns: + :returns: the cached content, or ``None``. """ ... @@ -56,7 +56,7 @@ def locked(self) -> Generator[None]: """ Context manager that acquires an exclusive lock on this store. - :returns: + :returns: a context manager; the lock is held inside its ``with`` block. """ ... @@ -70,7 +70,7 @@ def py_info(self, path: Path) -> ContentStore: Return the content store for the interpreter at *path*. :param path: absolute path to a Python executable. - :returns: + :returns: the content store for *path*. """ ... @@ -94,7 +94,7 @@ def py_info(self, path: Path) -> ContentStore: Return the content store for the interpreter at *path*. :param path: absolute path to a Python executable. - :returns: + :returns: the content store for *path*. """ if not self._trusted_folder(): return NoOpContentStore() diff --git a/src/python_discovery/_discovery.py b/src/python_discovery/_discovery.py index cc9b4f8..38c1aca 100644 --- a/src/python_discovery/_discovery.py +++ b/src/python_discovery/_discovery.py @@ -509,7 +509,7 @@ def iter_interpreters( enumeration, which interrogates every candidate as a subprocess on a cold cache. :param env: environment mapping for ``PATH`` and uv store lookup; defaults to :data:`os.environ`. :param predicate: optional filter applied after the spec match; return ``True`` to include the interpreter. - :yields: + :yields: each matching interpreter, in discovery order. """ if key is None: keys: tuple[str | None, ...] = (None,) diff --git a/src/python_discovery/_py_info.py b/src/python_discovery/_py_info.py index 34832cf..10117a7 100644 --- a/src/python_discovery/_py_info.py +++ b/src/python_discovery/_py_info.py @@ -48,40 +48,40 @@ class PythonInfo: # ruff:ignore[too-many-public-methods] PythonInfo is the publ """ Contains information for a Python interpreter. - :ivar platform: - :ivar implementation: - :ivar pypy_version_info: - :ivar version_info: - :ivar architecture: - :ivar sysconfig_platform: - :ivar version_nodot: - :ivar version: - :ivar os: - :ivar free_threaded: - :ivar debug_build: - :ivar marker_environment: - :ivar prefix: - :ivar base_prefix: - :ivar real_prefix: - :ivar base_exec_prefix: - :ivar exec_prefix: - :ivar executable: - :ivar original_executable: - :ivar system_executable: - :ivar has_venv: - :ivar path: - :ivar file_system_encoding: - :ivar stdout_encoding: - :ivar sysconfig_scheme: - :ivar sysconfig_paths: - :ivar distutils_install: - :ivar sysconfig: - :ivar sysconfig_vars: - :ivar tcl_lib: - :ivar tk_lib: - :ivar system_stdlib: - :ivar system_stdlib_platform: - :ivar max_size: + :ivar platform: the platform name, as reported by ``sys.platform``. + :ivar implementation: the implementation name, such as ``CPython``. + :ivar pypy_version_info: the PyPy version tuple, empty for other implementations. + :ivar version_info: the interpreter version as a named tuple. + :ivar architecture: the pointer width in bits, ``32`` or ``64``. + :ivar sysconfig_platform: the platform string from :mod:`sysconfig`, or ``None``. + :ivar version_nodot: the ``major.minor`` version without the dot. + :ivar version: the full version string from ``sys.version``. + :ivar os: the operating system name, as reported by ``os.name``. + :ivar free_threaded: whether the interpreter was built free-threaded. + :ivar debug_build: whether the interpreter was built with ``Py_DEBUG``. + :ivar marker_environment: the PEP 508 marker environment of the interpreter. + :ivar prefix: the ``sys.prefix`` of the interpreter. + :ivar base_prefix: the ``sys.base_prefix`` of the interpreter. + :ivar real_prefix: the ``sys.real_prefix`` set by legacy virtualenv, or ``None``. + :ivar base_exec_prefix: the ``sys.base_exec_prefix`` of the interpreter. + :ivar exec_prefix: the ``sys.exec_prefix`` of the interpreter. + :ivar executable: the ``sys.executable`` of the interpreter. + :ivar original_executable: the absolute path to the executable, without resolving symlinks. + :ivar system_executable: the system interpreter behind any virtualenv, or ``None``. + :ivar has_venv: whether the :mod:`venv` module can be imported. + :ivar path: the ``sys.path`` of the interpreter. + :ivar file_system_encoding: the filesystem encoding, from ``sys.getfilesystemencoding()``. + :ivar stdout_encoding: the encoding of ``sys.stdout``, or ``None``. + :ivar sysconfig_scheme: the installation scheme behind ``sysconfig_paths``, or ``None``. + :ivar sysconfig_paths: the installation paths, keyed by :mod:`sysconfig` path name. + :ivar distutils_install: the distutils installation directories, empty when sysconfig supplies them. + :ivar sysconfig: the :mod:`sysconfig` build metadata, such as the makefile filename. + :ivar sysconfig_vars: the :mod:`sysconfig` config variables queried for this interpreter. + :ivar tcl_lib: the Tcl library path, or ``None``. + :ivar tk_lib: the Tk library path, or ``None``. + :ivar system_stdlib: the standard library path of the system interpreter. + :ivar system_stdlib_platform: the platform standard library path of the system interpreter. + :ivar max_size: the ``sys.maxsize`` of the interpreter. """ platform: str @@ -134,8 +134,8 @@ def from_dict(cls, data: dict[str, InfoValue]) -> PythonInfo: Reconstruct a :class:`PythonInfo` from a plain dictionary. :param data: dictionary produced by :meth:`to_dict`. - :returns: - :raises ValueError: + :returns: the interpreter information rebuilt from *data*. + :raises ValueError: if *data* lacks an annotated field. """ if missing := PythonInfo.__annotations__.keys() - data.keys() - {"_cache_exe_discovery", "pypy_version_info"}: msg = f"missing interpreter information: {', '.join(sorted(missing))}" @@ -151,8 +151,8 @@ def current(cls, cache: PyInfoCache | None = None) -> PythonInfo: Locate the current host interpreter information. :param cache: interpreter metadata cache; when ``None`` results are not cached. - :returns: - :raises RuntimeError: + :returns: the information about the running interpreter. + :raises RuntimeError: if the running interpreter cannot be queried. """ if cls._current is None: result = cls.from_exe(sys.executable, cache, raise_on_error=True, resolve_to_host=False) @@ -182,7 +182,7 @@ def from_exe( # ruff:ignore[too-many-arguments] each argument is a separate pro :param ignore_cache: bypass the cache and re-query the interpreter. :param resolve_to_host: resolve through virtualenv layers to the system interpreter. :param env: environment mapping; defaults to :data:`os.environ`. - :returns: + :returns: the interpreter information, or ``None`` when it cannot be queried. """ return cls._from_exe_with_args( exe, @@ -236,8 +236,8 @@ def current_system(cls, cache: PyInfoCache | None = None) -> PythonInfo: Locate the current system interpreter information, resolving through any virtualenv layers. :param cache: interpreter metadata cache; when ``None`` results are not cached. - :returns: - :raises RuntimeError: + :returns: the system interpreter behind any virtualenv layers. + :raises RuntimeError: if the system interpreter cannot be queried. """ if cls._current_system is None: result = cls.from_exe(sys.executable, cache, raise_on_error=True, resolve_to_host=True) @@ -253,7 +253,7 @@ def from_json(cls, payload: str) -> PythonInfo: Deserialize interpreter information from a JSON string. :param payload: JSON produced by :meth:`to_json`. - :returns: + :returns: the interpreter information decoded from *payload*. """ return cls.from_dict(json.loads(payload).copy()) @@ -267,8 +267,8 @@ def resolve_to_system( :param cache: interpreter metadata cache; when ``None`` results are not cached. :param target: the interpreter to resolve. :param env: environment mapping; defaults to :data:`os.environ`. - :returns: - :raises RuntimeError: + :returns: the system interpreter that *target* resolves to. + :raises RuntimeError: if the prefixes form a cycle. """ start_executable = target.executable prefixes = OrderedDict() @@ -407,8 +407,8 @@ def sysconfig_path(self, key: str, config_var: dict[str, str | int | None] | Non :param key: sysconfig path key (e.g. ``"purelib"``, ``"include"``). :param config_var: replacement mapping for sysconfig variables; when ``None`` uses the interpreter's own values. :param sep: path separator to use in the result. - :returns: - :raises ValueError: + :returns: the path, or an empty string if *key* is unknown. + :raises ValueError: if the sysconfig pattern contains format specifiers. """ pattern = self.sysconfig_paths.get(key) if pattern is None: @@ -434,7 +434,7 @@ def install_path(self, key: str) -> str: Return the relative installation path for a given installation scheme *key*. :param key: sysconfig installation scheme key (e.g. ``"scripts"``, ``"purelib"``). - :returns: + :returns: the installation path relative to the prefix. """ result = self.distutils_install.get(key) if result is None: # pragma: >=3.11 cover # distutils is empty when "venv" scheme is available @@ -478,7 +478,7 @@ def satisfies(self, spec: PythonSpec, *, impl_must_match: bool) -> bool: # ruff :param spec: the specification to check against. :param impl_must_match: when ``True``, the implementation name must match exactly. - :returns: + :returns: ``True`` if this interpreter satisfies *spec*. """ if spec.path and not self._satisfies_path(spec): return False @@ -542,7 +542,7 @@ def to_json(self) -> str: """ Serialize this interpreter information to a JSON string. - :returns: + :returns: the JSON document. """ return json.dumps(self.to_dict(), indent=2) @@ -550,7 +550,7 @@ def to_dict(self) -> dict[str, InfoValue]: """ Convert this interpreter information to a plain dictionary. - :returns: + :returns: the interpreter information as a dictionary. """ data = {name: None if name == "_creators" else value for name, value in vars(self).items()} version_info = data["version_info"] @@ -572,8 +572,8 @@ def discover_exe( :param prefix: directory prefix to search under. :param exact: when ``True``, require an exact version match. :param env: environment mapping; defaults to :data:`os.environ`. - :returns: - :raises RuntimeError: + :returns: the matching interpreter information. + :raises RuntimeError: if no matching interpreter is found. """ key = prefix, exact, self.version_info if key in self._cache_exe_discovery and prefix: @@ -740,8 +740,8 @@ def normalize_isa(isa: str) -> str: Known aliases are mapped (e.g. ``amd64`` → ``x86_64``, ``aarch64`` → ``arm64``). Unrecognized values are lowercased and returned as-is. - :param isa: - :returns: + :param isa: the architecture string as the platform reports it. + :returns: the normalized architecture name. """ low = isa.lower() return { diff --git a/src/python_discovery/_py_spec.py b/src/python_discovery/_py_spec.py index 1bec978..0c08365 100644 --- a/src/python_discovery/_py_spec.py +++ b/src/python_discovery/_py_spec.py @@ -178,7 +178,7 @@ def from_string_spec(cls, string_spec: str) -> PythonSpec: :param string_spec: an interpreter spec — an absolute path, a version string, an implementation prefix, or a `version specifier `_. - :returns: + :returns: the parsed specification. :raises ValueError: if the spec starts with a version specifier operator but does not parse as one. """ if pathlib.Path(string_spec).is_absolute(): @@ -214,7 +214,7 @@ def generate_re(self, *, windows: bool, all_implementations: bool = False) -> re filename in :data:`KNOWN_IMPLEMENTATIONS` instead of only ``python``. A free-threaded or debug build the spec leaves open also matches its marked name, such as ``python3.13t`` or ``python3.13-dbg``. Used by enumeration APIs. - :returns: + :returns: a compiled pattern matching the filenames the spec accepts. """ version = r"{}(\.{}(\.{})?)?".format( *(r"\d+" if v is None else v for v in (self.major, self.minor, self.micro)), @@ -240,7 +240,7 @@ def satisfies(self, spec: PythonSpec) -> bool: # ruff:ignore[too-many-return-st Check if this spec is compatible with the given *spec* (e.g. PEP-514 on Windows). :param spec: the requirement to check against. - :returns: + :returns: ``True`` if this spec is compatible with *spec*. """ if spec.is_abs and self.is_abs and self.path != spec.path: return False diff --git a/src/python_discovery/_specifier.py b/src/python_discovery/_specifier.py index 56b96cc..a9eed79 100644 --- a/src/python_discovery/_specifier.py +++ b/src/python_discovery/_specifier.py @@ -67,8 +67,8 @@ def from_string(cls, version_str: str) -> SimpleVersion: Parse a PEP 440 version string (e.g. ``3.12.1``). :param version_str: the version string to parse. - :returns: - :raises ValueError: + :returns: the parsed version. + :raises ValueError: if *version_str* is not a valid version. """ stripped = version_str.strip() if not (match := _VERSION_RE.match(stripped)): @@ -155,8 +155,8 @@ def from_string(cls, spec_str: str) -> SimpleSpecifier: Parse a single PEP 440 specifier (e.g. ``>=3.10``). :param spec_str: the specifier string to parse. - :returns: - :raises ValueError: + :returns: the parsed specifier. + :raises ValueError: if *spec_str* is not a valid specifier. """ stripped = spec_str.strip() if not (match := _SPECIFIER_RE.match(stripped)): @@ -201,7 +201,7 @@ def contains(self, version_str: str) -> bool: Check if a version string satisfies this specifier. :param version_str: the version string to test. - :returns: + :returns: ``True`` if *version_str* satisfies this specifier. """ try: candidate = SimpleVersion.from_string(version_str) if isinstance(version_str, str) else version_str @@ -278,7 +278,7 @@ def from_string(cls, specifiers_str: str = "") -> SimpleSpecifierSet: Parse a comma-separated PEP 440 specifier string (e.g. ``>=3.10,<4``). :param specifiers_str: the specifier string to parse. - :returns: + :returns: the parsed specifier set. """ stripped = specifiers_str.strip() specs: list[SimpleSpecifier] = [] @@ -311,7 +311,7 @@ def contains(self, version_str: str) -> bool: Check if a version satisfies all specifiers in the set. :param version_str: the version string to test. - :returns: + :returns: ``True`` if *version_str* satisfies every specifier. """ if not self.specifiers: return True From f959bba8b09dfaa34a5949a3ce6f97719ea4a2f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Sat, 7 Nov 2026 07:34:45 -0800 Subject: [PATCH 08/25] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor(test):=20au?= =?UTF-8?q?tospec=20mocked=20callables?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A patch without a spec accepts any call, so a renamed or re-signed target keeps passing. The winreg patches in tests/windows stay as they are: conftest already replaces winreg with mocks, so autospec cannot apply. --- tests/py_info/test_py_info.py | 16 ++++----- tests/test_cache.py | 8 ++--- tests/test_cached_py_info.py | 61 +++++++++++++++++++++------------ tests/test_discovery.py | 32 ++++++++--------- tests/test_discovery_extra.py | 4 +-- tests/test_iter_interpreters.py | 17 +++++---- tests/test_py_info_collect.py | 18 ++++++---- tests/test_py_info_extra.py | 41 ++++++++++++---------- tests/windows/conftest.py | 2 +- 9 files changed, 115 insertions(+), 84 deletions(-) diff --git a/tests/py_info/test_py_info.py b/tests/py_info/test_py_info.py index a7721cd..78e360c 100644 --- a/tests/py_info/test_py_info.py +++ b/tests/py_info/test_py_info.py @@ -232,13 +232,13 @@ def _make_py_info(of: PyInfoMock) -> PythonInfo: names.append(path.name) target_py_info = _make_py_info(target) - mocker.patch.object(target_py_info, "_find_possible_exe_names", return_value=names) - mocker.patch.object(target_py_info, "_find_possible_folders", return_value=[str(tmp_path)]) + mocker.patch.object(target_py_info, "_find_possible_exe_names", return_value=names, autospec=True) + mocker.patch.object(target_py_info, "_find_possible_folders", return_value=[str(tmp_path)], autospec=True) def func(exe_path: str, _cache: object = None, **_kwargs: object) -> PythonInfo: return discovered_with_path[exe_path] - mocker.patch.object(target_py_info, "from_exe", side_effect=func) + mocker.patch.object(target_py_info, "from_exe", side_effect=func, autospec=True) target_py_info.real_prefix = str(tmp_path) target_py_info.system_executable = None @@ -275,9 +275,9 @@ def candidate(*, flag: bool, name: str) -> PythonInfo: match = candidate(flag=True, name="debug") by_path = {info.executable: info for info in (mismatch, match)} - mocker.patch.object(target, "_find_possible_exe_names", return_value=["release", "debug"]) - mocker.patch.object(target, "_find_possible_folders", return_value=[str(tmp_path)]) - mocker.patch.object(target, "from_exe", side_effect=lambda exe, *_a, **_k: by_path[exe]) + mocker.patch.object(target, "_find_possible_exe_names", return_value=["release", "debug"], autospec=True) + mocker.patch.object(target, "_find_possible_folders", return_value=[str(tmp_path)], autospec=True) + mocker.patch.object(target, "from_exe", side_effect=lambda exe, *_a, **_k: by_path[exe], autospec=True) found = target.discover_exe(session_cache, prefix=str(tmp_path), exact=True) assert found is match # the release build is refused even though version, impl and arch match @@ -349,7 +349,7 @@ def test_py_info_to_system_raises( # pragma: no cover # skipped in venv environ caplog: pytest.LogCaptureFixture, ) -> None: caplog.set_level(logging.DEBUG) - mocker.patch.object(PythonInfo, "_find_possible_folders", return_value=[]) + mocker.patch.object(PythonInfo, "_find_possible_folders", return_value=[], autospec=True) result = PythonInfo.from_exe(sys.executable, cache=session_cache, raise_on_error=False) assert result is None log = caplog.records[-1] @@ -413,7 +413,7 @@ def test_py_info_machine_derivation(platform: str, expected: str) -> None: def test_py_info_machine_derivation_universal2(mocker: MockerFixture, runtime_isa: str) -> None: info = copy.deepcopy(_CURRENT) info.sysconfig_platform = "macosx-11.0-universal2" - mocker.patch("python_discovery._py_info.platform.machine", return_value=runtime_isa) + mocker.patch("python_discovery._py_info.platform.machine", return_value=runtime_isa, autospec=True) assert info.machine == runtime_isa diff --git a/tests/test_cache.py b/tests/test_cache.py index c868f2c..bf2154b 100644 --- a/tests/test_cache.py +++ b/tests/test_cache.py @@ -225,7 +225,7 @@ def test_disk_cache_skips_file_as_root(tmp_path: Path) -> None: @pytest.mark.skipif(sys.platform == "win32", reason="POSIX ownership and modes") def test_disk_cache_skips_uncreatable_directory(tmp_path: Path, mocker: MockerFixture) -> None: - mocker.patch.object(Path, "mkdir", side_effect=PermissionError) + mocker.patch.object(Path, "mkdir", side_effect=PermissionError, autospec=True) assert isinstance(DiskCache(tmp_path / "cache").py_info(Path("/some/path")), NoOpContentStore) @@ -233,7 +233,7 @@ def test_disk_cache_skips_uncreatable_directory(tmp_path: Path, mocker: MockerFi def test_disk_cache_skips_foreign_directory(tmp_path: Path, mocker: MockerFixture) -> None: status = list(tmp_path.stat()) status[4] = os.geteuid() + 1 - mocker.patch.object(Path, "lstat", return_value=os.stat_result(status)) + mocker.patch.object(Path, "lstat", return_value=os.stat_result(status), autospec=True) assert isinstance(DiskCache(tmp_path).py_info(Path("/some/path")), NoOpContentStore) @@ -290,7 +290,7 @@ def test_disk_content_store_rejects_untrusted_entry( store.write({"key": "value"}) status = list((tmp_path / "entry.json").stat()) status[0], status[4] = stat.S_IFREG | mode, os.geteuid() + owner_offset - mocker.patch("python_discovery._cache.os.fstat", return_value=os.stat_result(status)) + mocker.patch("python_discovery._cache.os.fstat", return_value=os.stat_result(status), autospec=True) assert store.read() is None assert not store.exists() @@ -340,7 +340,7 @@ def test_disk_content_store_write_is_private_with_lax_umask(tmp_path: Path) -> N ], ) def test_disk_content_store_write_skips_io_failure(tmp_path: Path, mocker: MockerFixture, target: str) -> None: - mocker.patch(target, side_effect=PermissionError) + mocker.patch(target, side_effect=PermissionError, autospec=True) DiskContentStore(tmp_path, "entry").write({"key": "value"}) assert list(tmp_path.iterdir()) == [] diff --git a/tests/test_cached_py_info.py b/tests/test_cached_py_info.py index eb23163..dfc027e 100644 --- a/tests/test_cached_py_info.py +++ b/tests/test_cached_py_info.py @@ -61,8 +61,8 @@ def test_resolve_py_info_script_file_exists() -> None: def test_resolve_py_info_script_fallback_to_pkgutil(mocker: MockerFixture) -> None: - mocker.patch("python_discovery._cached_py_info.Path.is_file", return_value=False) - mocker.patch("pkgutil.get_data", return_value=b"# mock script") + mocker.patch("python_discovery._cached_py_info.Path.is_file", return_value=False, autospec=True) + mocker.patch("pkgutil.get_data", return_value=b"# mock script", autospec=True) with _resolve_py_info_script() as script: assert script.exists() content = script.read_text(encoding="utf-8") @@ -73,8 +73,8 @@ def test_resolve_py_info_script_fallback_to_pkgutil(mocker: MockerFixture) -> No def test_resolve_py_info_script_pkgutil_returns_none(mocker: MockerFixture) -> None: - mocker.patch("python_discovery._cached_py_info.Path.is_file", return_value=False) - mocker.patch("pkgutil.get_data", return_value=None) + mocker.patch("python_discovery._cached_py_info.Path.is_file", return_value=False, autospec=True) + mocker.patch("pkgutil.get_data", return_value=None, autospec=True) with pytest.raises(FileNotFoundError, match="cannot locate"), _resolve_py_info_script(): pass # pragma: no cover, the cache file is absent on this path @@ -94,7 +94,7 @@ def test_run_subprocess_bad_exe() -> None: def test_run_subprocess_invalid_json(mocker: MockerFixture) -> None: - mocker.patch("python_discovery._cached_py_info.Popen", return_value=_mock_process("not json", "", 0)) + mocker.patch("python_discovery._cached_py_info.Popen", return_value=_mock_process("not json", "", 0), autospec=True) failure, result = _run_subprocess(PythonInfo, sys.executable, dict(os.environ)) assert failure is not None assert result is None @@ -127,8 +127,11 @@ def test_from_exe_rejects_wrong_shape_json(tmp_path: Path, mocker: MockerFixture mocker.patch( "python_discovery._cached_py_info.Popen", side_effect=[_mock_process(wrapped, "", 0), _mock_process(wrapped, "", 0)], + autospec=True, + ) + mocker.patch( + "python_discovery._cached_py_info.gen_cookie", side_effect=[start_cookie, end_cookie] * 2, autospec=True ) - mocker.patch("python_discovery._cached_py_info.gen_cookie", side_effect=[start_cookie, end_cookie] * 2) assert PythonInfo.from_exe(str(tmp_path / "python"), raise_on_error=False, ignore_cache=True) is None @@ -137,6 +140,7 @@ def test_from_exe_rejects_json_without_cookies(tmp_path: Path, mocker: MockerFix mocker.patch( "python_discovery._cached_py_info.Popen", side_effect=[_mock_process(output, "", 0), _mock_process(output, "", 0)], + autospec=True, ) assert PythonInfo.from_exe(str(tmp_path / "python"), raise_on_error=False, ignore_cache=True) is None @@ -146,10 +150,10 @@ def test_run_subprocess_with_cookies(mocker: MockerFixture) -> None: end_cookie = "b" * 32 out = f"pre{start_cookie[::-1]}{json.dumps(PythonInfo().to_dict())}{end_cookie[::-1]}post" - mocker.patch("python_discovery._cached_py_info.Popen", return_value=_mock_process(out, "", 0)) - mocker.patch("python_discovery._cached_py_info.gen_cookie", side_effect=[start_cookie, end_cookie]) + mocker.patch("python_discovery._cached_py_info.Popen", return_value=_mock_process(out, "", 0), autospec=True) + mocker.patch("python_discovery._cached_py_info.gen_cookie", side_effect=[start_cookie, end_cookie], autospec=True) - with patch("sys.stdout") as mock_stdout: + with patch("sys.stdout", autospec=True) as mock_stdout: failure, result = _run_subprocess(PythonInfo, sys.executable, dict(os.environ)) assert failure is None @@ -158,7 +162,9 @@ def test_run_subprocess_with_cookies(mocker: MockerFixture) -> None: def test_run_subprocess_timeout(mocker: MockerFixture) -> None: - mocker.patch("python_discovery._cached_py_info._collect_query_output", return_value=("", "timed out", -1)) + mocker.patch( + "python_discovery._cached_py_info._collect_query_output", return_value=("", "timed out", -1), autospec=True + ) failure, result = _run_subprocess(PythonInfo, sys.executable, dict(os.environ)) assert failure is not None assert "timed out" in str(failure) @@ -166,7 +172,11 @@ def test_run_subprocess_timeout(mocker: MockerFixture) -> None: def test_run_subprocess_nonzero_exit(mocker: MockerFixture) -> None: - mocker.patch("python_discovery._cached_py_info.Popen", return_value=_mock_process("some output", "some error", 1)) + mocker.patch( + "python_discovery._cached_py_info.Popen", + return_value=_mock_process("some output", "some error", 1), + autospec=True, + ) failure, result = _run_subprocess(PythonInfo, sys.executable, dict(os.environ)) assert failure is not None assert "failed to query" in str(failure) @@ -206,7 +216,7 @@ def test_collect_query_output_normalizes_trailing_newline() -> None: def test_collect_query_output_decodes_split_utf8() -> None: - with patch("python_discovery._cached_py_info.os.read", side_effect=lambda fd, _: read(fd, 1)): + with patch("python_discovery._cached_py_info.os.read", side_effect=lambda fd, _: read(fd, 1), autospec=True): assert _collect_query_output(_mock_process("é", "", 0), 1) == ("é", "", 0) @@ -241,8 +251,8 @@ def test_collect_query_output_timeout_reaps_readers() -> None: def test_collect_query_output_timeout_after_eof(mocker: MockerFixture) -> None: process = Popen([sys.executable, "-c", ""], stdin=DEVNULL, stdout=PIPE, stderr=PIPE, text=True) process.wait(timeout=5) - mocker.patch.object(process, "wait", side_effect=[TimeoutExpired("python", 0), 0]) - mocker.patch.object(process, "kill") + mocker.patch.object(process, "wait", side_effect=[TimeoutExpired("python", 0), 0], autospec=True) + mocker.patch.object(process, "kill", autospec=True) assert _collect_query_output(process, 1) == ("", "timed out", -1) process.kill.assert_called_once() @@ -251,8 +261,8 @@ def test_collect_query_output_timeout_after_eof(mocker: MockerFixture) -> None: def test_collect_query_output_read_error(mocker: MockerFixture) -> None: process = Popen([sys.executable, "-c", ""], stdin=DEVNULL, stdout=PIPE, stderr=PIPE, text=True) process.wait(timeout=5) - mocker.patch("python_discovery._cached_py_info.os.read", side_effect=OSError("broken pipe")) - mocker.patch.object(process, "kill") + mocker.patch("python_discovery._cached_py_info.os.read", side_effect=OSError("broken pipe"), autospec=True) + mocker.patch.object(process, "kill", autospec=True) with pytest.raises(OSError, match="broken pipe"): _collect_query_output(process, 1) @@ -267,7 +277,7 @@ def test_collect_query_output_read_error(mocker: MockerFixture) -> None: ], ) def test_collect_query_output_requires_pipes(stdout: StringIO | None, stderr: StringIO | None) -> None: - process = MagicMock(stdout=stdout, stderr=stderr) + process = MagicMock(stdout=stdout, stderr=stderr, autospec=True) with pytest.raises(ValueError, match="query output pipes are required"): _collect_query_output(process, 1) @@ -290,7 +300,7 @@ def test_load_cached_py_info_system_exe_missing(mocker: MockerFixture) -> None: store = MagicMock() content = PythonInfo().to_dict() content["system_executable"] = "/nonexistent/python" - mocker.patch("os.path.exists", return_value=False) + mocker.patch("os.path.exists", return_value=False, autospec=True) result = _load_cached_py_info(PythonInfo, store, content) assert result is None store.remove.assert_called_once() @@ -342,6 +352,7 @@ def test_from_exe_retry_on_first_failure( mocker.patch( "python_discovery._cached_py_info._run_subprocess", side_effect=[(error, None), (None, PythonInfo())], + autospec=True, ) result = _get_via_file_cache(PythonInfo, cache, Path("/fake"), "/fake", dict(os.environ)) assert isinstance(result, PythonInfo) @@ -351,7 +362,9 @@ def test_from_exe_retry_on_first_failure( def test_get_via_file_cache_hash_oserror(tmp_path: Path, mocker: MockerFixture) -> None: cache = DiskCache(tmp_path) _script_hash.cache_clear() - mocker.patch("python_discovery._cached_py_info.Path.read_bytes", side_effect=OSError("permission denied")) + mocker.patch( + "python_discovery._cached_py_info.Path.read_bytes", side_effect=OSError("permission denied"), autospec=True + ) result = _get_via_file_cache(PythonInfo, cache, Path(sys.executable), sys.executable, dict(os.environ)) assert isinstance(result, PythonInfo) @@ -361,6 +374,7 @@ def test_get_via_file_cache_py_info_none(tmp_path: Path, mocker: MockerFixture) mocker.patch( "python_discovery._cached_py_info._run_subprocess", return_value=(None, None), + autospec=True, ) result = _get_via_file_cache(PythonInfo, cache, Path("/fake"), "/fake", dict(os.environ)) assert isinstance(result, RuntimeError) @@ -530,11 +544,11 @@ def test_from_exe_recovers_unusable_cache_path(tmp_path: Path, folder: str, suff @pytest.fixture def popen(mocker: MockerFixture) -> MagicMock: start, end = "a" * 32, "b" * 32 - mocker.patch("python_discovery._cached_py_info.gen_cookie", side_effect=[start, end]) + mocker.patch("python_discovery._cached_py_info.gen_cookie", side_effect=[start, end], autospec=True) info = PythonInfo() info.executable = _SELECTED process = _mock_process(f"{start[::-1]}{info.to_json()}{end[::-1]}", "", 0) - return mocker.patch("python_discovery._cached_py_info.Popen", return_value=process) + return mocker.patch("python_discovery._cached_py_info.Popen", return_value=process, autospec=True) def test_executable_args_precede_query_script(popen: MagicMock) -> None: @@ -560,6 +574,7 @@ def test_executable_args_disk_entry_not_reused_without_args(tmp_path: Path, mock query = mocker.patch( "python_discovery._cached_py_info._run_subprocess", side_effect=[(None, with_args), (None, without_args)], + autospec=True, ) cache, path, env = DiskCache(tmp_path), Path(sys.executable), dict(os.environ) _get_via_file_cache(PythonInfo, cache, path, sys.executable, env, executable_args=_ARGS) @@ -573,7 +588,9 @@ def test_executable_args_disk_entry_not_reused_without_args(tmp_path: Path, mock def test_executable_args_memory_cache_keyed_by_args(tmp_path: Path, mocker: MockerFixture) -> None: mocker.patch.dict("python_discovery._cached_py_info._CACHE", clear=True) first, second = PythonInfo(), PythonInfo() - query = mocker.patch("python_discovery._cached_py_info._get_via_file_cache", side_effect=[first, second]) + query = mocker.patch( + "python_discovery._cached_py_info._get_via_file_cache", side_effect=[first, second], autospec=True + ) cache, env = DiskCache(tmp_path), dict(os.environ) results = [ diff --git a/tests/test_discovery.py b/tests/test_discovery.py index 4fe9c56..65ff09d 100644 --- a/tests/test_discovery.py +++ b/tests/test_discovery.py @@ -188,7 +188,7 @@ def test_bare_name_spec_does_not_probe_cwd( ) -> None: (tmp_path / name).touch(mode=0o755) monkeypatch.chdir(tmp_path) - from_exe = mocker.patch("python_discovery._discovery.PythonInfo.from_exe", return_value=None) + from_exe = mocker.patch("python_discovery._discovery.PythonInfo.from_exe", return_value=None, autospec=True) assert get_interpreter(name, [], env={"PATH": "", "UV_PYTHON_INSTALL_DIR": str(tmp_path / "uv")}) is None from_exe.assert_not_called() @@ -245,7 +245,7 @@ def test_discovery_fallback_reuses_preferred_paths( executable.touch() preferred_info = deepcopy(PythonInfo.current_system(session_cache)) preferred_info.executable = str(executable) - mocker.patch.object(PythonInfo, "from_exe", return_value=preferred_info) + mocker.patch.object(PythonInfo, "from_exe", return_value=preferred_info, autospec=True) paths = [str(executable)] result = get_interpreter( ["missing-interpreter", ">=3"], @@ -384,7 +384,7 @@ def test_get_paths_skips_relative_default(tmp_path: Path, monkeypatch: pytest.Mo (absolute_dir / "python3").touch() with ( - patch("python_discovery._discovery.os.confstr", side_effect=AttributeError, create=True), + patch("python_discovery._discovery.os.confstr", side_effect=AttributeError, create=True, autospec=True), patch("python_discovery._discovery.os.defpath", f".{os.pathsep}{absolute_dir}"), ): assert list(get_paths({})) == [absolute_dir] @@ -485,7 +485,7 @@ def test_shim_resolved_to_real_binary( monkeypatch.delenv("ASDF_DATA_DIR", raising=False) if env_var != "ASDF_DATA_DIR" else None monkeypatch.delenv("PYENV_ROOT", raising=False) if env_var != "PYENV_ROOT" else None - with patch("python_discovery._discovery.PathPythonInfo.from_exe") as mock_from_exe: + with patch("python_discovery._discovery.PathPythonInfo.from_exe", autospec=True) as mock_from_exe: mock_from_exe.return_value = None get_interpreter("python2.7", []) assert mock_from_exe.call_args_list[0][0][0] == str(real_binary) @@ -518,7 +518,7 @@ def test_shim_not_resolved_without_version_manager_env(tmp_path: Path, monkeypat monkeypatch.delenv("MISE_DATA_DIR", raising=False) monkeypatch.delenv("ASDF_DATA_DIR", raising=False) - with patch("python_discovery._discovery.PathPythonInfo.from_exe") as mock_from_exe: + with patch("python_discovery._discovery.PathPythonInfo.from_exe", autospec=True) as mock_from_exe: mock_from_exe.return_value = None get_interpreter("python2.7", []) assert mock_from_exe.call_args_list[0][0][0] == str(shim) @@ -533,7 +533,7 @@ def test_shim_falls_through_when_binary_missing(tmp_path: Path, monkeypatch: pyt monkeypatch.setenv("PYENV_ROOT", str(root)) monkeypatch.setenv("PYENV_VERSION", "2.7.18") - with patch("python_discovery._discovery.PathPythonInfo.from_exe") as mock_from_exe: + with patch("python_discovery._discovery.PathPythonInfo.from_exe", autospec=True) as mock_from_exe: mock_from_exe.return_value = None get_interpreter("python2.7", []) assert mock_from_exe.call_args_list[0][0][0] == str(shim) @@ -550,7 +550,7 @@ def test_shim_uses_python_version_file(tmp_path: Path, monkeypatch: pytest.Monke monkeypatch.delenv("PYENV_VERSION", raising=False) monkeypatch.chdir(tmp_path) - with patch("python_discovery._discovery.PathPythonInfo.from_exe") as mock_from_exe: + with patch("python_discovery._discovery.PathPythonInfo.from_exe", autospec=True) as mock_from_exe: mock_from_exe.return_value = None get_interpreter("python2.7", []) assert mock_from_exe.call_args_list[0][0][0] == str(real_binary) @@ -587,7 +587,7 @@ def test_shim_skips_path_like_version( monkeypatch.delenv("PYENV_VERSION", raising=False) monkeypatch.chdir(tmp_path) - with patch("python_discovery._discovery.PathPythonInfo.from_exe") as mock_from_exe: + with patch("python_discovery._discovery.PathPythonInfo.from_exe", autospec=True) as mock_from_exe: mock_from_exe.return_value = None get_interpreter("python2.7", []) assert mock_from_exe.call_args_list[0][0][0] == str(real_binary) @@ -603,7 +603,7 @@ def test_shim_skips_empty_environment_version(tmp_path: Path, monkeypatch: pytes monkeypatch.setenv("PYENV_ROOT", str(root)) monkeypatch.setenv("PYENV_VERSION", ":2.7.18") - with patch("python_discovery._discovery.PathPythonInfo.from_exe") as mock_from_exe: + with patch("python_discovery._discovery.PathPythonInfo.from_exe", autospec=True) as mock_from_exe: mock_from_exe.return_value = None get_interpreter("python2.7", []) assert mock_from_exe.call_args_list[0][0][0] == str(real_binary) @@ -684,7 +684,7 @@ def pyenv_probed( monkeypatch.setenv("PATH", str(root / "shims")) monkeypatch.setenv("PYENV_ROOT", str(root)) monkeypatch.delenv("PYENV_VERSION", raising=False) - from_exe = mocker.patch("python_discovery._discovery.PathPythonInfo.from_exe", return_value=None) + from_exe = mocker.patch("python_discovery._discovery.PathPythonInfo.from_exe", return_value=None, autospec=True) def _probed() -> tuple[str, ...]: get_interpreter("python2.7", []) @@ -705,7 +705,7 @@ def test_shim_pyenv_version_env_takes_priority_over_file(tmp_path: Path, monkeyp monkeypatch.setenv("PYENV_VERSION", "2.7.15") monkeypatch.chdir(tmp_path) - with patch("python_discovery._discovery.PathPythonInfo.from_exe") as mock_from_exe: + with patch("python_discovery._discovery.PathPythonInfo.from_exe", autospec=True) as mock_from_exe: mock_from_exe.return_value = None get_interpreter("python2.7", []) assert mock_from_exe.call_args_list[0][0][0] == str(env_binary) @@ -724,7 +724,7 @@ def test_shim_uses_global_version_file(tmp_path: Path, monkeypatch: pytest.Monke monkeypatch.delenv("PYENV_VERSION", raising=False) monkeypatch.chdir(workdir) - with patch("python_discovery._discovery.PathPythonInfo.from_exe") as mock_from_exe: + with patch("python_discovery._discovery.PathPythonInfo.from_exe", autospec=True) as mock_from_exe: mock_from_exe.return_value = None get_interpreter("python2.7", []) assert mock_from_exe.call_args_list[0][0][0] == str(real_binary) @@ -743,7 +743,7 @@ def test_shim_colon_separated_pyenv_version_picks_first_match( monkeypatch.setenv("PYENV_ROOT", str(root)) monkeypatch.setenv("PYENV_VERSION", "3.9.1:2.7.15") - with patch("python_discovery._discovery.PathPythonInfo.from_exe") as mock_from_exe: + with patch("python_discovery._discovery.PathPythonInfo.from_exe", autospec=True) as mock_from_exe: mock_from_exe.return_value = None get_interpreter("python2.7", []) assert mock_from_exe.call_args_list[0][0][0] == str(second_binary) @@ -813,7 +813,7 @@ def _plant(root: Path) -> str: @pytest.fixture def uv_probed(mocker: MockerFixture) -> Callable[..., list[str]]: # Run a discovery with the interrogation subprocess stubbed out, reporting the executables it reached for. - from_exe = mocker.patch("python_discovery._discovery.PathPythonInfo.from_exe", return_value=None) + from_exe = mocker.patch("python_discovery._discovery.PathPythonInfo.from_exe", return_value=None, autospec=True) def _probed(**kwargs: object) -> list[str]: get_interpreter(_NO_UV_MATCH, [], **kwargs) @@ -1034,7 +1034,7 @@ def test_discovery_uv_patch_release_matches_minor_spec( info.version_info = info.version_info._replace( major=3, minor=99, micro=2 if releaselevel == "final" else 0, releaselevel=releaselevel, serial=2 ) - mocker.patch("python_discovery._discovery.PathPythonInfo.from_exe", return_value=info) + mocker.patch("python_discovery._discovery.PathPythonInfo.from_exe", return_value=info, autospec=True) found = get_interpreter(spec, [], env={"PATH": "", "UV_PYTHON_INSTALL_DIR": str(store)}) @@ -1050,7 +1050,7 @@ def test_discovery_uv_invalid_hand_built_spec_skips_install(tmp_path: Path, mock spec.version_specifier = SimpleSpecifierSet.from_string("==invalid") info = deepcopy(PythonInfo.current_system()) info.executable = str(executable) - mocker.patch("python_discovery._discovery.PathPythonInfo.from_exe", return_value=info) + mocker.patch("python_discovery._discovery.PathPythonInfo.from_exe", return_value=info, autospec=True) candidates = list(propose_interpreters(spec, [], env={"PATH": "", "UV_PYTHON_INSTALL_DIR": str(store)})) diff --git a/tests/test_discovery_extra.py b/tests/test_discovery_extra.py index b9c4f5f..e7acb99 100644 --- a/tests/test_discovery_extra.py +++ b/tests/test_discovery_extra.py @@ -83,8 +83,8 @@ def test_lazy_path_dump_debug_non_executable(tmp_path: Path) -> None: def test_lazy_path_dump_debug_oserror(tmp_path: Path, mocker: MockerFixture) -> None: (tmp_path / "bad_file").touch() - mocker.patch("pathlib.Path.is_dir", side_effect=[False, False]) - mocker.patch("pathlib.Path.stat", side_effect=OSError("permission denied")) + mocker.patch("pathlib.Path.is_dir", side_effect=[False, False], autospec=True) + mocker.patch("pathlib.Path.stat", side_effect=OSError("permission denied"), autospec=True) repr(LazyPathDump(0, tmp_path, {"_VIRTUALENV_DEBUG": "1"})) diff --git a/tests/test_iter_interpreters.py b/tests/test_iter_interpreters.py index 085f5c2..66162ab 100644 --- a/tests/test_iter_interpreters.py +++ b/tests/test_iter_interpreters.py @@ -44,7 +44,7 @@ def uv_probed( ) -> Callable[[str | None], list[str]]: # Stub out the interrogation subprocess, so a run reports the executables discovery reached for. monkeypatch.setenv("PATH", "") - from_exe = mocker.patch("python_discovery._discovery.PathPythonInfo.from_exe", return_value=None) + from_exe = mocker.patch("python_discovery._discovery.PathPythonInfo.from_exe", return_value=None, autospec=True) def _probed(key: str | None) -> list[str]: list(iter_interpreters(key)) @@ -137,7 +137,7 @@ def test_iter_interpreters_no_key_probes_variant_builds_on_path( exe.parent.mkdir() exe.touch() monkeypatch.setenv("PATH", str(exe.parent)) - from_exe = mocker.patch("python_discovery._discovery.PathPythonInfo.from_exe", return_value=None) + from_exe = mocker.patch("python_discovery._discovery.PathPythonInfo.from_exe", return_value=None, autospec=True) list(iter_interpreters()) @@ -161,7 +161,7 @@ def test_iter_interpreters_skips_path_directories( executable = bin_dir / f"python3.98{'.exe' if IS_WIN else ''}" executable.touch() monkeypatch.setenv("PATH", str(bin_dir)) - from_exe = mocker.patch("python_discovery._discovery.PathPythonInfo.from_exe", return_value=None) + from_exe = mocker.patch("python_discovery._discovery.PathPythonInfo.from_exe", return_value=None, autospec=True) list(iter_interpreters()) @@ -207,6 +207,7 @@ def test_iter_interpreters_skips_none_from_propose( mocker.patch( "python_discovery._discovery.propose_interpreters", return_value=iter([(None, True), (real_info, True)]), + autospec=True, ) results = list(iter_interpreters(cache=session_cache)) assert results == [real_info] @@ -220,6 +221,7 @@ def test_iter_interpreters_skips_when_no_executable( mocker.patch( "python_discovery._discovery.propose_interpreters", return_value=iter([(bogus, True)]), + autospec=True, ) assert list(iter_interpreters(cache=session_cache)) == [] @@ -238,7 +240,7 @@ def test_iter_interpreters_uv_yields_when_interrogation_succeeds( fake_info = mocker.MagicMock(spec=PythonInfo, system_executable=str(uv_dir / "fake-unique" / "python")) fake_info.executable = fake_info.system_executable fake_info.satisfies.return_value = True - mocker.patch("python_discovery._discovery.PathPythonInfo.from_exe", return_value=fake_info) + mocker.patch("python_discovery._discovery.PathPythonInfo.from_exe", return_value=fake_info, autospec=True) assert fake_info in list(iter_interpreters(cache=session_cache)) @@ -294,7 +296,9 @@ def test_iter_interpreters_uv_layout( for link_rel, target_rel in symlinks: Path(str(uv_dir / link_rel)).symlink_to(uv_dir / target_rel) - mock_from_exe = mocker.patch("python_discovery._discovery.PathPythonInfo.from_exe", return_value=None) + mock_from_exe = mocker.patch( + "python_discovery._discovery.PathPythonInfo.from_exe", return_value=None, autospec=True + ) list(iter_interpreters(cache=session_cache)) interrogated_real = {os.path.realpath(call.args[0]) for call in mock_from_exe.call_args_list} @@ -506,7 +510,7 @@ def _junction(target: str, link: str) -> None: else: # pragma: win32 no cover # stand in for the junction with a symlink that does not report as one (uv_dir / link).symlink_to(uv_dir / target) - mocker.patch.object(Path, "is_symlink", return_value=False) + mocker.patch.object(Path, "is_symlink", return_value=False, autospec=True) return _junction @@ -523,6 +527,7 @@ def test_iter_interpreters_uv_probes_one_executable_per_install( return_value=mocker.create_autospec( PythonInfo, instance=True, system_executable=executable, executable=executable ), + autospec=True, ) list(iter_interpreters("graalpy3.11")) diff --git a/tests/test_py_info_collect.py b/tests/test_py_info_collect.py index edb8897..a1000bd 100644 --- a/tests/test_py_info_collect.py +++ b/tests/test_py_info_collect.py @@ -99,6 +99,7 @@ def no_framework(mocker: MockerFixture) -> None: sysconfig, "get_config_var", side_effect=lambda name: "" if name == "PYTHONFRAMEWORK" else get_config_var(name), # ty: ignore[deprecated] # resolves the deprecated Literal["SO"] overload even though name is a generic str + autospec=True, ) @@ -207,6 +208,7 @@ def test_system_executable_framework_symlink_kept(tmp_path: Path, mocker: Mocker sysconfig, "get_config_var", side_effect=lambda name: "Python" if name == "PYTHONFRAMEWORK" else get_config_var(name), # ty: ignore[deprecated] # resolves the deprecated Literal["SO"] overload even though name is a generic str + autospec=True, ) mocker.patch.object(sys, "executable", str(link)) assert PythonInfo().system_executable == str(link) @@ -305,7 +307,7 @@ def test_system_executable_versioned_fallback(mocker: MockerFixture, tmp_path: P def test_system_executable_pypy_fallback(mocker: MockerFixture, tmp_path: Path) -> None: _venv_missing_base_no_candidates(mocker, tmp_path) - mocker.patch("platform.python_implementation", return_value="PyPy") + mocker.patch("platform.python_implementation", return_value="PyPy", autospec=True) mocker.patch.object(sys, "pypy_version_info", (7, 3, 11, "final", 0), create=True) pypy = tmp_path / "pypy3" pypy.touch() @@ -329,12 +331,12 @@ def test_tcl_tk_libs_queried_with_env(tmp_path: Path, mocker: MockerFixture) -> def _fake_tkinter(mocker: MockerFixture, eval_side_effect: Callable[[str], str] | Exception) -> Mock: - evaluate = mocker.Mock(side_effect=eval_side_effect) - profile = mocker.Mock(return_value=None) + evaluate = mocker.Mock(side_effect=eval_side_effect, autospec=True) + profile = mocker.Mock(return_value=None, autospec=True) interpreter = type( "Tk", (), {"__init__": lambda self, **_: self.readprofile("Tk", "Tk"), "readprofile": profile, "eval": evaluate} ) - module = mocker.MagicMock(TclError=_TclError, Tk=interpreter) + module = mocker.MagicMock(TclError=_TclError, Tk=interpreter, autospec=True) module.Tcl.side_effect = lambda: interpreter(useTk=False) mocker.patch.dict(sys.modules, {"tkinter": module}) return profile @@ -359,7 +361,7 @@ def evaluate(script: str) -> str: raise AssertionError _fake_tkinter(mocker, evaluate) - mocker.patch.dict(sys.modules, {"_tkinter": mocker.MagicMock(TK_VERSION="8.6")}) + mocker.patch.dict(sys.modules, {"_tkinter": mocker.MagicMock(TK_VERSION="8.6", autospec=True)}) mocker.patch.dict(os.environ, {"TCL_LIBRARY": str(tcl_dir)}) info = PythonInfo() @@ -377,7 +379,9 @@ def test_tcl_tk_libs_none_on_tcl_error(tmp_path: Path, mocker: MockerFixture) -> def test_tcl_tk_libs_skip_tk_profile(tmp_path: Path, mocker: MockerFixture) -> None: profile = _fake_tkinter(mocker, {"info library": "/tcl-lib"}.__getitem__) profile.side_effect = AssertionError("Tk profile loaded") - mocker.patch("python_discovery._py_info_collect.PythonInfoCollector._resolve_tk_lib", return_value=None) + mocker.patch( + "python_discovery._py_info_collect.PythonInfoCollector._resolve_tk_lib", return_value=None, autospec=True + ) mocker.patch.dict(os.environ, {"TCL_LIBRARY": str(tmp_path)}) info = PythonInfo() @@ -388,6 +392,6 @@ def test_tcl_tk_libs_skip_tk_profile(tmp_path: Path, mocker: MockerFixture) -> N def test_system_stdlib_empty_when_scheme_lacks_path(mocker: MockerFixture) -> None: names = tuple(name for name in sysconfig.get_path_names() if name not in {"stdlib", "platstdlib"}) - mocker.patch.object(sysconfig, "get_path_names", return_value=names) + mocker.patch.object(sysconfig, "get_path_names", return_value=names, autospec=True) info = PythonInfo() assert (info.system_stdlib, info.system_stdlib_platform) == ("", "") diff --git a/tests/test_py_info_extra.py b/tests/test_py_info_extra.py index 265dace..daa4108 100644 --- a/tests/test_py_info_extra.py +++ b/tests/test_py_info_extra.py @@ -20,14 +20,14 @@ def test_py_info_pypy_version(mocker: MockerFixture) -> None: - mocker.patch("platform.python_implementation", return_value="PyPy") + mocker.patch("platform.python_implementation", return_value="PyPy", autospec=True) mocker.patch.object(sys, "pypy_version_info", (7, 3, 11, "final", 0), create=True) info = PythonInfo() assert (info.implementation, info.pypy_version_info) == ("PyPy", (7, 3, 11, "final", 0)) def test_py_info_canonicalizes_graalvm(mocker: MockerFixture) -> None: - mocker.patch("platform.python_implementation", return_value="GraalVM") + mocker.patch("platform.python_implementation", return_value="GraalVM", autospec=True) info = PythonInfo() assert info.implementation == "GraalPy" @@ -139,7 +139,7 @@ def test_resolve_to_system_circle(mocker: MockerFixture, caplog: pytest.LogCaptu third.real_prefix = None third.base_prefix = "/prefix_a" - mocker.patch.object(PythonInfo, "discover_exe", side_effect=[second, third]) + mocker.patch.object(PythonInfo, "discover_exe", side_effect=[second, third], autospec=True) with pytest.raises(RuntimeError, match="prefixes are causing a circle"): PythonInfo.resolve_to_system(None, target) @@ -157,7 +157,7 @@ def test_resolve_to_system_single_prefix_self_link(mocker: MockerFixture, caplog second.real_prefix = None second.base_prefix = "/prefix_a" - mocker.patch.object(PythonInfo, "discover_exe", return_value=second) + mocker.patch.object(PythonInfo, "discover_exe", return_value=second, autospec=True) result = PythonInfo.resolve_to_system(None, target) assert result.system_executable is not None @@ -172,7 +172,7 @@ def test_resolve_to_system_repeated_prefix_with_parent_segments(tmp_path: Path, repeated = PythonInfo() repeated.system_executable = repeated.real_prefix = None repeated.base_prefix = str(tmp_path / "child" / "..") - mocker.patch.object(PythonInfo, "discover_exe", return_value=repeated) + mocker.patch.object(PythonInfo, "discover_exe", return_value=repeated, autospec=True) result = PythonInfo.resolve_to_system(None, target) @@ -362,8 +362,9 @@ def test_from_exe_resolve_error(mocker: MockerFixture, caplog: pytest.LogCapture mocker.patch( "python_discovery._cached_py_info.from_exe", return_value=fake_info, + autospec=True, ) - mocker.patch.object(PythonInfo, "resolve_to_system", side_effect=RuntimeError("test error")) + mocker.patch.object(PythonInfo, "resolve_to_system", side_effect=RuntimeError("test error"), autospec=True) result = PythonInfo.from_exe(sys.executable, raise_on_error=False, resolve_to_host=True) assert result is None assert any("cannot resolve system" in r.message for r in caplog.records) @@ -390,7 +391,7 @@ def test_satisfies_abs_spec_path_match() -> None: def test_current_returns_none_raises(mocker: MockerFixture) -> None: PythonInfo._current = None - mocker.patch.object(PythonInfo, "from_exe", return_value=None) + mocker.patch.object(PythonInfo, "from_exe", return_value=None, autospec=True) with pytest.raises(RuntimeError, match="failed to query current Python interpreter"): PythonInfo.current() PythonInfo._current = None @@ -398,7 +399,7 @@ def test_current_returns_none_raises(mocker: MockerFixture) -> None: def test_current_system_returns_none_raises(mocker: MockerFixture) -> None: PythonInfo._current_system = None - mocker.patch.object(PythonInfo, "from_exe", return_value=None) + mocker.patch.object(PythonInfo, "from_exe", return_value=None, autospec=True) with pytest.raises(RuntimeError, match="failed to query current system Python interpreter"): PythonInfo.current_system() PythonInfo._current_system = None @@ -407,7 +408,7 @@ def test_current_system_returns_none_raises(mocker: MockerFixture) -> None: def test_check_exe_from_exe_returns_none(tmp_path: Path, mocker: MockerFixture) -> None: info = copy.deepcopy(_CURRENT) (tmp_path / "python").touch() - mocker.patch.object(PythonInfo, "from_exe", return_value=None) + mocker.patch.object(PythonInfo, "from_exe", return_value=None, autospec=True) result = info._check_exe(MagicMock(), str(tmp_path), "python", [], dict(os.environ), exact=True) assert result is None @@ -417,7 +418,7 @@ def test_check_exe_mismatch_not_exact(tmp_path: Path, mocker: MockerFixture) -> (tmp_path / "python").touch() other = copy.deepcopy(_CURRENT) other.architecture = 32 if info.architecture == 64 else 64 - mocker.patch.object(PythonInfo, "from_exe", return_value=other) + mocker.patch.object(PythonInfo, "from_exe", return_value=other, autospec=True) discovered: list[PythonInfo] = [] result = info._check_exe(MagicMock(), str(tmp_path), "python", discovered, dict(os.environ), exact=False) assert result is None @@ -429,7 +430,7 @@ def test_check_exe_mismatch_exact(tmp_path: Path, mocker: MockerFixture) -> None (tmp_path / "python").touch() other = copy.deepcopy(_CURRENT) other.architecture = 32 if info.architecture == 64 else 64 - mocker.patch.object(PythonInfo, "from_exe", return_value=other) + mocker.patch.object(PythonInfo, "from_exe", return_value=other, autospec=True) discovered: list[PythonInfo] = [] result = info._check_exe(MagicMock(), str(tmp_path), "python", discovered, dict(os.environ), exact=True) assert result is None @@ -456,7 +457,7 @@ def test_possible_base_case_sensitive(mocker: MockerFixture) -> None: info = copy.deepcopy(_CURRENT) info.executable = "/usr/bin/CPython3.12" info.implementation = "CPython" - mocker.patch("python_discovery._compat.fs_is_case_sensitive", return_value=True) + mocker.patch("python_discovery._compat.fs_is_case_sensitive", return_value=True, autospec=True) names = list(info._possible_base()) lower_names = [n for n in names if n.islower()] upper_names = [n for n in names if n.isupper()] @@ -468,7 +469,7 @@ def test_possible_base_case_sensitive_upper_equals_base(mocker: MockerFixture) - info = copy.deepcopy(_CURRENT) info.executable = "/usr/bin/JYTHON" info.implementation = "JYTHON" - mocker.patch("python_discovery._compat.fs_is_case_sensitive", return_value=True) + mocker.patch("python_discovery._compat.fs_is_case_sensitive", return_value=True, autospec=True) names = list(info._possible_base()) assert "jython" in names assert "JYTHON" in names @@ -484,7 +485,7 @@ def test_resolve_to_system_resolved_from_exe(mocker: MockerFixture, caplog: pyte resolved.system_executable = "/some/system/python" resolved.executable = "/some/system/python" - mocker.patch.object(PythonInfo, "from_exe", return_value=resolved) + mocker.patch.object(PythonInfo, "from_exe", return_value=resolved, autospec=True) result = PythonInfo.resolve_to_system(None, target) assert result.executable == "/some/venv/python" @@ -507,6 +508,7 @@ def test_from_exe_uses_supplied_environment_for_base_probe( "venv": target, "system": mapped if kwargs["env"] == {"PYTHONPATH": "mapped"} else process_only, }[exe], + autospec=True, ) info = PythonInfo.from_exe("venv", env={"PYTHONPATH": "mapped"}) @@ -530,7 +532,10 @@ def test_resolve_to_system_uses_supplied_environment_for_prefix_probe(mocker: Mo mocker.patch.object( PythonInfo, "discover_exe", - side_effect=lambda _cache, **kwargs: mapped if kwargs.get("env") == {"PYTHONPATH": "mapped"} else fallback, + side_effect=lambda _cls, _cache, **kwargs: ( + mapped if kwargs.get("env") == {"PYTHONPATH": "mapped"} else fallback + ), + autospec=True, ) resolved = PythonInfo.resolve_to_system(None, target, env={"PYTHONPATH": "mapped"}) @@ -543,7 +548,7 @@ def test_resolve_to_system_from_exe_returns_none(mocker: MockerFixture) -> None: target.system_executable = "/some/system/python" target.executable = "/some/venv/python" - mocker.patch.object(PythonInfo, "from_exe", return_value=None) + mocker.patch.object(PythonInfo, "from_exe", return_value=None, autospec=True) result = PythonInfo.resolve_to_system(None, target) assert result.executable == "/some/venv/python" @@ -558,8 +563,8 @@ def test_resolve_to_system_from_exe_returns_none(mocker: MockerFixture) -> None: def test_resolve_to_system_leaves_shared_info_untouched(mocker: MockerFixture, system_executable: str | None) -> None: shared = copy.deepcopy(_CURRENT) shared.system_executable = shared.executable = "/some/system/python" - mocker.patch.object(PythonInfo, "from_exe", return_value=shared) - mocker.patch.object(PythonInfo, "discover_exe", return_value=shared) + mocker.patch.object(PythonInfo, "from_exe", return_value=shared, autospec=True) + mocker.patch.object(PythonInfo, "discover_exe", return_value=shared, autospec=True) results: list[PythonInfo] = [] for venv in ("/some/venv-a", "/some/venv-b"): diff --git a/tests/windows/conftest.py b/tests/windows/conftest.py index d0ab255..c232ee8 100644 --- a/tests/windows/conftest.py +++ b/tests/windows/conftest.py @@ -147,7 +147,7 @@ def _populate_pyinfo_cache(monkeypatch: pytest.MonkeyPatch) -> None: def _mock_pyinfo(major: int, minor: int, arch: int, exe: str, threaded: bool) -> MagicMock: from python_discovery._py_info import VersionInfo - info = MagicMock(base_prefix=str(Path(exe).parent)) + info = MagicMock(base_prefix=str(Path(exe).parent), autospec=True) info.executable = info.original_executable = info.system_executable = exe info.implementation = "CPython" info.architecture = arch From 5f68d6b30aab2634573bb640d3b2b3b9db7c6d48 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Sat, 7 Nov 2026 07:36:03 -0800 Subject: [PATCH 09/25] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor:=20annotate?= =?UTF-8?q?=20module=20constants=20Final?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Constants that are never reassigned now say so with Final, so a type checker rejects a later rebind. --- src/python_discovery/_py_spec.py | 4 ++-- tests/py_info/test_py_info.py | 6 +++--- tests/py_info/test_py_info_exe_based_of.py | 5 +++-- tests/test_py_info_collect.py | 4 ++-- tests/test_py_info_extra.py | 4 ++-- tests/windows/winreg_mock_values.py | 10 ++++++---- 6 files changed, 18 insertions(+), 15 deletions(-) diff --git a/src/python_discovery/_py_spec.py b/src/python_discovery/_py_spec.py index 0c08365..a9fa5ee 100644 --- a/src/python_discovery/_py_spec.py +++ b/src/python_discovery/_py_spec.py @@ -16,7 +16,7 @@ (?:-(?P[a-zA-Z0-9_.]+))? # ISA (e.g. arm64, x86_64, i86pc.64bit) $ """ -_PATTERN = re.compile( +_PATTERN: Final[re.Pattern[str]] = re.compile( r""" ^ (?P[a-zA-Z]+)? # implementation (e.g. cpython, pypy) @@ -25,7 +25,7 @@ + _MODIFIERS, re.VERBOSE, ) -_SPECIFIER_PATTERN = re.compile( +_SPECIFIER_PATTERN: Final[re.Pattern[str]] = re.compile( r""" ^ (?:(?P[A-Za-z]+)\s*)? # optional implementation prefix diff --git a/tests/py_info/test_py_info.py b/tests/py_info/test_py_info.py index 78e360c..0c94b17 100644 --- a/tests/py_info/test_py_info.py +++ b/tests/py_info/test_py_info.py @@ -23,9 +23,9 @@ from packaging.markers import Environment from pytest_mock import MockerFixture -_IS_PYPY = PythonInfo.current_system().implementation == "PyPy" +_IS_PYPY: Final[bool] = PythonInfo.current_system().implementation == "PyPy" -_CURRENT = PythonInfo.current_system() +_CURRENT: Final[PythonInfo] = PythonInfo.current_system() @pytest.mark.graalpy @@ -127,7 +127,7 @@ def _generate_not_match_current_interpreter_version() -> list[str]: return result -_NON_MATCH_VER = _generate_not_match_current_interpreter_version() +_NON_MATCH_VER: Final[list[str]] = _generate_not_match_current_interpreter_version() @pytest.mark.parametrize("spec", _NON_MATCH_VER) diff --git a/tests/py_info/test_py_info_exe_based_of.py b/tests/py_info/test_py_info_exe_based_of.py index 37c3cfa..badd4cc 100644 --- a/tests/py_info/test_py_info_exe_based_of.py +++ b/tests/py_info/test_py_info_exe_based_of.py @@ -2,6 +2,7 @@ import logging from pathlib import Path +from typing import Final import pytest @@ -10,7 +11,7 @@ from python_discovery._discovery import IS_WIN from python_discovery._py_info import EXTENSIONS -_CURRENT = PythonInfo.current() +_CURRENT: Final[PythonInfo] = PythonInfo.current() def _fs_supports_symlink() -> bool: @@ -33,7 +34,7 @@ def _discover_base_folders() -> tuple[str, ...]: return tuple(folders) -_BASE = _discover_base_folders() +_BASE: Final[tuple[str, ...]] = _discover_base_folders() @pytest.mark.skipif(not _fs_supports_symlink(), reason="symlink is not supported") diff --git a/tests/test_py_info_collect.py b/tests/test_py_info_collect.py index a1000bd..4d264b8 100644 --- a/tests/test_py_info_collect.py +++ b/tests/test_py_info_collect.py @@ -8,7 +8,7 @@ from itertools import takewhile from pathlib import Path from subprocess import run -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Final import pytest import vermin @@ -23,7 +23,7 @@ from pytest_mock import MockerFixture -_SCRIPT = Path(python_discovery.__file__).parent / "_py_info_collect.py" +_SCRIPT: Final[Path] = Path(python_discovery.__file__).parent / "_py_info_collect.py" def test_script_parses_down_to_python27() -> None: diff --git a/tests/test_py_info_extra.py b/tests/test_py_info_extra.py index daa4108..62b5c24 100644 --- a/tests/test_py_info_extra.py +++ b/tests/test_py_info_extra.py @@ -5,7 +5,7 @@ import os import sys from pathlib import Path -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Final from unittest.mock import MagicMock import pytest @@ -16,7 +16,7 @@ if TYPE_CHECKING: from pytest_mock import MockerFixture -_CURRENT = PythonInfo.current_system() +_CURRENT: Final[PythonInfo] = PythonInfo.current_system() def test_py_info_pypy_version(mocker: MockerFixture) -> None: diff --git a/tests/windows/winreg_mock_values.py b/tests/windows/winreg_mock_values.py index e218180..ed06aa6 100644 --- a/tests/windows/winreg_mock_values.py +++ b/tests/windows/winreg_mock_values.py @@ -1,6 +1,8 @@ from __future__ import annotations -hive_open = { +from typing import Final + +hive_open: Final[dict[tuple[object, ...], object]] = { (winreg.HKEY_CURRENT_USER, "Software\\Python", 0, winreg.KEY_READ): 78701856, (winreg.HKEY_LOCAL_MACHINE, "Software\\Python", 0, winreg.KEY_READ | winreg.KEY_WOW64_64KEY): 78701840, (winreg.HKEY_LOCAL_MACHINE, "Software\\Python", 0, winreg.KEY_READ | winreg.KEY_WOW64_32KEY): OSError( @@ -8,7 +10,7 @@ "The system cannot find the file specified", ), } -key_open = { +key_open: Final[dict[object, dict[str, object]]] = { 78701152: { "Anaconda310-32\\InstallPath": 78703200, "Anaconda310-32": 78703568, @@ -44,7 +46,7 @@ "3.6": 88820000, }, } -value_collect = { +value_collect: Final[dict[object, dict[str, object]]] = { 78703568: {"SysVersion": ("3.10", 1), "SysArchitecture": ("32bit", 1), "DisplayName": ("Python 3.10 (32-bit)", 1)}, 78703200: { "ExecutablePath": ("C:\\Users\\user\\Miniconda3\\python.exe", 1), @@ -143,7 +145,7 @@ "DisplayName": OSError(2, "The system cannot find the file specified"), }, } -enum_collect = { +enum_collect: Final[dict[object, dict[int, object]]] = { 78701856: [ "ContinuumAnalytics", "PythonCore", From 5e0d3e1a633a61b10103d48f5fdf3f6b09c3193b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Sat, 7 Nov 2026 07:40:15 -0800 Subject: [PATCH 10/25] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor(test):=20gi?= =?UTF-8?q?ve=20parametrize=20rows=20ids?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rows without an id get pytest's generated names, which change when the values do. Each row now carries a stable id. --- tests/py_info/test_py_info.py | 9 +++-- tests/py_info/test_py_info_exe_based_of.py | 10 ++++-- tests/test_py_spec.py | 19 ++++++---- tests/windows/test_windows.py | 40 +++++++++++++++------- 4 files changed, 55 insertions(+), 23 deletions(-) diff --git a/tests/py_info/test_py_info.py b/tests/py_info/test_py_info.py index 0c94b17..110df78 100644 --- a/tests/py_info/test_py_info.py +++ b/tests/py_info/test_py_info.py @@ -169,29 +169,32 @@ class PyInfoMock(NamedTuple): @pytest.mark.parametrize( ("target", "position", "discovered"), [ - ( + pytest.param( PyInfoMock("CPython", 64, VersionInfo(3, 6, 8, "final", 0)), 0, [ PyInfoMock("CPython", 64, VersionInfo(3, 6, 9, "final", 0)), PyInfoMock("PyPy", 64, VersionInfo(3, 6, 8, "final", 0)), ], + id="pypy-candidate-3.6.8", ), - ( + pytest.param( PyInfoMock("CPython", 64, VersionInfo(3, 6, 8, "final", 0)), 0, [ PyInfoMock("CPython", 64, VersionInfo(3, 6, 9, "final", 0)), PyInfoMock("CPython", 32, VersionInfo(3, 6, 9, "final", 0)), ], + id="cpython-32bit-candidate-3.6.9", ), - ( + pytest.param( PyInfoMock("CPython", 64, VersionInfo(3, 8, 1, "final", 0)), 0, [ PyInfoMock("CPython", 32, VersionInfo(2, 7, 12, "rc", 2)), PyInfoMock("PyPy", 64, VersionInfo(3, 8, 1, "final", 0)), ], + id="pypy-candidate-3.8.1", ), ], ) diff --git a/tests/py_info/test_py_info_exe_based_of.py b/tests/py_info/test_py_info_exe_based_of.py index badd4cc..7854d88 100644 --- a/tests/py_info/test_py_info_exe_based_of.py +++ b/tests/py_info/test_py_info_exe_based_of.py @@ -40,9 +40,15 @@ def _discover_base_folders() -> tuple[str, ...]: @pytest.mark.skipif(not _fs_supports_symlink(), reason="symlink is not supported") @pytest.mark.parametrize("suffix", sorted({".exe", ""} & set(EXTENSIONS) if IS_WIN else [""])) @pytest.mark.parametrize("into", _BASE) -@pytest.mark.parametrize("arch", [_CURRENT.architecture, ""]) +@pytest.mark.parametrize( + "arch", + [pytest.param(_CURRENT.architecture, id="current"), pytest.param("", id="empty")], +) @pytest.mark.parametrize("version", [".".join(str(i) for i in _CURRENT.version_info[0:i]) for i in range(3, 0, -1)]) -@pytest.mark.parametrize("impl", [_CURRENT.implementation, "python"]) +@pytest.mark.parametrize( + "impl", + [pytest.param(_CURRENT.implementation, id="current"), pytest.param("python", id="python")], +) def test_discover_ok( tmp_path: Path, suffix: str, diff --git a/tests/test_py_spec.py b/tests/test_py_spec.py index 8fd5e24..d792c85 100644 --- a/tests/test_py_spec.py +++ b/tests/test_py_spec.py @@ -115,7 +115,11 @@ def test_spec_satisfies_free_threaded() -> None: @pytest.mark.parametrize( ("req", "spec"), - [("py", "python"), ("jython", "jython"), ("CPython", "cpython")], + [ + pytest.param("py", "python", id="py"), + pytest.param("jython", "jython", id="jython"), + pytest.param("CPython", "cpython", id="CPython"), + ], ) def test_spec_satisfies_implementation_ok(req: str, spec: str) -> None: spec_1 = PythonSpec.from_string_spec(req) @@ -190,10 +194,10 @@ def test_relative_spec(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: @pytest.mark.parametrize( ("text", "expected"), [ - (">=3.12", ">=3.12"), - ("python>=3.12", ">=3.12"), - ("cpython!=3.11.*", "!=3.11.*"), - ("<=3.13,>=3.12", "<=3.13,>=3.12"), + pytest.param(">=3.12", ">=3.12", id=">=3.12"), + pytest.param("python>=3.12", ">=3.12", id="python>=3.12"), + pytest.param("cpython!=3.11.*", "!=3.11.*", id="cpython!=3.11.*"), + pytest.param("<=3.13,>=3.12", "<=3.13,>=3.12", id="<=3.13,>=3.12"), ], ) def test_specifier_parsing(text: str, expected: str) -> None: @@ -328,7 +332,10 @@ def test_spec_repr_machine(spec_str: str, in_repr: str) -> None: assert in_repr in repr(PythonSpec.from_string_spec(spec_str)) -@pytest.mark.parametrize(("left", "right"), [("graalpy", "graalvm"), ("graalvm", "graalpy")]) +@pytest.mark.parametrize( + ("left", "right"), + [pytest.param("graalpy", "graalvm", id="graalpy"), pytest.param("graalvm", "graalpy", id="graalvm")], +) def test_spec_satisfies_graalpy_implementation_aliases(left: str, right: str) -> None: spec_1 = PythonSpec.from_string_spec(left) spec_2 = PythonSpec.from_string_spec(right) diff --git a/tests/windows/test_windows.py b/tests/windows/test_windows.py index f185ccc..6fac48c 100644 --- a/tests/windows/test_windows.py +++ b/tests/windows/test_windows.py @@ -25,18 +25,34 @@ def test_discovery_via_path_literal_symlink(tmp_path: Path) -> None: @pytest.mark.parametrize( ("string_spec", "expected_exe"), [ - ("python3.10", "C:\\Users\\user\\Miniconda3-64\\python.exe"), - ("cpython3.10", "C:\\Users\\user\\Miniconda3-64\\python.exe"), - ("python3.12", "C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python312\\python.exe"), - ("cpython3.12", "C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python312\\python.exe"), - ("python", "C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python313\\python3.13t.exe"), - ("cpython", "C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python313\\python3.13t.exe"), - ("python3", "C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python312\\python.exe"), - ("cpython3", "C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python312\\python.exe"), - ("python3.6", "Z:\\CompanyA\\Python\\3.6\\python.exe"), - ("cpython3.6", "Z:\\CompanyA\\Python\\3.6\\python.exe"), - ("3t", "C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python313\\python3.13t.exe"), - ("python3.13t", "C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python313\\python3.13t.exe"), + pytest.param("python3.10", "C:\\Users\\user\\Miniconda3-64\\python.exe", id="python3.10"), + pytest.param("cpython3.10", "C:\\Users\\user\\Miniconda3-64\\python.exe", id="cpython3.10"), + pytest.param( + "python3.12", "C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python312\\python.exe", id="python3.12" + ), + pytest.param( + "cpython3.12", "C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python312\\python.exe", id="cpython3.12" + ), + pytest.param( + "python", "C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python313\\python3.13t.exe", id="python" + ), + pytest.param( + "cpython", "C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python313\\python3.13t.exe", id="cpython" + ), + pytest.param( + "python3", "C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python312\\python.exe", id="python3" + ), + pytest.param( + "cpython3", "C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python312\\python.exe", id="cpython3" + ), + pytest.param("python3.6", "Z:\\CompanyA\\Python\\3.6\\python.exe", id="python3.6"), + pytest.param("cpython3.6", "Z:\\CompanyA\\Python\\3.6\\python.exe", id="cpython3.6"), + pytest.param("3t", "C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python313\\python3.13t.exe", id="3t"), + pytest.param( + "python3.13t", + "C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python313\\python3.13t.exe", + id="python3.13t", + ), ], ) def test_propose_interpreters(string_spec: str, expected_exe: str) -> None: From f5cfb178bd13ee1f710b48e3140ca05d094f3903 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Sat, 7 Nov 2026 07:47:04 -0800 Subject: [PATCH 11/25] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor:=20clear=20?= =?UTF-8?q?simplint's=20source=20findings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Name regex groups, fold single-use module constants into their functions, and build text with format or f-strings where it was concatenated. Split the cognitive-complexity hot spot in the uv matcher and document public names that simplint asks about. The version-gate script keeps .format so it still parses on Python 2.7. --- src/python_discovery/__init__.py | 1 + src/python_discovery/_discovery.py | 43 +++++++++++++---------- src/python_discovery/_py_info.py | 27 ++++++++++++-- src/python_discovery/_py_info_collect.py | 10 +++--- src/python_discovery/_py_spec.py | 26 +++++++------- src/python_discovery/_specifier.py | 26 +++++++------- src/python_discovery/_windows/_pep514.py | 14 ++++---- src/python_discovery/_windows/_propose.py | 6 ++-- 8 files changed, 92 insertions(+), 61 deletions(-) diff --git a/src/python_discovery/__init__.py b/src/python_discovery/__init__.py index 85cd9d8..9d960d6 100644 --- a/src/python_discovery/__init__.py +++ b/src/python_discovery/__init__.py @@ -10,6 +10,7 @@ from ._py_spec import KNOWN_IMPLEMENTATIONS, PythonSpec from ._specifier import SimpleSpecifier, SimpleSpecifierSet, SimpleVersion +#: the installed distribution version __version__ = version("python-discovery") __all__ = [ diff --git a/src/python_discovery/_discovery.py b/src/python_discovery/_discovery.py index 38c1aca..b719d95 100644 --- a/src/python_discovery/_discovery.py +++ b/src/python_discovery/_discovery.py @@ -13,7 +13,7 @@ from ._compat import fs_path_id from ._py_info import PythonInfo from ._py_spec import KNOWN_IMPLEMENTATIONS, PythonSpec -from ._specifier import DC_KW, SimpleVersion +from ._specifier import DC_KW, SimpleSpecifier, SimpleVersion if TYPE_CHECKING: from collections.abc import Callable, Generator, Iterable, Iterator, Mapping, Sequence @@ -41,6 +41,7 @@ def get_interpreter( (``cpython3.12``), or a `version specifier `_ (``>=3.10``). When a sequence is given each entry is tried in order. + :param try_first_with: executables to probe before the normal discovery search. :param cache: interpreter metadata cache; when ``None`` results are not cached. :param env: environment mapping for ``PATH`` and uv store lookup; defaults to :data:`os.environ`. @@ -413,27 +414,31 @@ def _uv_version_matches(key: _UvKey, spec: PythonSpec) -> bool: # GraalPy keys carry the GraalVM version in older uv releases, so only CPython and PyPy state a Python version if key.implementation not in {"cpython", "pypy"}: return True - if spec.version_specifier is not None: - for specifier in spec.version_specifier: - if specifier.version is None: - return False - # Match at the requested precision, as PythonInfo.satisfies does after probing. - numeric_version = specifier.version_str - for prefix in ("rc", "b", "a"): - if prefix in numeric_version: - numeric_version = numeric_version.split(prefix)[0] - break - precision = numeric_version.count(".") + 1 - release = ".".join(str(part) for part in key.version.release[:precision]) - if key.version.pre_type is not None and ( - precision == 3 or specifier.version.pre_type is not None # ruff:ignore[magic-value-comparison] 3 is the precision of a full version tuple - ): - release = f"{release}{key.version.pre_type}{key.version.pre_num}" - if not specifier.contains(release): - return False + if spec.version_specifier is not None and not all( + _uv_specifier_matches(key, specifier) for specifier in spec.version_specifier + ): + return False return all(req is None or our == req for our, req in zip(key.version.release, (spec.major, spec.minor, spec.micro))) +def _uv_specifier_matches(key: _UvKey, specifier: SimpleSpecifier) -> bool: + if specifier.version is None: + return False + # Match at the requested precision, as PythonInfo.satisfies does after probing. + numeric_version = specifier.version_str + for prefix in ("rc", "b", "a"): + if prefix in numeric_version: + numeric_version = numeric_version.split(prefix)[0] + break + precision = numeric_version.count(".") + 1 + release = ".".join(str(part) for part in key.version.release[:precision]) + if key.version.pre_type is not None and ( + precision == 3 or specifier.version.pre_type is not None # ruff:ignore[magic-value-comparison] 3 is the precision of a full version tuple + ): + release = f"{release}{key.version.pre_type}{key.version.pre_num}" + return specifier.contains(release) + + def _parse_uv_install(path: Path) -> _UvInstall: implementation, _, remainder = path.name.partition("-") version_str, _, variant = remainder.partition("-")[0].partition("+") diff --git a/src/python_discovery/_py_info.py b/src/python_discovery/_py_info.py index 10117a7..cf05729 100644 --- a/src/python_discovery/_py_info.py +++ b/src/python_discovery/_py_info.py @@ -59,7 +59,9 @@ class PythonInfo: # ruff:ignore[too-many-public-methods] PythonInfo is the publ :ivar os: the operating system name, as reported by ``os.name``. :ivar free_threaded: whether the interpreter was built free-threaded. :ivar debug_build: whether the interpreter was built with ``Py_DEBUG``. + :ivar marker_environment: the PEP 508 marker environment of the interpreter. + :ivar prefix: the ``sys.prefix`` of the interpreter. :ivar base_prefix: the ``sys.base_prefix`` of the interpreter. :ivar real_prefix: the ``sys.real_prefix`` set by legacy virtualenv, or ``None``. @@ -69,11 +71,14 @@ class PythonInfo: # ruff:ignore[too-many-public-methods] PythonInfo is the publ :ivar original_executable: the absolute path to the executable, without resolving symlinks. :ivar system_executable: the system interpreter behind any virtualenv, or ``None``. :ivar has_venv: whether the :mod:`venv` module can be imported. + :ivar path: the ``sys.path`` of the interpreter. :ivar file_system_encoding: the filesystem encoding, from ``sys.getfilesystemencoding()``. :ivar stdout_encoding: the encoding of ``sys.stdout``, or ``None``. + :ivar sysconfig_scheme: the installation scheme behind ``sysconfig_paths``, or ``None``. :ivar sysconfig_paths: the installation paths, keyed by :mod:`sysconfig` path name. + :ivar distutils_install: the distutils installation directories, empty when sysconfig supplies them. :ivar sysconfig: the :mod:`sysconfig` build metadata, such as the makefile filename. :ivar sysconfig_vars: the :mod:`sysconfig` config variables queried for this interpreter. @@ -126,7 +131,7 @@ class PythonInfo: # ruff:ignore[too-many-public-methods] PythonInfo is the publ _cache_exe_discovery: ClassVar[dict[tuple[str, bool, VersionInfo], PythonInfo]] = {} def __init__(self) -> None: - self.__dict__.update(vars(self.from_dict(PythonInfoCollector().to_dict()))) + self.__dict__.update(self.from_dict(PythonInfoCollector().to_dict()).__dict__) @classmethod def from_dict(cls, data: dict[str, InfoValue]) -> PythonInfo: @@ -280,8 +285,8 @@ def resolve_to_system( _LOGGER.info("%r links back to itself via prefixes", target) target.system_executable = target.executable break - for at, (p, t) in enumerate(prefixes.items(), start=1): - _LOGGER.error("%d: prefix=%s, info=%r", at, p, t) + for at, (prefix_path, prefix_info) in enumerate(prefixes.items(), start=1): + _LOGGER.error("%d: prefix=%s, info=%r", at, prefix_path, prefix_info) _LOGGER.error("%d: prefix=%s, info=%r", len(prefixes) + 1, prefix, target) msg = "prefixes are causing a circle {}".format("|".join(prefixes.keys())) raise RuntimeError(msg) @@ -701,6 +706,22 @@ def sort_by(info: PythonInfo) -> int: class MarkerEnvironment(TypedDict): + """ + Environment markers of an interpreter, as defined by PEP 508. + + :ivar implementation_name: the ``implementation_name`` marker. + :ivar implementation_version: the ``implementation_version`` marker. + :ivar os_name: the ``os_name`` marker. + :ivar platform_machine: the ``platform_machine`` marker. + :ivar platform_release: the ``platform_release`` marker. + :ivar platform_system: the ``platform_system`` marker. + :ivar platform_version: the ``platform_version`` marker. + :ivar python_full_version: the ``python_full_version`` marker. + :ivar platform_python_implementation: the ``platform_python_implementation`` marker. + :ivar python_version: the ``python_version`` marker. + :ivar sys_platform: the ``sys_platform`` marker. + """ + implementation_name: str implementation_version: str os_name: str diff --git a/src/python_discovery/_py_info_collect.py b/src/python_discovery/_py_info_collect.py index 67a756f..78f5d24 100644 --- a/src/python_discovery/_py_info_collect.py +++ b/src/python_discovery/_py_info_collect.py @@ -14,7 +14,9 @@ UNSUPPORTED_MARKER = "unsupported Python version " if __name__ == "__main__" and sys.version_info[:2] < MIN_INTERROGATE_VERSION: # pragma: no cover # needs <3.6 - sys.stderr.write(UNSUPPORTED_MARKER + ".".join(str(i) for i in sys.version_info[:3]) + "\n" + sys.executable) + sys.stderr.write( + "{}{}\n{}".format(UNSUPPORTED_MARKER, ".".join(str(i) for i in sys.version_info[:3]), sys.executable) + ) raise SystemExit(UNSUPPORTED_EXIT_CODE) import json @@ -29,7 +31,6 @@ # ruff:ignore[collections-named-tuple] # typing.NamedTuple class syntax needs annotations, unavailable on 3.6 VersionInfo = namedtuple("VersionInfo", ["major", "minor", "micro", "releaselevel", "serial"]) -_32BIT_POINTER_SIZE = 4 _CONF_VAR_RE = re.compile( r""" \{ \w+ } # sysconfig variable placeholder like {base} @@ -56,7 +57,8 @@ def _init_identity(self): self.version_info = VersionInfo(*sys.version_info) # same as stdlib platform.architecture to account for pointer size != max int - self.architecture = 32 if struct.calcsize("P") == _32BIT_POINTER_SIZE else 64 + pointer_bytes = 4 + self.architecture = 32 if struct.calcsize("P") == pointer_bytes else 64 self.sysconfig_platform = sysconfig.get_platform() self.version_nodot = sysconfig.get_config_var("py_version_nodot") self.version = sys.version @@ -362,7 +364,7 @@ def _sysconfig_path(self, key, config_var): def to_dict(self): # Convert the collected information to a plain dictionary. - data = {var: (getattr(self, var) if var != "_creators" else None) for var in vars(self)} + data = {var: (getattr(self, var) if var != "_creators" else None) for var in self.__dict__} data["version_info"] = self.version_info._asdict() return data diff --git a/src/python_discovery/_py_spec.py b/src/python_discovery/_py_spec.py index a9fa5ee..c42cea4 100644 --- a/src/python_discovery/_py_spec.py +++ b/src/python_discovery/_py_spec.py @@ -17,27 +17,25 @@ $ """ _PATTERN: Final[re.Pattern[str]] = re.compile( - r""" + rf""" ^ (?P[a-zA-Z]+)? # implementation (e.g. cpython, pypy) (?P[0-9.]+)? # version (e.g. 3.12, 3.12.1) - """ - + _MODIFIERS, + {_MODIFIERS} + """, re.VERBOSE, ) _SPECIFIER_PATTERN: Final[re.Pattern[str]] = re.compile( - r""" + rf""" ^ (?:(?P[A-Za-z]+)\s*)? # optional implementation prefix (?P(?:===|==|~=|!=|<=|>=|<|>).*?) # PEP 440 version specifier, lazy so the modifiers can follow it - """ - + _MODIFIERS, + {_MODIFIERS} + """, re.VERBOSE, ) -_MAX_VERSION_PARTS: Final[int] = 3 -_SINGLE_DIGIT_MAX: Final[int] = 9 - +#: implementation names accepted as a spec prefix KNOWN_IMPLEMENTATIONS: Final[tuple[str, ...]] = ("python", "cpython", "pypy", "graalpy") SpecifierSet = SimpleSpecifierSet @@ -51,19 +49,21 @@ def _int_or_none(val: str | None) -> int | None: def _parse_version_parts(version: str) -> tuple[int | None, int | None, int | None]: + max_version_parts = 3 + single_digit_max = 9 versions = tuple(int(i) for i in version.split(".") if i) if not versions: msg = "version must contain a numeric component" raise ValueError(msg) - if len(versions) > _MAX_VERSION_PARTS: + if len(versions) > max_version_parts: msg = "too many version parts" raise ValueError(msg) - if len(versions) == _MAX_VERSION_PARTS: + if len(versions) == max_version_parts: return versions[0], versions[1], versions[2] if len(versions) == 2: # ruff:ignore[magic-value-comparison] a two-part version is major.minor return versions[0], versions[1], None version_data = versions[0] - return int(str(version_data)[0]), (int(str(version_data)[1:]) if version_data > _SINGLE_DIGIT_MAX else None), None + return int(str(version_data)[0]), (int(str(version_data)[1:]) if version_data > single_digit_max else None), None def _parse_spec_pattern(string_spec: str) -> PythonSpec | None: @@ -130,11 +130,13 @@ class PythonSpec: Contains specification about a Python Interpreter. :param str_spec: the raw specification string as provided by the caller. + :param implementation: interpreter implementation name (e.g. ``"cpython"``, ``"pypy"``), or ``None`` for any. :param major: required major version, or ``None`` for any. :param minor: required minor version, or ``None`` for any. :param micro: required micro (patch) version, or ``None`` for any. :param architecture: required pointer-size bitness (``32`` or ``64``), or ``None`` for any. + :param path: filesystem path to a specific interpreter, or ``None``. :param free_threaded: whether a free-threaded build is required, or ``None`` for any. :param machine: required ISA (e.g. ``"arm64"``), or ``None`` for any. diff --git a/src/python_discovery/_specifier.py b/src/python_discovery/_specifier.py index a9eed79..312d588 100644 --- a/src/python_discovery/_specifier.py +++ b/src/python_discovery/_specifier.py @@ -19,10 +19,10 @@ _VERSION_RE: Final[re.Pattern[str]] = re.compile( r""" ^ - (\d+) # major - (?:\.(\d+))? # optional minor - (?:\.(\d+))? # optional micro - (?:(a|b|rc)(\d+))? # optional pre-release suffix + (?P\d+) # major + (?:\.(?P\d+))? # optional minor + (?:\.(?P\d+))? # optional micro + (?:(?Pa|b|rc)(?P\d+))? # optional pre-release suffix $ """, re.VERBOSE, @@ -30,9 +30,9 @@ _SPECIFIER_RE: Final[re.Pattern[str]] = re.compile( r""" ^ - (===|==|~=|!=|<=|>=|<|>) # operator + (?P===|==|~=|!=|<=|>=|<|>) # operator \s* - (.+) # version string + (?P.+) # version string $ """, re.VERBOSE, @@ -74,16 +74,16 @@ def from_string(cls, version_str: str) -> SimpleVersion: if not (match := _VERSION_RE.match(stripped)): msg = f"Invalid version: {version_str}" raise ValueError(msg) - major = int(match.group(1)) - minor = int(match.group(2)) if match.group(2) else 0 - micro = int(match.group(3)) if match.group(3) else 0 + major = int(match["major"]) + minor = int(match["minor"]) if match["minor"] else 0 + micro = int(match["micro"]) if match["micro"] else 0 return cls( version_str=stripped, major=major, minor=minor, micro=micro, - pre_type=match.group(4), - pre_num=int(match.group(5)) if match.group(5) else None, + pre_type=match["pre_type"], + pre_num=int(match["pre_num"]) if match["pre_num"] else None, release=(major, minor, micro), ) @@ -162,8 +162,8 @@ def from_string(cls, spec_str: str) -> SimpleSpecifier: if not (match := _SPECIFIER_RE.match(stripped)): msg = f"Invalid specifier: {spec_str}" raise ValueError(msg) - op = match.group(1) - version_str = match.group(2).strip() + op = match["op"] + version_str = match["version"].strip() is_wildcard = version_str.endswith(".*") wildcard_precision: int | None = None if is_wildcard: diff --git a/src/python_discovery/_windows/_pep514.py b/src/python_discovery/_windows/_pep514.py index 4fe645a..1ae3713 100644 --- a/src/python_discovery/_windows/_pep514.py +++ b/src/python_discovery/_windows/_pep514.py @@ -29,9 +29,9 @@ _VERSION_RE: Final[re.Pattern[str]] = re.compile( r""" ^ - (\d+) # major - (?:\.(\d+))? # optional minor - (?:\.(\d+))? # optional micro + (?P\d+) # major + (?:\.(?P\d+))? # optional minor + (?:\.(?P\d+))? # optional micro $ """, re.VERBOSE, @@ -187,11 +187,11 @@ def _load_version_data( def parse_version(version_str: Any) -> tuple[int | None, int | None, int | None]: # ruff:ignore[any-type] winreg handles have no importable type on other platforms if isinstance(version_str, str): if match := _VERSION_RE.match(version_str): - g1, g2, g3 = match.groups() + major, minor, micro = match["major"], match["minor"], match["micro"] return ( - int(g1) if g1 is not None else None, - int(g2) if g2 is not None else None, - int(g3) if g3 is not None else None, + int(major) if major is not None else None, + int(minor) if minor is not None else None, + int(micro) if micro is not None else None, ) error = f"invalid format {version_str}" else: diff --git a/src/python_discovery/_windows/_propose.py b/src/python_discovery/_windows/_propose.py index bfe500d..fa3d87c 100644 --- a/src/python_discovery/_windows/_propose.py +++ b/src/python_discovery/_windows/_propose.py @@ -20,9 +20,9 @@ def propose_interpreters( ) -> Generator[PythonInfo, None, None]: existing = list(discover_pythons()) existing.sort( - key=lambda i: ( - *tuple(-1 if j is None else j for j in i[1:4]), - 1 if i[0] == "PythonCore" else 0, + key=lambda item: ( + *tuple(-1 if j is None else j for j in item[1:4]), + 1 if item[0] == "PythonCore" else 0, ), reverse=True, ) From cfa5be34ab1009f18f6a17a040cd9706474f4f3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Sat, 7 Nov 2026 07:47:07 -0800 Subject: [PATCH 12/25] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor(test):=20cl?= =?UTF-8?q?ear=20simplint's=20test=20findings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace vars() reads with explicit attribute checks, bind single-letter loop names, use an assignment expression where the value is only tested, and upper-case the winreg mock tables so they are private constants. --- tests/py_info/test_py_info.py | 8 ++++---- tests/py_info/test_py_info_exe_based_of.py | 11 +++++++---- tests/test_py_spec.py | 15 +++++++++++---- tests/windows/conftest.py | 2 +- tests/windows/winreg_mock_values.py | 8 ++++---- 5 files changed, 27 insertions(+), 17 deletions(-) diff --git a/tests/py_info/test_py_info.py b/tests/py_info/test_py_info.py index 110df78..b8e8bba 100644 --- a/tests/py_info/test_py_info.py +++ b/tests/py_info/test_py_info.py @@ -220,10 +220,10 @@ def _make_py_info(of: PyInfoMock) -> PythonInfo: discovered_with_path: dict[str, PythonInfo] = {} names: list[str] = [] selected = None - for pos, i in enumerate(discovered): + for pos, info in enumerate(discovered): path = tmp_path / str(pos) path.write_text("", encoding="utf-8") - py_info = _make_py_info(i) + py_info = _make_py_info(info) system_executable = _CURRENT.system_executable assert system_executable is not None py_info.system_executable = system_executable @@ -562,8 +562,8 @@ def test_select_most_likely_prefers_machine_match( target = copy.deepcopy(_CURRENT) target.sysconfig_platform = target_platform discovered = [copy.deepcopy(_CURRENT) for _ in discovered_platforms] - for d, plat in zip(discovered, discovered_platforms): - d.sysconfig_platform = plat + for info, plat in zip(discovered, discovered_platforms): + info.sysconfig_platform = plat result = PythonInfo._select_most_likely(discovered, target) assert result.sysconfig_platform == discovered_platforms[expected_idx] diff --git a/tests/py_info/test_py_info_exe_based_of.py b/tests/py_info/test_py_info_exe_based_of.py index 7854d88..491ac1a 100644 --- a/tests/py_info/test_py_info_exe_based_of.py +++ b/tests/py_info/test_py_info_exe_based_of.py @@ -26,10 +26,13 @@ def test_discover_empty_folder(tmp_path: Path, session_cache: DiskCache) -> None def _discover_base_folders() -> tuple[str, ...]: exe_dir = str(Path(_CURRENT.executable).parent) folders: dict[str, None] = {} - if exe_dir.startswith(_CURRENT.prefix): # pragma: no branch, the branch depends on the host interpreter layout - relative = exe_dir[len(_CURRENT.prefix) :].lstrip("/\\") - if relative: # pragma: no branch, the branch depends on the host interpreter layout - folders[relative] = None + if ( + exe_dir.startswith(_CURRENT.prefix) + and ( # pragma: no branch, the branch depends on the host interpreter layout + relative := exe_dir[len(_CURRENT.prefix) :].lstrip("/\\") + ) + ): + folders[relative] = None folders["."] = None return tuple(folders) diff --git a/tests/test_py_spec.py b/tests/test_py_spec.py index d792c85..5208797 100644 --- a/tests/test_py_spec.py +++ b/tests/test_py_spec.py @@ -30,10 +30,17 @@ def test_bad_py_spec(text: str) -> None: spec = PythonSpec.from_string_spec(text) assert text in repr(spec) assert (spec.str_spec, spec.path) == (text, text) - content = vars(spec) - del content["str_spec"] - del content["path"] - assert all(v is None for v in content.values()) + assert ( + spec.implementation, + spec.major, + spec.minor, + spec.micro, + spec.architecture, + spec.free_threaded, + spec.machine, + spec.debug, + spec.version_specifier, + ) == (None,) * 9 @pytest.mark.parametrize( diff --git a/tests/windows/conftest.py b/tests/windows/conftest.py index c232ee8..2001b76 100644 --- a/tests/windows/conftest.py +++ b/tests/windows/conftest.py @@ -66,7 +66,7 @@ def _load_registry_data( glob: dict[str, object] = {"winreg": winreg} mock_value_str = (Path(__file__).parent / "winreg_mock_values.py").read_text(encoding="utf-8") exec(mock_value_str, glob, loc) # ruff:ignore[exec-builtin] the mock values are Python source kept in a separate file - return loc["enum_collect"], loc["value_collect"], loc["key_open"], loc["hive_open"] # ty: ignore[invalid-return-type] the namespace is built by exec, so it has no static types + return loc["_ENUM_COLLECT"], loc["_VALUE_COLLECT"], loc["_KEY_OPEN"], loc["_HIVE_OPEN"] # ty: ignore[invalid-return-type] the namespace is built by exec, so it has no static types def _make_enum_key(enum_collect: dict[object, dict[int, object]]) -> object: diff --git a/tests/windows/winreg_mock_values.py b/tests/windows/winreg_mock_values.py index ed06aa6..aee5b20 100644 --- a/tests/windows/winreg_mock_values.py +++ b/tests/windows/winreg_mock_values.py @@ -2,7 +2,7 @@ from typing import Final -hive_open: Final[dict[tuple[object, ...], object]] = { +_HIVE_OPEN: Final[dict[tuple[int, str, int, int], int | OSError]] = { (winreg.HKEY_CURRENT_USER, "Software\\Python", 0, winreg.KEY_READ): 78701856, (winreg.HKEY_LOCAL_MACHINE, "Software\\Python", 0, winreg.KEY_READ | winreg.KEY_WOW64_64KEY): 78701840, (winreg.HKEY_LOCAL_MACHINE, "Software\\Python", 0, winreg.KEY_READ | winreg.KEY_WOW64_32KEY): OSError( @@ -10,7 +10,7 @@ "The system cannot find the file specified", ), } -key_open: Final[dict[object, dict[str, object]]] = { +_KEY_OPEN: Final[dict[int, dict[str, int]]] = { 78701152: { "Anaconda310-32\\InstallPath": 78703200, "Anaconda310-32": 78703568, @@ -46,7 +46,7 @@ "3.6": 88820000, }, } -value_collect: Final[dict[object, dict[str, object]]] = { +_VALUE_COLLECT: Final[dict[int, dict[str, tuple[str, int] | OSError]]] = { 78703568: {"SysVersion": ("3.10", 1), "SysArchitecture": ("32bit", 1), "DisplayName": ("Python 3.10 (32-bit)", 1)}, 78703200: { "ExecutablePath": ("C:\\Users\\user\\Miniconda3\\python.exe", 1), @@ -145,7 +145,7 @@ "DisplayName": OSError(2, "The system cannot find the file specified"), }, } -enum_collect: Final[dict[object, dict[int, object]]] = { +_ENUM_COLLECT: Final[dict[int, list[str | OSError]]] = { 78701856: [ "ContinuumAnalytics", "PythonCore", From fdef0c3ae25e5d5af2a3445d8e94217417233688 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Sat, 7 Nov 2026 07:51:55 -0800 Subject: [PATCH 13/25] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor:=20keep=20p?= =?UTF-8?q?ackage=20=5F=5Finit=5F=5F=20to=20re-exports?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move __version__ into _about so python_discovery/__init__.py only re-exports, and leave _windows/__init__.py as a docstring. Callers import the Windows modules directly. Also drop the one-use helper in _py_spec, inline the pointer-width literal in _py_info_collect, and compare pointer size against a 64-bit integer so the gate has no magic number. --- src/python_discovery/__init__.py | 6 +-- src/python_discovery/_about.py | 8 +++ src/python_discovery/_discovery.py | 2 +- src/python_discovery/_py_info_collect.py | 3 +- src/python_discovery/_py_spec.py | 6 +-- src/python_discovery/_windows/__init__.py | 12 ----- tests/test_discovery.py | 28 +++++----- tests/test_iter_interpreters.py | 16 +++--- tests/windows/conftest.py | 66 +++++++++++++---------- tests/windows/test_windows.py | 2 +- 10 files changed, 76 insertions(+), 73 deletions(-) create mode 100644 src/python_discovery/_about.py diff --git a/src/python_discovery/__init__.py b/src/python_discovery/__init__.py index 9d960d6..7b3c5aa 100644 --- a/src/python_discovery/__init__.py +++ b/src/python_discovery/__init__.py @@ -2,17 +2,13 @@ from __future__ import annotations -from importlib.metadata import version - +from ._about import __version__ from ._cache import ContentStore, DiskCache, PyInfoCache from ._discovery import get_interpreter, iter_interpreters from ._py_info import KNOWN_ARCHITECTURES, MarkerEnvironment, PythonInfo, normalize_isa from ._py_spec import KNOWN_IMPLEMENTATIONS, PythonSpec from ._specifier import SimpleSpecifier, SimpleSpecifierSet, SimpleVersion -#: the installed distribution version -__version__ = version("python-discovery") - __all__ = [ "KNOWN_ARCHITECTURES", "KNOWN_IMPLEMENTATIONS", diff --git a/src/python_discovery/_about.py b/src/python_discovery/_about.py new file mode 100644 index 0000000..b0ca5e7 --- /dev/null +++ b/src/python_discovery/_about.py @@ -0,0 +1,8 @@ +"""The installed distribution version.""" + +from __future__ import annotations + +from importlib.metadata import version + +#: the installed distribution version +__version__ = version("python-discovery") diff --git a/src/python_discovery/_discovery.py b/src/python_discovery/_discovery.py index b719d95..a2667ac 100644 --- a/src/python_discovery/_discovery.py +++ b/src/python_discovery/_discovery.py @@ -150,7 +150,7 @@ def _propose_current_and_windows( yield current_python, True if IS_WIN: # pragma: win32 cover - from ._windows import propose_interpreters as win_propose # ruff:ignore[import-outside-top-level] the module is Windows-only and imported only there + from ._windows._propose import propose_interpreters as win_propose # ruff:ignore[import-outside-top-level] the module is Windows-only and imported only there for interpreter in win_propose(spec, cache, env): if _is_new_exe(str(interpreter.executable), tested_exes): diff --git a/src/python_discovery/_py_info_collect.py b/src/python_discovery/_py_info_collect.py index 78f5d24..83fd59e 100644 --- a/src/python_discovery/_py_info_collect.py +++ b/src/python_discovery/_py_info_collect.py @@ -57,8 +57,7 @@ def _init_identity(self): self.version_info = VersionInfo(*sys.version_info) # same as stdlib platform.architecture to account for pointer size != max int - pointer_bytes = 4 - self.architecture = 32 if struct.calcsize("P") == pointer_bytes else 64 + self.architecture = 32 if struct.calcsize("P") < struct.calcsize("Q") else 64 self.sysconfig_platform = sysconfig.get_platform() self.version_nodot = sysconfig.get_config_var("py_version_nodot") self.version = sys.version diff --git a/src/python_discovery/_py_spec.py b/src/python_discovery/_py_spec.py index c42cea4..739c412 100644 --- a/src/python_discovery/_py_spec.py +++ b/src/python_discovery/_py_spec.py @@ -44,10 +44,6 @@ InvalidVersion = ValueError -def _int_or_none(val: str | None) -> int | None: - return None if val is None else int(val) - - def _parse_version_parts(version: str) -> tuple[int | None, int | None, int | None]: max_version_parts = 3 single_digit_max = 9 @@ -96,7 +92,7 @@ def _spec_from_match( string_spec, impl, *release, - _int_or_none(match["arch"]), + None if (arch := match["arch"]) is None else int(arch), None, free_threaded=threaded, machine=None if (machine := match["machine"]) is None else normalize_isa(machine), diff --git a/src/python_discovery/_windows/__init__.py b/src/python_discovery/_windows/__init__.py index 5d06b8a..177916a 100644 --- a/src/python_discovery/_windows/__init__.py +++ b/src/python_discovery/_windows/__init__.py @@ -1,13 +1 @@ """Windows-specific Python discovery via PEP 514 registry entries.""" - -from __future__ import annotations - -from ._pep514 import _run, discover_pythons -from ._propose import Pep514PythonInfo, propose_interpreters - -__all__ = [ - "Pep514PythonInfo", - "_run", - "discover_pythons", - "propose_interpreters", -] diff --git a/tests/test_discovery.py b/tests/test_discovery.py index 65ff09d..c93b58a 100644 --- a/tests/test_discovery.py +++ b/tests/test_discovery.py @@ -7,7 +7,7 @@ from copy import deepcopy from pathlib import Path from subprocess import check_call, check_output -from typing import TYPE_CHECKING, Final +from typing import TYPE_CHECKING, Final, Protocol from unittest.mock import patch from uuid import uuid4 @@ -810,8 +810,12 @@ def _plant(root: Path) -> str: return _plant +class UvProbe(Protocol): + def __call__(self, **kwargs: object) -> list[str]: ... + + @pytest.fixture -def uv_probed(mocker: MockerFixture) -> Callable[..., list[str]]: +def uv_probed(mocker: MockerFixture) -> UvProbe: # Run a discovery with the interrogation subprocess stubbed out, reporting the executables it reached for. from_exe = mocker.patch("python_discovery._discovery.PathPythonInfo.from_exe", return_value=None, autospec=True) @@ -827,7 +831,7 @@ def test_discovery_uv_store_install_dir_overrides_the_platform_default( uv_home: Path, monkeypatch: pytest.MonkeyPatch, uv_plant: Callable[[Path], str], - uv_probed: Callable[..., list[str]], + uv_probed: UvProbe, expand_user: bool, ) -> None: monkeypatch.setenv("UV_PYTHON_INSTALL_DIR", "~/store" if expand_user else str(uv_home / "store")) @@ -858,7 +862,7 @@ def test_discovery_uv_store_install_dir_overrides_the_platform_default( def test_discovery_uv_store_roots_per_platform( monkeypatch: pytest.MonkeyPatch, uv_plant: Callable[[Path], str], - uv_probed: Callable[..., list[str]], + uv_probed: UvProbe, platform: str, extra_env: dict[str, str], expected: list[str], @@ -882,7 +886,7 @@ def test_discovery_uv_store_invalid_appdata_uses_profile_default( uv_home: Path, monkeypatch: pytest.MonkeyPatch, uv_plant: Callable[[Path], str], - uv_probed: Callable[..., list[str]], + uv_probed: UvProbe, appdata: str, store: str, ) -> None: @@ -908,7 +912,7 @@ def test_discovery_uv_store_skips_relative_profile( uv_home: Path, monkeypatch: pytest.MonkeyPatch, uv_plant: Callable[[Path], str], - uv_probed: Callable[..., list[str]], + uv_probed: UvProbe, platform: str, profile_var: str, store: str, @@ -926,7 +930,7 @@ def test_discovery_uv_store_windows_ignores_xdg_data_home( uv_home: Path, monkeypatch: pytest.MonkeyPatch, uv_plant: Callable[[Path], str], - uv_probed: Callable[..., list[str]], + uv_probed: UvProbe, ) -> None: monkeypatch.setattr(sys, "platform", "win32") monkeypatch.setenv("XDG_DATA_HOME", str(uv_home / "xdg")) @@ -937,7 +941,7 @@ def test_discovery_uv_store_windows_ignores_xdg_data_home( def test_discovery_uv_store_reads_the_env_argument( - uv_home: Path, uv_plant: Callable[[Path], str], uv_probed: Callable[..., list[str]] + uv_home: Path, uv_plant: Callable[[Path], str], uv_probed: UvProbe ) -> None: executable = uv_plant(uv_home / "store") @@ -956,7 +960,7 @@ def test_discovery_uv_store_ignores_process_home_without_mapped_home( uv_home: Path, monkeypatch: pytest.MonkeyPatch, uv_plant: Callable[[Path], str], - uv_probed: Callable[..., list[str]], + uv_probed: UvProbe, platform: str, store: str, ) -> None: @@ -978,7 +982,7 @@ def test_discovery_uv_store_uses_mapped_directory_without_home( uv_home: Path, monkeypatch: pytest.MonkeyPatch, uv_plant: Callable[[Path], str], - uv_probed: Callable[..., list[str]], + uv_probed: UvProbe, platform: str, variable: str, store: str, @@ -991,7 +995,7 @@ def test_discovery_uv_store_uses_mapped_directory_without_home( @pytest.mark.usefixtures("uv_home") -def test_discovery_uv_store_missing_root_is_not_an_error(uv_probed: Callable[..., list[str]]) -> None: +def test_discovery_uv_store_missing_root_is_not_an_error(uv_probed: UvProbe) -> None: assert uv_probed() == [] @@ -999,7 +1003,7 @@ def test_discovery_uv_store_is_searched_after_path( uv_home: Path, monkeypatch: pytest.MonkeyPatch, uv_plant: Callable[[Path], str], - uv_probed: Callable[..., list[str]], + uv_probed: UvProbe, ) -> None: store_executable = uv_plant(uv_home / ("AppData/Roaming/uv/data" if IS_WIN else ".local/share/uv") / "python") (path_dir := uv_home / "path-bin").mkdir() diff --git a/tests/test_iter_interpreters.py b/tests/test_iter_interpreters.py index 66162ab..4e4a5e2 100644 --- a/tests/test_iter_interpreters.py +++ b/tests/test_iter_interpreters.py @@ -4,7 +4,7 @@ import os import sys from pathlib import Path -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Protocol from uuid import uuid4 import pytest @@ -28,8 +28,12 @@ def uv_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: return root +class UvInstall(Protocol): + def __call__(self, *relative: str) -> None: ... + + @pytest.fixture -def uv_install(uv_dir: Path) -> Callable[..., None]: +def uv_install(uv_dir: Path) -> UvInstall: def _install(*relative: str) -> None: for entry in relative: (path := uv_dir / entry).parent.mkdir(parents=True, exist_ok=True) @@ -458,7 +462,7 @@ def test_iter_interpreters_uv_layout( ], ) def test_iter_interpreters_uv_probe_order( - uv_install: Callable[..., None], + uv_install: UvInstall, uv_probed: Callable[[str | None], list[str]], planted: tuple[str, ...], key: str | None, @@ -470,7 +474,7 @@ def test_iter_interpreters_uv_probe_order( def test_iter_interpreters_uv_minor_version_link_loses_to_its_target( - uv_dir: Path, uv_install: Callable[..., None], uv_probed: Callable[[str | None], list[str]] + uv_dir: Path, uv_install: UvInstall, uv_probed: Callable[[str | None], list[str]] ) -> None: # uv links `cpython-3.15` at the concrete install, and repoints it on the next patch release. uv_install("cpython-3.15.0b3-macos-aarch64-none/bin/python") @@ -488,7 +492,7 @@ def test_iter_interpreters_uv_minor_version_link_loses_to_its_target( ], ) def test_iter_interpreters_uv_minor_version_junction_loses_to_its_target( - uv_install: Callable[..., None], + uv_install: UvInstall, uv_probed: Callable[[str | None], list[str]], uv_junction: Callable[[str, str], None], concrete: str, @@ -516,7 +520,7 @@ def _junction(target: str, link: str) -> None: def test_iter_interpreters_uv_probes_one_executable_per_install( - uv_dir: Path, uv_install: Callable[..., None], monkeypatch: pytest.MonkeyPatch, mocker: MockerFixture + uv_dir: Path, uv_install: UvInstall, monkeypatch: pytest.MonkeyPatch, mocker: MockerFixture ) -> None: # GraalPy ships bin/python as a copy of bin/graalpy, so the realpath dedup alone would spawn it twice. monkeypatch.setenv("PATH", "") diff --git a/tests/windows/conftest.py b/tests/windows/conftest.py index 2001b76..fa15bb3 100644 --- a/tests/windows/conftest.py +++ b/tests/windows/conftest.py @@ -2,20 +2,44 @@ import os import sys -from contextlib import contextmanager +from contextlib import AbstractContextManager, contextmanager from pathlib import Path from types import ModuleType, TracebackType -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Protocol from unittest.mock import MagicMock import pytest if TYPE_CHECKING: - from collections.abc import Generator + from collections.abc import Callable, Generator from typing_extensions import Self +class _OpenKeyEx(Protocol): + def __call__(self, *args: object) -> AbstractContextManager[_Key | int | OSError]: ... + + +def _make_open_key_ex( + key_open: dict[int, dict[str, int]], hive_open: dict[tuple[int, str, int, int], int | OSError] +) -> _OpenKeyEx: + @contextmanager + def _open_key_ex(*args: object) -> Generator[_Key | int | OSError]: + if len(args) == 2: + key, value = args + result = _Key(key_open[(key.value if isinstance(key, _Key) else key)][value]) + elif len(args) == 4: + result = hive_open[args] + else: + raise RuntimeError + value = result.value if isinstance(result, _Key) else result + if isinstance(value, OSError): + raise value + yield result + + return _open_key_ex + + @pytest.fixture def _mock_registry(monkeypatch: pytest.MonkeyPatch) -> None: if sys.platform != "win32": @@ -56,10 +80,10 @@ def _create_winreg_mock() -> ModuleType: def _load_registry_data( winreg: ModuleType, ) -> tuple[ - dict[object, dict[int, object]], - dict[object, dict[str, object]], - dict[object, dict[str, object]], - dict[tuple[object, ...], object], + dict[int, list[str | OSError]], + dict[int, dict[str, tuple[str, int] | OSError]], + dict[int, dict[str, int]], + dict[tuple[int, str, int, int], int | OSError], ]: # Load winreg mock values using the given (possibly mock) winreg module. loc: dict[str, object] = {} @@ -69,7 +93,7 @@ def _load_registry_data( return loc["_ENUM_COLLECT"], loc["_VALUE_COLLECT"], loc["_KEY_OPEN"], loc["_HIVE_OPEN"] # ty: ignore[invalid-return-type] the namespace is built by exec, so it has no static types -def _make_enum_key(enum_collect: dict[object, dict[int, object]]) -> object: +def _make_enum_key(enum_collect: dict[int, list[str | OSError]]) -> Callable[[object, int], str]: def _enum_key(key: object, at: int) -> str: result = enum_collect[(key.value if isinstance(key, _Key) else key)][at] if isinstance(result, OSError): @@ -80,7 +104,7 @@ def _enum_key(key: object, at: int) -> str: class _Key: - def __init__(self, value: object) -> None: + def __init__(self, value: int) -> None: self.value = value def __enter__(self) -> Self: @@ -92,8 +116,10 @@ def __exit__( return None -def _make_query_value_ex(value_collect: dict[object, dict[str, object]]) -> object: - def _query_value_ex(key: object, value_name: str) -> object: +def _make_query_value_ex( + value_collect: dict[int, dict[str, tuple[str, int] | OSError]], +) -> Callable[[object, str], tuple[str, int]]: + def _query_value_ex(key: object, value_name: str) -> tuple[str, int]: result = value_collect[(key.value if isinstance(key, _Key) else key)][value_name] if isinstance(result, OSError): raise result @@ -102,24 +128,6 @@ def _query_value_ex(key: object, value_name: str) -> object: return _query_value_ex -def _make_open_key_ex(key_open: dict[object, dict[str, object]], hive_open: dict[tuple[object, ...], object]) -> object: - @contextmanager - def _open_key_ex(*args: object) -> Generator[_Key | object]: - if len(args) == 2: - key, value = args - result = _Key(key_open[(key.value if isinstance(key, _Key) else key)][value]) - elif len(args) == 4: - result = hive_open[args] - else: - raise RuntimeError - value = result.value if isinstance(result, _Key) else result - if isinstance(value, OSError): - raise value - yield result - - return _open_key_ex - - @pytest.fixture def _populate_pyinfo_cache(monkeypatch: pytest.MonkeyPatch) -> None: from python_discovery._cached_py_info import _CACHE diff --git a/tests/windows/test_windows.py b/tests/windows/test_windows.py index 6fac48c..15dd58a 100644 --- a/tests/windows/test_windows.py +++ b/tests/windows/test_windows.py @@ -56,7 +56,7 @@ def test_discovery_via_path_literal_symlink(tmp_path: Path) -> None: ], ) def test_propose_interpreters(string_spec: str, expected_exe: str) -> None: - from python_discovery._windows import propose_interpreters + from python_discovery._windows._propose import propose_interpreters interpreter = next(propose_interpreters(spec=PythonSpec.from_string_spec(string_spec), cache=None, env={})) assert interpreter.executable == expected_exe From 41eb9f04a11e5891ef6e931cb154fd77199927fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Sat, 7 Nov 2026 07:55:34 -0800 Subject: [PATCH 14/25] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor(test):=20gi?= =?UTF-8?q?ve=20mocks=20a=20spec?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Constructor mocks now take spec= from the type they stand in for: PyInfoCache, ContentStore, Popen and PythonInfo. The autospec=True kwargs added to Mock and MagicMock constructors in an earlier commit did not spec anything; they set a stray attribute and hid the finding. Removing them here. --- tests/test_cached_py_info.py | 10 +++++----- tests/test_py_info_collect.py | 8 ++++---- tests/test_py_info_extra.py | 18 ++++++++++++------ tests/windows/conftest.py | 3 ++- 4 files changed, 23 insertions(+), 16 deletions(-) diff --git a/tests/test_cached_py_info.py b/tests/test_cached_py_info.py index dfc027e..5931eba 100644 --- a/tests/test_cached_py_info.py +++ b/tests/test_cached_py_info.py @@ -16,7 +16,7 @@ import pytest -from python_discovery import DiskCache, PythonInfo +from python_discovery import ContentStore, DiskCache, PythonInfo from python_discovery._cached_py_info import ( _MAX_QUERY_OUTPUT, LogCmd, @@ -277,27 +277,27 @@ def test_collect_query_output_read_error(mocker: MockerFixture) -> None: ], ) def test_collect_query_output_requires_pipes(stdout: StringIO | None, stderr: StringIO | None) -> None: - process = MagicMock(stdout=stdout, stderr=stderr, autospec=True) + process = MagicMock(spec=Popen, stdout=stdout, stderr=stderr) with pytest.raises(ValueError, match="query output pipes are required"): _collect_query_output(process, 1) def test_load_cached_py_info_valid() -> None: - result = _load_cached_py_info(PythonInfo, MagicMock(), PythonInfo().to_dict()) + result = _load_cached_py_info(PythonInfo, MagicMock(spec=ContentStore), PythonInfo().to_dict()) assert result is not None assert isinstance(result, PythonInfo) def test_load_cached_py_info_bad_data() -> None: - store = MagicMock() + store = MagicMock(spec=ContentStore) result = _load_cached_py_info(PythonInfo, store, {"bad": "data"}) assert result is None store.remove.assert_called_once() def test_load_cached_py_info_system_exe_missing(mocker: MockerFixture) -> None: - store = MagicMock() + store = MagicMock(spec=ContentStore) content = PythonInfo().to_dict() content["system_executable"] = "/nonexistent/python" mocker.patch("os.path.exists", return_value=False, autospec=True) diff --git a/tests/test_py_info_collect.py b/tests/test_py_info_collect.py index 4d264b8..89764ac 100644 --- a/tests/test_py_info_collect.py +++ b/tests/test_py_info_collect.py @@ -331,12 +331,12 @@ def test_tcl_tk_libs_queried_with_env(tmp_path: Path, mocker: MockerFixture) -> def _fake_tkinter(mocker: MockerFixture, eval_side_effect: Callable[[str], str] | Exception) -> Mock: - evaluate = mocker.Mock(side_effect=eval_side_effect, autospec=True) - profile = mocker.Mock(return_value=None, autospec=True) + evaluate = mocker.Mock(side_effect=eval_side_effect) + profile = mocker.Mock(return_value=None) interpreter = type( "Tk", (), {"__init__": lambda self, **_: self.readprofile("Tk", "Tk"), "readprofile": profile, "eval": evaluate} ) - module = mocker.MagicMock(TclError=_TclError, Tk=interpreter, autospec=True) + module = mocker.MagicMock(TclError=_TclError, Tk=interpreter) module.Tcl.side_effect = lambda: interpreter(useTk=False) mocker.patch.dict(sys.modules, {"tkinter": module}) return profile @@ -361,7 +361,7 @@ def evaluate(script: str) -> str: raise AssertionError _fake_tkinter(mocker, evaluate) - mocker.patch.dict(sys.modules, {"_tkinter": mocker.MagicMock(TK_VERSION="8.6", autospec=True)}) + mocker.patch.dict(sys.modules, {"_tkinter": mocker.MagicMock(TK_VERSION="8.6")}) mocker.patch.dict(os.environ, {"TCL_LIBRARY": str(tcl_dir)}) info = PythonInfo() diff --git a/tests/test_py_info_extra.py b/tests/test_py_info_extra.py index 62b5c24..2fa2ba4 100644 --- a/tests/test_py_info_extra.py +++ b/tests/test_py_info_extra.py @@ -10,7 +10,7 @@ import pytest -from python_discovery import DiskCache, PythonInfo, PythonSpec, SimpleSpecifierSet +from python_discovery import DiskCache, PyInfoCache, PythonInfo, PythonSpec, SimpleSpecifierSet from python_discovery._py_info import VersionInfo if TYPE_CHECKING: @@ -184,7 +184,7 @@ def test_discover_exe_cache_hit() -> None: cached = copy.deepcopy(_CURRENT) PythonInfo._cache_exe_discovery["/some/prefix", True, info.version_info] = cached try: - result = info.discover_exe(MagicMock(), prefix="/some/prefix", exact=True) + result = info.discover_exe(MagicMock(spec=PyInfoCache), prefix="/some/prefix", exact=True) assert result is cached finally: del PythonInfo._cache_exe_discovery["/some/prefix", True, info.version_info] @@ -208,7 +208,9 @@ def test_discover_exe_cache_keeps_requested_version(tmp_path: Path) -> None: def test_check_exe_none_path(tmp_path: Path) -> None: info = copy.deepcopy(_CURRENT) - result = info._check_exe(MagicMock(), str(tmp_path), "nonexistent", [], dict(os.environ), exact=True) + result = info._check_exe( + MagicMock(spec=PyInfoCache), str(tmp_path), "nonexistent", [], dict(os.environ), exact=True + ) assert result is None @@ -409,7 +411,7 @@ def test_check_exe_from_exe_returns_none(tmp_path: Path, mocker: MockerFixture) info = copy.deepcopy(_CURRENT) (tmp_path / "python").touch() mocker.patch.object(PythonInfo, "from_exe", return_value=None, autospec=True) - result = info._check_exe(MagicMock(), str(tmp_path), "python", [], dict(os.environ), exact=True) + result = info._check_exe(MagicMock(spec=PyInfoCache), str(tmp_path), "python", [], dict(os.environ), exact=True) assert result is None @@ -420,7 +422,9 @@ def test_check_exe_mismatch_not_exact(tmp_path: Path, mocker: MockerFixture) -> other.architecture = 32 if info.architecture == 64 else 64 mocker.patch.object(PythonInfo, "from_exe", return_value=other, autospec=True) discovered: list[PythonInfo] = [] - result = info._check_exe(MagicMock(), str(tmp_path), "python", discovered, dict(os.environ), exact=False) + result = info._check_exe( + MagicMock(spec=PyInfoCache), str(tmp_path), "python", discovered, dict(os.environ), exact=False + ) assert result is None assert len(discovered) == 1 @@ -432,7 +436,9 @@ def test_check_exe_mismatch_exact(tmp_path: Path, mocker: MockerFixture) -> None other.architecture = 32 if info.architecture == 64 else 64 mocker.patch.object(PythonInfo, "from_exe", return_value=other, autospec=True) discovered: list[PythonInfo] = [] - result = info._check_exe(MagicMock(), str(tmp_path), "python", discovered, dict(os.environ), exact=True) + result = info._check_exe( + MagicMock(spec=PyInfoCache), str(tmp_path), "python", discovered, dict(os.environ), exact=True + ) assert result is None assert len(discovered) == 0 diff --git a/tests/windows/conftest.py b/tests/windows/conftest.py index fa15bb3..6a9db38 100644 --- a/tests/windows/conftest.py +++ b/tests/windows/conftest.py @@ -153,9 +153,10 @@ def _populate_pyinfo_cache(monkeypatch: pytest.MonkeyPatch) -> None: def _mock_pyinfo(major: int, minor: int, arch: int, exe: str, threaded: bool) -> MagicMock: + from python_discovery import PythonInfo from python_discovery._py_info import VersionInfo - info = MagicMock(base_prefix=str(Path(exe).parent), autospec=True) + info = MagicMock(spec=PythonInfo, base_prefix=str(Path(exe).parent)) info.executable = info.original_executable = info.system_executable = exe info.implementation = "CPython" info.architecture = arch From 3f411a32e771158fa41df0c5c2c4771daa163fbc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Sat, 7 Nov 2026 07:59:05 -0800 Subject: [PATCH 15/25] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor:=20name=20t?= =?UTF-8?q?uples=20returned=20by=20private=20helpers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Return NamedTuples from the query-output, cookie-slice, version-parts and test registry helpers so callers read fields by name. Tuple unpacking at call sites still works, so behavior is unchanged. --- src/python_discovery/_cached_py_info.py | 27 +++++++--- src/python_discovery/_py_spec.py | 20 ++++++-- tests/test_cache_recovery.py | 12 +++-- tests/windows/conftest.py | 68 ++++++++++++------------- 4 files changed, 78 insertions(+), 49 deletions(-) diff --git a/src/python_discovery/_cached_py_info.py b/src/python_discovery/_cached_py_info.py index 88ed179..3f69227 100644 --- a/src/python_discovery/_cached_py_info.py +++ b/src/python_discovery/_cached_py_info.py @@ -21,7 +21,7 @@ from subprocess import DEVNULL, PIPE, Popen, TimeoutExpired, list2cmdline # ruff:ignore[suspicious-subprocess-import] list2cmdline only formats the command for logging from threading import Event, Thread from time import monotonic -from typing import TYPE_CHECKING, Final +from typing import TYPE_CHECKING, Final, NamedTuple from ._cache import NoOpCache from ._py_info import PythonInfo @@ -299,7 +299,13 @@ def __repr__(self) -> str: return cmd_repr -def _collect_query_output(process: Popen[str], timeout: float | None) -> tuple[str, str, int]: +class _QueryOutput(NamedTuple): + out: str + err: str + code: int + + +def _collect_query_output(process: Popen[str], timeout: float | None) -> _QueryOutput: stdout, stderr = process.stdout, process.stderr if stdout is None or stderr is None: msg = "query output pipes are required" @@ -321,12 +327,12 @@ def _collect_query_output(process: Popen[str], timeout: float | None) -> tuple[s # A descendant may keep the pipes open after the candidate exits. process.wait() _stop_query_readers(readers) - return "", "timed out", -1 + return _QueryOutput("", "timed out", -1) if state.read_errors: raise state.read_errors[0] if state.overflow.is_set(): - return "", f"produced more than {_MAX_QUERY_OUTPUT} characters of output", -1 - return "".join(out_chunks), "".join(err_chunks), process.wait() + return _QueryOutput("", f"produced more than {_MAX_QUERY_OUTPUT} characters of output", -1) + return _QueryOutput("".join(out_chunks), "".join(err_chunks), process.wait()) @dataclass(frozen=True) @@ -401,7 +407,14 @@ def _query_failure(exe: str, out: str, err: str | None, code: int | None) -> Run return RuntimeError(f"failed to query {msg}") -def _extract_between_cookies(out: str, start_cookie: str, end_cookie: str) -> tuple[str, str, int, int]: +class _CookieSlice(NamedTuple): + out: str + raw_out: str + out_starts: int + out_ends: int + + +def _extract_between_cookies(out: str, start_cookie: str, end_cookie: str) -> _CookieSlice: # Extract payload between reversed cookie markers, forwarding any surrounding output to stdout. raw_out = out out_starts = out.find(start_cookie[::-1]) @@ -414,7 +427,7 @@ def _extract_between_cookies(out: str, start_cookie: str, end_cookie: str) -> tu if post_cookie := out[out_ends + _COOKIE_LENGTH :]: sys.stdout.write(post_cookie) out = out[:out_ends] - return out, raw_out, out_starts, out_ends + return _CookieSlice(out, raw_out, out_starts, out_ends) class _UnsupportedInterpreterError(RuntimeError): diff --git a/src/python_discovery/_py_spec.py b/src/python_discovery/_py_spec.py index 739c412..5532895 100644 --- a/src/python_discovery/_py_spec.py +++ b/src/python_discovery/_py_spec.py @@ -4,7 +4,7 @@ import pathlib import re -from typing import Final +from typing import Final, NamedTuple from ._py_info import normalize_isa from ._specifier import SimpleSpecifier, SimpleSpecifierSet, SimpleVersion @@ -44,7 +44,13 @@ InvalidVersion = ValueError -def _parse_version_parts(version: str) -> tuple[int | None, int | None, int | None]: +class _VersionParts(NamedTuple): + major: int | None + minor: int | None + micro: int | None + + +def _parse_version_parts(version: str) -> _VersionParts: max_version_parts = 3 single_digit_max = 9 versions = tuple(int(i) for i in version.split(".") if i) @@ -55,11 +61,15 @@ def _parse_version_parts(version: str) -> tuple[int | None, int | None, int | No msg = "too many version parts" raise ValueError(msg) if len(versions) == max_version_parts: - return versions[0], versions[1], versions[2] + return _VersionParts(versions[0], versions[1], versions[2]) if len(versions) == 2: # ruff:ignore[magic-value-comparison] a two-part version is major.minor - return versions[0], versions[1], None + return _VersionParts(versions[0], versions[1], None) version_data = versions[0] - return int(str(version_data)[0]), (int(str(version_data)[1:]) if version_data > single_digit_max else None), None + return _VersionParts( + int(str(version_data)[0]), + int(str(version_data)[1:]) if version_data > single_digit_max else None, + None, + ) def _parse_spec_pattern(string_spec: str) -> PythonSpec | None: diff --git a/tests/test_cache_recovery.py b/tests/test_cache_recovery.py index 5d332d5..378ec42 100644 --- a/tests/test_cache_recovery.py +++ b/tests/test_cache_recovery.py @@ -2,7 +2,7 @@ import json import sys -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, NamedTuple import pytest @@ -66,9 +66,15 @@ def test_from_exe_recovers_invalid_system_executable( ) +class _CachedInterpreter(NamedTuple): + cache: DiskCache + cache_file: Path + expected: dict + + @pytest.fixture -def cached_interpreter(tmp_path: Path) -> tuple[DiskCache, Path, dict]: +def cached_interpreter(tmp_path: Path) -> _CachedInterpreter: cache = DiskCache(tmp_path) assert PythonInfo.from_exe(sys.executable, cache, ignore_cache=True, resolve_to_host=False) is not None cache_file = next(tmp_path.rglob("*.json")) - return cache, cache_file, json.loads(cache_file.read_text(encoding="utf-8")) + return _CachedInterpreter(cache, cache_file, json.loads(cache_file.read_text(encoding="utf-8"))) diff --git a/tests/windows/conftest.py b/tests/windows/conftest.py index 6a9db38..ecf1e0c 100644 --- a/tests/windows/conftest.py +++ b/tests/windows/conftest.py @@ -5,7 +5,7 @@ from contextlib import AbstractContextManager, contextmanager from pathlib import Path from types import ModuleType, TracebackType -from typing import TYPE_CHECKING, Protocol +from typing import TYPE_CHECKING, NamedTuple, Protocol from unittest.mock import MagicMock import pytest @@ -16,30 +16,6 @@ from typing_extensions import Self -class _OpenKeyEx(Protocol): - def __call__(self, *args: object) -> AbstractContextManager[_Key | int | OSError]: ... - - -def _make_open_key_ex( - key_open: dict[int, dict[str, int]], hive_open: dict[tuple[int, str, int, int], int | OSError] -) -> _OpenKeyEx: - @contextmanager - def _open_key_ex(*args: object) -> Generator[_Key | int | OSError]: - if len(args) == 2: - key, value = args - result = _Key(key_open[(key.value if isinstance(key, _Key) else key)][value]) - elif len(args) == 4: - result = hive_open[args] - else: - raise RuntimeError - value = result.value if isinstance(result, _Key) else result - if isinstance(value, OSError): - raise value - yield result - - return _open_key_ex - - @pytest.fixture def _mock_registry(monkeypatch: pytest.MonkeyPatch) -> None: if sys.platform != "win32": @@ -77,20 +53,20 @@ def _create_winreg_mock() -> ModuleType: return winreg -def _load_registry_data( - winreg: ModuleType, -) -> tuple[ - dict[int, list[str | OSError]], - dict[int, dict[str, tuple[str, int] | OSError]], - dict[int, dict[str, int]], - dict[tuple[int, str, int, int], int | OSError], -]: +class _RegistryData(NamedTuple): + enum_collect: dict[int, list[str | OSError]] + value_collect: dict[int, dict[str, tuple[str, int] | OSError]] + key_open: dict[int, dict[str, int]] + hive_open: dict[tuple[int, str, int, int], int | OSError] + + +def _load_registry_data(winreg: ModuleType) -> _RegistryData: # Load winreg mock values using the given (possibly mock) winreg module. loc: dict[str, object] = {} glob: dict[str, object] = {"winreg": winreg} mock_value_str = (Path(__file__).parent / "winreg_mock_values.py").read_text(encoding="utf-8") exec(mock_value_str, glob, loc) # ruff:ignore[exec-builtin] the mock values are Python source kept in a separate file - return loc["_ENUM_COLLECT"], loc["_VALUE_COLLECT"], loc["_KEY_OPEN"], loc["_HIVE_OPEN"] # ty: ignore[invalid-return-type] the namespace is built by exec, so it has no static types + return _RegistryData(loc["_ENUM_COLLECT"], loc["_VALUE_COLLECT"], loc["_KEY_OPEN"], loc["_HIVE_OPEN"]) # ty: ignore[invalid-return-type] the namespace is built by exec, so it has no static types def _make_enum_key(enum_collect: dict[int, list[str | OSError]]) -> Callable[[object, int], str]: @@ -128,6 +104,30 @@ def _query_value_ex(key: object, value_name: str) -> tuple[str, int]: return _query_value_ex +def _make_open_key_ex( + key_open: dict[int, dict[str, int]], hive_open: dict[tuple[int, str, int, int], int | OSError] +) -> _OpenKeyEx: + @contextmanager + def _open_key_ex(*args: object) -> Generator[_Key | int | OSError]: + if len(args) == 2: + key, value = args + result = _Key(key_open[(key.value if isinstance(key, _Key) else key)][value]) + elif len(args) == 4: + result = hive_open[args] + else: + raise RuntimeError + value = result.value if isinstance(result, _Key) else result + if isinstance(value, OSError): + raise value + yield result + + return _open_key_ex + + +class _OpenKeyEx(Protocol): + def __call__(self, *args: object) -> AbstractContextManager[_Key | int | OSError]: ... + + @pytest.fixture def _populate_pyinfo_cache(monkeypatch: pytest.MonkeyPatch) -> None: from python_discovery._cached_py_info import _CACHE From 3c6683b06ab38d215214bdd1c708e7c1d5715ca6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Sat, 7 Nov 2026 08:00:39 -0800 Subject: [PATCH 16/25] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor(test):=20ho?= =?UTF-8?q?ist=20platform=20names=20out=20of=20test=20bodies?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Platform-dependent file names and suffixes in test_discovery are module constants now, so the test bodies no longer branch on IS_WIN. --- tests/test_discovery.py | 54 +++++++++++++++++++++++------------------ 1 file changed, 31 insertions(+), 23 deletions(-) diff --git a/tests/test_discovery.py b/tests/test_discovery.py index c93b58a..c244092 100644 --- a/tests/test_discovery.py +++ b/tests/test_discovery.py @@ -21,6 +21,14 @@ from pytest_mock import MockerFixture +_EXE_SUFFIX = ".exe" if IS_WIN else "" +_SCRIPTS_DIR = "Scripts" if IS_WIN else "bin" +_PYTHON_NAME = "python.exe" if IS_WIN else "python" +_PYTHON27_NAME = "python2.7.exe" if IS_WIN else "python2.7" +_VENV_PYTHON = "Scripts/python.exe" if IS_WIN else "bin/python" +_A_FILE_NAME = "a_file.exe" if IS_WIN else "a_file" +_UV_DATA_DIR = "AppData/Roaming/uv/data" if IS_WIN else ".local/share/uv" + @pytest.mark.graalpy @pytest.mark.skipif(not Path(sys.executable).is_symlink() and not Path(sys.executable).is_file(), reason="no symlink") @@ -95,7 +103,7 @@ def test_discovery_via_path_literal_symlink(tmp_path: Path) -> None: def test_pypy_spec_rejects_cpython_alias(name: str, tmp_path: Path, session_cache: DiskCache) -> None: threaded = "t" if PythonInfo.current_system(session_cache).free_threaded else "" version = f"{sys.version_info.major}.{sys.version_info.minor}{threaded}" - (tmp_path / f"{name}{version}{'.exe' if IS_WIN else ''}").symlink_to(sys.executable) + (tmp_path / f"{name}{version}{_EXE_SUFFIX}").symlink_to(sys.executable) env = {"PATH": str(tmp_path), "UV_PYTHON_INSTALL_DIR": str(tmp_path / "missing")} selected = get_interpreter(f"pypy{version}", [], cache=session_cache, env=env) @@ -109,7 +117,7 @@ def test_pypy_spec_rejects_cpython_alias(name: str, tmp_path: Path, session_cach def test_generic_python_spec_accepts_mixed_case_alias(tmp_path: Path, session_cache: DiskCache) -> None: threaded = "t" if PythonInfo.current_system(session_cache).free_threaded else "" version = f"{sys.version_info.major}.{sys.version_info.minor}{threaded}" - alias = tmp_path / f"Python{version}{'.exe' if IS_WIN else ''}" + alias = tmp_path / f"Python{version}{_EXE_SUFFIX}" alias.symlink_to(sys.executable) selected = get_interpreter( @@ -240,7 +248,7 @@ def test_discovery_invalid_specifier_raises(session_cache: DiskCache) -> None: def test_discovery_fallback_reuses_preferred_paths( tmp_path: Path, session_cache: DiskCache, mocker: MockerFixture, *, use_iterator: bool ) -> None: - executable = tmp_path / "preferred" / ("Scripts/python.exe" if IS_WIN else "bin/python") + executable = tmp_path / "preferred" / _VENV_PYTHON executable.parent.mkdir(parents=True) executable.touch() preferred_info = deepcopy(PythonInfo.current_system(session_cache)) @@ -289,8 +297,8 @@ def test_discovery_absolute_path_with_try_first( check_call([sys.executable, "-m", "venv", str(good_env)]) check_call([sys.executable, "-m", "venv", str(bad_env)]) - scripts_dir = "Scripts" if IS_WIN else "bin" - exe_name = "python.exe" if IS_WIN else "python" + scripts_dir = _SCRIPTS_DIR + exe_name = _PYTHON_NAME good_exe = good_env / scripts_dir / exe_name bad_exe = bad_env / scripts_dir / exe_name @@ -305,8 +313,8 @@ def test_discovery_absolute_path_with_try_first( def test_discovery_venvs_on_one_base_keep_their_executable(tmp_path: Path, session_cache: DiskCache) -> None: - scripts_dir = "Scripts" if IS_WIN else "bin" - exe_name = "python.exe" if IS_WIN else "python" + scripts_dir = _SCRIPTS_DIR + exe_name = _PYTHON_NAME exes = [tmp_path / name / scripts_dir / exe_name for name in ("a", "b")] for exe in exes: check_call([sys.executable, "-m", "venv", "--without-pip", str(exe.parents[1])]) @@ -319,7 +327,7 @@ def test_discovery_venvs_on_one_base_keep_their_executable(tmp_path: Path, sessi def test_discovery_try_first_with_keeps_venv(tmp_path: Path, session_cache: DiskCache) -> None: venv = tmp_path / "preferred" check_call([sys.executable, "-m", "venv", "--without-pip", str(venv)]) - exe = venv / ("Scripts/python.exe" if IS_WIN else "bin/python") + exe = venv / _VENV_PYTHON info = get_interpreter( ">=3", @@ -424,7 +432,7 @@ def test_get_paths_empty_entry_does_not_yield_cwd(tmp_path: Path, monkeypatch: p def test_lazy_path_dump_debug(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: monkeypatch.setenv("_VIRTUALENV_DEBUG", "1") a_dir = tmp_path - executable_file = "a_file.exe" if IS_WIN else "a_file" + executable_file = _A_FILE_NAME (a_dir / executable_file).touch(mode=0o755) (a_dir / "b_file").touch(mode=0o644) output = repr(LazyPathDump(0, a_dir, os.environ)) @@ -476,7 +484,7 @@ def test_shim_resolved_to_real_binary( ) -> None: root = _create_version_manager(tmp_path, env_var) real_binary = _create_versioned_binary(root, versions_path, "2.7.18", "python2.7") - (root / "shims" / ("python2.7.exe" if IS_WIN else "python2.7")).touch(mode=0o755) + (root / "shims" / _PYTHON27_NAME).touch(mode=0o755) monkeypatch.setenv("PATH", str(root / "shims")) monkeypatch.setenv(env_var, str(root)) @@ -501,7 +509,7 @@ def _create_version_manager(tmp_path: Path, env_var: str) -> Path: def _create_versioned_binary(root: Path, versions_path: tuple[str, ...], version: str, exe_name: str) -> Path: bin_dir = root.joinpath(*versions_path, version, "bin") bin_dir.mkdir(parents=True, exist_ok=True) - exe = bin_dir / (f"{exe_name}.exe" if IS_WIN else exe_name) + exe = bin_dir / f"{exe_name}{_EXE_SUFFIX}" exe.touch() exe.chmod(exe.stat().st_mode | stat.S_IEXEC) return exe @@ -510,7 +518,7 @@ def _create_versioned_binary(root: Path, versions_path: tuple[str, ...], version def test_shim_not_resolved_without_version_manager_env(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: shims_dir = tmp_path / "shims" shims_dir.mkdir() - shim = shims_dir / ("python2.7.exe" if IS_WIN else "python2.7") + shim = shims_dir / _PYTHON27_NAME shim.touch(mode=0o755) monkeypatch.setenv("PATH", str(shims_dir)) @@ -526,7 +534,7 @@ def test_shim_not_resolved_without_version_manager_env(tmp_path: Path, monkeypat def test_shim_falls_through_when_binary_missing(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: root = _create_version_manager(tmp_path, "PYENV_ROOT") - shim = root / "shims" / ("python2.7.exe" if IS_WIN else "python2.7") + shim = root / "shims" / _PYTHON27_NAME shim.touch(mode=0o755) monkeypatch.setenv("PATH", str(root / "shims")) @@ -542,7 +550,7 @@ def test_shim_falls_through_when_binary_missing(tmp_path: Path, monkeypatch: pyt def test_shim_uses_python_version_file(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: root = _create_version_manager(tmp_path, "PYENV_ROOT") real_binary = _create_versioned_binary(root, ("versions",), "2.7.18", "python2.7") - (root / "shims" / ("python2.7.exe" if IS_WIN else "python2.7")).touch(mode=0o755) + (root / "shims" / _PYTHON27_NAME).touch(mode=0o755) (tmp_path / ".python-version").write_text(encoding="utf-8", data="2.7.18\n") monkeypatch.setenv("PATH", str(root / "shims")) @@ -579,7 +587,7 @@ def test_shim_skips_path_like_version( "nested": root / "versions" / "2.7.18" / "subdir", }[target] _create_versioned_binary(attack_root, (), "", "python2.7") - (root / "shims" / ("python2.7.exe" if IS_WIN else "python2.7")).touch(mode=0o755) + (root / "shims" / _PYTHON27_NAME).touch(mode=0o755) (tmp_path / ".python-version").write_text(f"{version.format(outside=outside)}\n2.7.18\n", encoding="utf-8") monkeypatch.setenv("PATH", str(root / "shims")) @@ -597,7 +605,7 @@ def test_shim_skips_empty_environment_version(tmp_path: Path, monkeypatch: pytes root = _create_version_manager(tmp_path, "PYENV_ROOT") real_binary = _create_versioned_binary(root, ("versions",), "2.7.18", "python2.7") _create_versioned_binary(root / "versions", (), "", "python2.7") - (root / "shims" / ("python2.7.exe" if IS_WIN else "python2.7")).touch(mode=0o755) + (root / "shims" / _PYTHON27_NAME).touch(mode=0o755) monkeypatch.setenv("PATH", str(root / "shims")) monkeypatch.setenv("PYENV_ROOT", str(root)) @@ -635,7 +643,7 @@ def test_shim_python_version_file_keeps_unicode_separator_in_version( (tmp_path / ".python-version").write_text("2.7.18\u2028ignored\n", encoding="utf-8") monkeypatch.chdir(tmp_path) - assert pyenv_probed() == ("shims", "python2.7.exe" if IS_WIN else "python2.7") + assert pyenv_probed() == ("shims", _PYTHON27_NAME) def test_shim_python_version_file_not_utf8( @@ -680,7 +688,7 @@ def pyenv_probed( ) -> Callable[[], tuple[str, ...]]: root = _create_version_manager(tmp_path, "PYENV_ROOT") _create_versioned_binary(root, ("versions",), "2.7.18", "python2.7") - (root / "shims" / ("python2.7.exe" if IS_WIN else "python2.7")).touch(mode=0o755) + (root / "shims" / _PYTHON27_NAME).touch(mode=0o755) monkeypatch.setenv("PATH", str(root / "shims")) monkeypatch.setenv("PYENV_ROOT", str(root)) monkeypatch.delenv("PYENV_VERSION", raising=False) @@ -697,7 +705,7 @@ def test_shim_pyenv_version_env_takes_priority_over_file(tmp_path: Path, monkeyp root = _create_version_manager(tmp_path, "PYENV_ROOT") _create_versioned_binary(root, ("versions",), "2.7.18", "python2.7") env_binary = _create_versioned_binary(root, ("versions",), "2.7.15", "python2.7") - (root / "shims" / ("python2.7.exe" if IS_WIN else "python2.7")).touch(mode=0o755) + (root / "shims" / _PYTHON27_NAME).touch(mode=0o755) (tmp_path / ".python-version").write_text(encoding="utf-8", data="2.7.18\n") monkeypatch.setenv("PATH", str(root / "shims")) @@ -714,7 +722,7 @@ def test_shim_pyenv_version_env_takes_priority_over_file(tmp_path: Path, monkeyp def test_shim_uses_global_version_file(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: root = _create_version_manager(tmp_path, "PYENV_ROOT") real_binary = _create_versioned_binary(root, ("versions",), "2.7.18", "python2.7") - (root / "shims" / ("python2.7.exe" if IS_WIN else "python2.7")).touch(mode=0o755) + (root / "shims" / _PYTHON27_NAME).touch(mode=0o755) (root / "version").write_text(encoding="utf-8", data="2.7.18\n") workdir = tmp_path / "workdir" workdir.mkdir() @@ -737,7 +745,7 @@ def test_shim_colon_separated_pyenv_version_picks_first_match( root = _create_version_manager(tmp_path, "PYENV_ROOT") _create_versioned_binary(root, ("versions",), "2.7.18", "python2.7") second_binary = _create_versioned_binary(root, ("versions",), "2.7.15", "python2.7") - (root / "shims" / ("python2.7.exe" if IS_WIN else "python2.7")).touch(mode=0o755) + (root / "shims" / _PYTHON27_NAME).touch(mode=0o755) monkeypatch.setenv("PATH", str(root / "shims")) monkeypatch.setenv("PYENV_ROOT", str(root)) @@ -1005,9 +1013,9 @@ def test_discovery_uv_store_is_searched_after_path( uv_plant: Callable[[Path], str], uv_probed: UvProbe, ) -> None: - store_executable = uv_plant(uv_home / ("AppData/Roaming/uv/data" if IS_WIN else ".local/share/uv") / "python") + store_executable = uv_plant(uv_home / _UV_DATA_DIR / "python") (path_dir := uv_home / "path-bin").mkdir() - (path_executable := path_dir / f"python{_NO_UV_MATCH}{'.exe' if IS_WIN else ''}").touch() + (path_executable := path_dir / f"python{_NO_UV_MATCH}{_EXE_SUFFIX}").touch() monkeypatch.setenv("PATH", str(path_dir)) assert uv_probed() == [str(path_executable), store_executable] From 5d98179a45b05cf714ba07734656b0ed07930289 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Sat, 7 Nov 2026 08:10:31 -0800 Subject: [PATCH 17/25] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor(test):=20dr?= =?UTF-8?q?op=20host=20branches=20from=20test=20bodies?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Host-dependent values (suffixes, threaded tags, architecture flips, case folding) move to module constants or lookups. The missing-key cache case gets its own test instead of a branch on the parameter. --- tests/py_info/test_py_info.py | 30 ++++++++++------------ tests/py_info/test_py_info_exe_based_of.py | 15 +++++------ tests/test_cache.py | 8 +++--- tests/test_cache_recovery.py | 19 ++++++++++---- tests/test_discovery.py | 14 +++++----- tests/test_discovery_extra.py | 8 +++--- tests/test_iter_interpreters.py | 13 +++++----- tests/test_py_info_extra.py | 4 +-- tests/test_py_spec_extra.py | 2 +- 9 files changed, 61 insertions(+), 52 deletions(-) diff --git a/tests/py_info/test_py_info.py b/tests/py_info/test_py_info.py index b8e8bba..dc8b084 100644 --- a/tests/py_info/test_py_info.py +++ b/tests/py_info/test_py_info.py @@ -26,6 +26,11 @@ _IS_PYPY: Final[bool] = PythonInfo.current_system().implementation == "PyPy" _CURRENT: Final[PythonInfo] = PythonInfo.current_system() +_EXECUTABLE_SUFFIX: Final[str] = Path(_CURRENT.executable).suffix if sys.platform == "win32" else "" +_ORIGINAL_SUFFIX: Final[str] = Path(_CURRENT.original_executable).suffix if sys.platform == "win32" else "" +_THREADED: Final[str] = "t" if _CURRENT.free_threaded else "" +_UNTHREADED: Final[str] = "" if _CURRENT.free_threaded else "t" +_OTHER_ARCHITECTURE: Final[int] = {32: 64, 64: 32}[_CURRENT.architecture] @pytest.mark.graalpy @@ -101,7 +106,7 @@ def test_satisfy_py_info(spec: str) -> None: def test_satisfy_not_arch() -> None: parsed_spec = PythonSpec.from_string_spec( - f"{_CURRENT.implementation}-{64 if _CURRENT.architecture == 32 else 32}", + f"{_CURRENT.implementation}-{_OTHER_ARCHITECTURE}", ) matches = _CURRENT.satisfies(parsed_spec, impl_must_match=True) assert matches is False @@ -109,7 +114,7 @@ def test_satisfy_not_arch() -> None: def test_satisfy_not_threaded() -> None: parsed_spec = PythonSpec.from_string_spec( - f"{_CURRENT.implementation}{_CURRENT.version_info.major}{'' if _CURRENT.free_threaded else 't'}", + f"{_CURRENT.implementation}{_CURRENT.version_info.major}{_UNTHREADED}", ) matches = _CURRENT.satisfies(parsed_spec, impl_must_match=True) assert matches is False @@ -156,7 +161,7 @@ def test_py_info_cache_clear(mocker: MockerFixture, session_cache: DiskCache) -> info = PythonInfo.from_exe(sys.executable, session_cache) assert info is not None - native_difference = 1 if info.system_executable == info.executable else 0 + native_difference = int(info.system_executable == info.executable) assert spy.call_count + native_difference >= 1 @@ -219,7 +224,6 @@ def _make_py_info(of: PyInfoMock) -> PythonInfo: discovered_with_path: dict[str, PythonInfo] = {} names: list[str] = [] - selected = None for pos, info in enumerate(discovered): path = tmp_path / str(pos) path.write_text("", encoding="utf-8") @@ -229,8 +233,6 @@ def _make_py_info(of: PyInfoMock) -> PythonInfo: py_info.system_executable = system_executable py_info.executable = system_executable py_info.base_executable = str(path) # ty: ignore[unresolved-attribute] the test stub is a plain object, not a PythonInfo - if pos == position: - selected = py_info discovered_with_path[str(path)] = py_info names.append(path.name) @@ -249,7 +251,7 @@ def func(exe_path: str, _cache: object = None, **_kwargs: object) -> PythonInfo: mapped = target_py_info.resolve_to_system(session_cache, target_py_info) assert mapped.system_executable == _CURRENT.system_executable found = discovered_with_path[mapped.base_executable] - assert found is selected + assert found is discovered_with_path[str(tmp_path / str(position))] assert caplog.records[0].msg == "discover exe for %s in %s" for record in caplog.records[1:-1]: @@ -306,9 +308,7 @@ def test_py_info_ignores_distutils_config(monkeypatch: pytest.MonkeyPatch, tmp_p def test_discover_exe_on_path_non_spec_name_match(mocker: MockerFixture) -> None: - suffixed_name = f"python{_CURRENT.version_info.major}.{_CURRENT.version_info.minor}m" - if sys.platform == "win32": # pragma: win32 cover - suffixed_name += Path(_CURRENT.executable).suffix + suffixed_name = f"python{_CURRENT.version_info.major}.{_CURRENT.version_info.minor}m{_EXECUTABLE_SUFFIX}" spec = PythonSpec.from_string_spec(suffixed_name) mocker.patch.object(_CURRENT, "executable", str(Path(_CURRENT.executable).parent / suffixed_name)) assert _CURRENT.satisfies(spec, impl_must_match=True) is True @@ -327,9 +327,7 @@ def test_satisfies_debug_spec_requires_debug_build(mocker: MockerFixture) -> Non def test_discover_exe_on_path_non_spec_name_not_match(mocker: MockerFixture) -> None: - suffixed_name = f"python{_CURRENT.version_info.major}.{_CURRENT.version_info.minor}m" - if sys.platform == "win32": # pragma: win32 cover - suffixed_name += Path(_CURRENT.original_executable).suffix + suffixed_name = f"python{_CURRENT.version_info.major}.{_CURRENT.version_info.minor}m{_ORIGINAL_SUFFIX}" spec = PythonSpec.from_string_spec(suffixed_name) mocker.patch.object( _CURRENT, @@ -421,7 +419,7 @@ def test_py_info_machine_derivation_universal2(mocker: MockerFixture, runtime_is def test_py_info_satisfies_with_machine() -> None: - threaded = "t" if _CURRENT.free_threaded else "" + threaded = _THREADED spec_str = ( f"{_CURRENT.implementation}{_CURRENT.version_info.major}{threaded}-{_CURRENT.architecture}-{_CURRENT.machine}" ) @@ -430,13 +428,13 @@ def test_py_info_satisfies_with_machine() -> None: def test_py_info_satisfies_not_machine() -> None: - other_machine = "arm64" if _CURRENT.machine != "arm64" else "x86_64" + other_machine = {"arm64": "x86_64"}.get(_CURRENT.machine, "arm64") parsed_spec = PythonSpec.from_string_spec(f"{_CURRENT.implementation}-{_CURRENT.architecture}-{other_machine}") assert _CURRENT.satisfies(parsed_spec, impl_must_match=True) is False def test_py_info_satisfies_no_machine_in_spec() -> None: - threaded = "t" if _CURRENT.free_threaded else "" + threaded = _THREADED spec_str = f"{_CURRENT.implementation}{_CURRENT.version_info.major}{threaded}-{_CURRENT.architecture}" parsed_spec = PythonSpec.from_string_spec(spec_str) assert parsed_spec.machine is None diff --git a/tests/py_info/test_py_info_exe_based_of.py b/tests/py_info/test_py_info_exe_based_of.py index 491ac1a..7ac240c 100644 --- a/tests/py_info/test_py_info_exe_based_of.py +++ b/tests/py_info/test_py_info_exe_based_of.py @@ -1,5 +1,6 @@ from __future__ import annotations +import contextlib import logging from pathlib import Path from typing import Final @@ -12,6 +13,8 @@ from python_discovery._py_info import EXTENSIONS _CURRENT: Final[PythonInfo] = PythonInfo.current() +_THREADED: Final[str] = "t" if _CURRENT.free_threaded else "" +_CASE_FOLD: Final = str.lower if not fs_is_case_sensitive() else str def _fs_supports_symlink() -> bool: @@ -66,22 +69,16 @@ def test_discover_ok( caplog.set_level(logging.DEBUG) folder = tmp_path / into folder.mkdir(parents=True, exist_ok=True) - name = f"{impl}{version}{'t' if _CURRENT.free_threaded else ''}" - if arch: - name += f"-{arch}" - name += suffix + name = f"{impl}{version}{_THREADED}{f'-{arch}' * bool(arch)}{suffix}" dest = folder / name Path(str(dest)).symlink_to(_CURRENT.executable) pyvenv = Path(_CURRENT.executable).parents[1] / "pyvenv.cfg" - if pyvenv.exists(): # pragma: no branch, the branch depends on the host interpreter layout + with contextlib.suppress(FileNotFoundError): (folder / pyvenv.name).write_text(pyvenv.read_text(encoding="utf-8"), encoding="utf-8") inside_folder = str(tmp_path) found = _CURRENT.discover_exe(session_cache, inside_folder).executable dest_str = str(dest) - if not fs_is_case_sensitive(): # pragma: win32 cover - found = found.lower() - dest_str = dest_str.lower() - assert found == dest_str + assert _CASE_FOLD(found) == _CASE_FOLD(dest_str) assert len(caplog.messages) >= 1, caplog.text assert "get interpreter info via cmd: " in caplog.text diff --git a/tests/test_cache.py b/tests/test_cache.py index bf2154b..536c989 100644 --- a/tests/test_cache.py +++ b/tests/test_cache.py @@ -7,7 +7,7 @@ from os import fsdecode from pathlib import Path from threading import Event, Thread -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Final import pytest from filelock import FileLock, Timeout @@ -67,9 +67,11 @@ def test_disk_content_store_write_size_tracks_input(tmp_path: Path, source: str) assert (tmp_path / "test.json").stat().st_size < 2 * len(json.dumps(content)) +_DEEP_JSON_DEPTH: Final[int] = 2_000 if sys.version_info >= (3, 15) else 900 + + def test_disk_content_store_write_deep_json(tmp_path: Path) -> None: - depth = 2_000 if sys.version_info >= (3, 15) else 900 - DiskContentStore(tmp_path, "test").write({"padding": json.loads("[" * depth + "]" * depth)}) + DiskContentStore(tmp_path, "test").write({"padding": json.loads("[" * _DEEP_JSON_DEPTH + "]" * _DEEP_JSON_DEPTH)}) assert (tmp_path / "test.json").stat().st_size < 10_000 diff --git a/tests/test_cache_recovery.py b/tests/test_cache_recovery.py index 378ec42..f10ec93 100644 --- a/tests/test_cache_recovery.py +++ b/tests/test_cache_recovery.py @@ -36,7 +36,6 @@ def test_from_exe_recovers_non_object_cache(cached_interpreter: tuple[DiskCache, @pytest.mark.parametrize( "payload", [ - pytest.param(None, id="missing"), pytest.param("5", id="int"), pytest.param('["python"]', id="list"), pytest.param("{}", id="dict"), @@ -53,10 +52,20 @@ def test_from_exe_recovers_invalid_system_executable( ) -> None: cache, cache_file, expected = cached_interpreter data = json.loads(cache_file.read_text(encoding="utf-8")) - if payload is None: - del data["content"]["system_executable"] - else: - data["content"]["system_executable"] = json.loads(payload) + data["content"]["system_executable"] = json.loads(payload) + cache_file.write_text(json.dumps(data), encoding="utf-8") + result = PythonInfo.from_exe(sys.executable, cache, ignore_cache=True, resolve_to_host=False) + assert result is not None + assert (json.loads(result.to_json()), json.loads(cache_file.read_text(encoding="utf-8"))) == ( + expected["content"], + expected, + ) + + +def test_from_exe_recovers_missing_system_executable(cached_interpreter: _CachedInterpreter) -> None: + cache, cache_file, expected = cached_interpreter + data = json.loads(cache_file.read_text(encoding="utf-8")) + del data["content"]["system_executable"] cache_file.write_text(json.dumps(data), encoding="utf-8") result = PythonInfo.from_exe(sys.executable, cache, ignore_cache=True, resolve_to_host=False) assert result is not None diff --git a/tests/test_discovery.py b/tests/test_discovery.py index c244092..b170f05 100644 --- a/tests/test_discovery.py +++ b/tests/test_discovery.py @@ -21,13 +21,13 @@ from pytest_mock import MockerFixture -_EXE_SUFFIX = ".exe" if IS_WIN else "" -_SCRIPTS_DIR = "Scripts" if IS_WIN else "bin" -_PYTHON_NAME = "python.exe" if IS_WIN else "python" -_PYTHON27_NAME = "python2.7.exe" if IS_WIN else "python2.7" -_VENV_PYTHON = "Scripts/python.exe" if IS_WIN else "bin/python" -_A_FILE_NAME = "a_file.exe" if IS_WIN else "a_file" -_UV_DATA_DIR = "AppData/Roaming/uv/data" if IS_WIN else ".local/share/uv" +_EXE_SUFFIX: Final[str] = ".exe" if IS_WIN else "" +_SCRIPTS_DIR: Final[str] = "Scripts" if IS_WIN else "bin" +_PYTHON_NAME: Final[str] = "python.exe" if IS_WIN else "python" +_PYTHON27_NAME: Final[str] = "python2.7.exe" if IS_WIN else "python2.7" +_VENV_PYTHON: Final[str] = "Scripts/python.exe" if IS_WIN else "bin/python" +_A_FILE_NAME: Final[str] = "a_file.exe" if IS_WIN else "a_file" +_UV_DATA_DIR: Final[str] = "AppData/Roaming/uv/data" if IS_WIN else ".local/share/uv" @pytest.mark.graalpy diff --git a/tests/test_discovery_extra.py b/tests/test_discovery_extra.py index e7acb99..d42d64f 100644 --- a/tests/test_discovery_extra.py +++ b/tests/test_discovery_extra.py @@ -3,7 +3,7 @@ import os import sys from pathlib import Path -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Final import pytest @@ -18,6 +18,8 @@ ) from python_discovery._py_spec import PythonSpec +_PYTHON_NAME: Final[str] = "python.exe" if IS_WIN else "python" + if TYPE_CHECKING: from pytest_mock import MockerFixture @@ -53,7 +55,7 @@ def test_propose_interpreters_relative_path( session_cache: DiskCache, ) -> None: Path(sys.executable) - link = tmp_path / ("python.exe" if IS_WIN else "python") + link = tmp_path / _PYTHON_NAME Path(str(link)).symlink_to(sys.executable) spec = PythonSpec.from_string_spec(link.name) spec.path = link.name @@ -174,7 +176,7 @@ def test_propose_interpreters_abs_path_exists(session_cache: DiskCache) -> None: def test_propose_interpreters_relative_spec_is_abs(tmp_path: Path, session_cache: DiskCache) -> None: - link = tmp_path / ("python.exe" if IS_WIN else "python") + link = tmp_path / _PYTHON_NAME Path(str(link)).symlink_to(sys.executable) spec = PythonSpec.from_string_spec(str(link)) spec.path = str(link) diff --git a/tests/test_iter_interpreters.py b/tests/test_iter_interpreters.py index 4e4a5e2..1758b92 100644 --- a/tests/test_iter_interpreters.py +++ b/tests/test_iter_interpreters.py @@ -4,7 +4,7 @@ import os import sys from pathlib import Path -from typing import TYPE_CHECKING, Protocol +from typing import TYPE_CHECKING, Final, Protocol from uuid import uuid4 import pytest @@ -12,6 +12,8 @@ from python_discovery import DiskCache, PythonInfo, iter_interpreters from python_discovery._discovery import IS_WIN +_EXE_SUFFIX: Final[str] = ".exe" if IS_WIN else "" + if TYPE_CHECKING: from collections.abc import Callable, Iterator @@ -137,7 +139,7 @@ def test_iter_interpreters_no_key_includes_running_interpreter(session_cache: Di def test_iter_interpreters_no_key_probes_variant_builds_on_path( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, mocker: MockerFixture, name: str ) -> None: - exe = tmp_path / "bin" / f"{name}{'.exe' if IS_WIN else ''}" + exe = tmp_path / "bin" / f"{name}{_EXE_SUFFIX}" exe.parent.mkdir() exe.touch() monkeypatch.setenv("PATH", str(exe.parent)) @@ -153,7 +155,7 @@ def test_iter_interpreters_no_key_probes_variant_builds_on_path( "directory_name", [ pytest.param(".", id="path-directory"), - pytest.param(f"python3.99{'.exe' if IS_WIN else ''}", id="named-directory"), + pytest.param(f"python3.99{_EXE_SUFFIX}", id="named-directory"), ], ) def test_iter_interpreters_skips_path_directories( @@ -162,7 +164,7 @@ def test_iter_interpreters_skips_path_directories( bin_dir = tmp_path / "bin" bin_dir.mkdir() (bin_dir / directory_name).mkdir(exist_ok=True) - executable = bin_dir / f"python3.98{'.exe' if IS_WIN else ''}" + executable = bin_dir / f"python3.98{_EXE_SUFFIX}" executable.touch() monkeypatch.setenv("PATH", str(bin_dir)) from_exe = mocker.patch("python_discovery._discovery.PathPythonInfo.from_exe", return_value=None, autospec=True) @@ -178,9 +180,8 @@ def test_iter_interpreters_dedups_symlinks( monkeypatch: pytest.MonkeyPatch, session_cache: DiskCache, ) -> None: - suffix = ".exe" if IS_WIN else "" for name in ("python3", "python3.99"): - Path(str(tmp_path / f"{name}{suffix}")).symlink_to(sys.executable) + Path(str(tmp_path / f"{name}{_EXE_SUFFIX}")).symlink_to(sys.executable) pyvenv_cfg = Path(sys.executable).parents[1] / "pyvenv.cfg" with contextlib.suppress(FileNotFoundError): (tmp_path / pyvenv_cfg.name).write_bytes(pyvenv_cfg.read_bytes()) diff --git a/tests/test_py_info_extra.py b/tests/test_py_info_extra.py index 2fa2ba4..951c9b4 100644 --- a/tests/test_py_info_extra.py +++ b/tests/test_py_info_extra.py @@ -419,7 +419,7 @@ def test_check_exe_mismatch_not_exact(tmp_path: Path, mocker: MockerFixture) -> info = copy.deepcopy(_CURRENT) (tmp_path / "python").touch() other = copy.deepcopy(_CURRENT) - other.architecture = 32 if info.architecture == 64 else 64 + other.architecture = {32: 64, 64: 32}[info.architecture] mocker.patch.object(PythonInfo, "from_exe", return_value=other, autospec=True) discovered: list[PythonInfo] = [] result = info._check_exe( @@ -433,7 +433,7 @@ def test_check_exe_mismatch_exact(tmp_path: Path, mocker: MockerFixture) -> None info = copy.deepcopy(_CURRENT) (tmp_path / "python").touch() other = copy.deepcopy(_CURRENT) - other.architecture = 32 if info.architecture == 64 else 64 + other.architecture = {32: 64, 64: 32}[info.architecture] mocker.patch.object(PythonInfo, "from_exe", return_value=other, autospec=True) discovered: list[PythonInfo] = [] result = info._check_exe( diff --git a/tests/test_py_spec_extra.py b/tests/test_py_spec_extra.py index cd2aa80..b4ce4eb 100644 --- a/tests/test_py_spec_extra.py +++ b/tests/test_py_spec_extra.py @@ -104,7 +104,7 @@ def test_generate_re_with_debug(name: str) -> None: @pytest.mark.parametrize("windows", [pytest.param(False, id="posix"), pytest.param(True, id="windows")]) def test_generate_re_all_implementations_matches_variant_names(name: str, windows: bool) -> None: pat = PythonSpec("", None, None, None, None, None, None).generate_re(windows=windows, all_implementations=True) - assert pat.fullmatch(f"{name}.exe" if windows else name) is not None + assert pat.fullmatch(f"{name}{'.exe' * windows}") is not None @pytest.mark.parametrize( From b45c3e8753d60bd7c28cf12e2e7782594f532474 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Sat, 7 Nov 2026 08:13:42 -0800 Subject: [PATCH 18/25] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor:=20order=20?= =?UTF-8?q?definitions=20and=20drop=20test=20branches?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Define each helper after its first user and move NamedTuple classes next to the functions that return them. In test_discovery the path and env branches become dict lookups, bool multiplication and a parametrized wrapper. --- src/python_discovery/_cached_py_info.py | 26 +++++----- src/python_discovery/_windows/_propose.py | 1 - tests/py_info/test_py_info.py | 3 +- tests/py_info/test_py_info_exe_based_of.py | 10 ++-- tests/test_discovery.py | 56 ++++++++++------------ tests/windows/conftest.py | 14 +++--- 6 files changed, 52 insertions(+), 58 deletions(-) diff --git a/src/python_discovery/_cached_py_info.py b/src/python_discovery/_cached_py_info.py index 3f69227..4af368e 100644 --- a/src/python_discovery/_cached_py_info.py +++ b/src/python_discovery/_cached_py_info.py @@ -299,12 +299,6 @@ def __repr__(self) -> str: return cmd_repr -class _QueryOutput(NamedTuple): - out: str - err: str - code: int - - def _collect_query_output(process: Popen[str], timeout: float | None) -> _QueryOutput: stdout, stderr = process.stdout, process.stderr if stdout is None or stderr is None: @@ -393,6 +387,12 @@ def _stop_query_readers(readers: tuple[Thread, Thread]) -> None: reader.join() +class _QueryOutput(NamedTuple): + out: str + err: str + code: int + + def _query_failure(exe: str, out: str, err: str | None, code: int | None) -> RuntimeError: # the gate's exit code and stderr marker must both be present: either alone can collide, an errno of 79 from a # failed exec or a shim that echoes the marker phrase, but only the gate produces the combination @@ -407,13 +407,6 @@ def _query_failure(exe: str, out: str, err: str | None, code: int | None) -> Run return RuntimeError(f"failed to query {msg}") -class _CookieSlice(NamedTuple): - out: str - raw_out: str - out_starts: int - out_ends: int - - def _extract_between_cookies(out: str, start_cookie: str, end_cookie: str) -> _CookieSlice: # Extract payload between reversed cookie markers, forwarding any surrounding output to stdout. raw_out = out @@ -430,6 +423,13 @@ def _extract_between_cookies(out: str, start_cookie: str, end_cookie: str) -> _C return _CookieSlice(out, raw_out, out_starts, out_ends) +class _CookieSlice(NamedTuple): + out: str + raw_out: str + out_starts: int + out_ends: int + + class _UnsupportedInterpreterError(RuntimeError): # The interpreter is older than the interrogation script supports; retrying cannot help. def __init__(self, message: str, executable: str | None = None) -> None: diff --git a/src/python_discovery/_windows/_propose.py b/src/python_discovery/_windows/_propose.py index fa3d87c..4582e9e 100644 --- a/src/python_discovery/_windows/_propose.py +++ b/src/python_discovery/_windows/_propose.py @@ -54,6 +54,5 @@ class Pep514PythonInfo(PythonInfo): __all__ = [ - "Pep514PythonInfo", "propose_interpreters", ] diff --git a/tests/py_info/test_py_info.py b/tests/py_info/test_py_info.py index dc8b084..6553418 100644 --- a/tests/py_info/test_py_info.py +++ b/tests/py_info/test_py_info.py @@ -434,8 +434,7 @@ def test_py_info_satisfies_not_machine() -> None: def test_py_info_satisfies_no_machine_in_spec() -> None: - threaded = _THREADED - spec_str = f"{_CURRENT.implementation}{_CURRENT.version_info.major}{threaded}-{_CURRENT.architecture}" + spec_str = f"{_CURRENT.implementation}{_CURRENT.version_info.major}{_THREADED}-{_CURRENT.architecture}" parsed_spec = PythonSpec.from_string_spec(spec_str) assert parsed_spec.machine is None assert _CURRENT.satisfies(parsed_spec, impl_must_match=True) is True diff --git a/tests/py_info/test_py_info_exe_based_of.py b/tests/py_info/test_py_info_exe_based_of.py index 7ac240c..8089e43 100644 --- a/tests/py_info/test_py_info_exe_based_of.py +++ b/tests/py_info/test_py_info_exe_based_of.py @@ -3,7 +3,7 @@ import contextlib import logging from pathlib import Path -from typing import Final +from typing import TYPE_CHECKING, Final import pytest @@ -12,9 +12,12 @@ from python_discovery._discovery import IS_WIN from python_discovery._py_info import EXTENSIONS +if TYPE_CHECKING: + from collections.abc import Callable + _CURRENT: Final[PythonInfo] = PythonInfo.current() _THREADED: Final[str] = "t" if _CURRENT.free_threaded else "" -_CASE_FOLD: Final = str.lower if not fs_is_case_sensitive() else str +_CASE_FOLD: Final[Callable[[str], str]] = str.lower if not fs_is_case_sensitive() else str def _fs_supports_symlink() -> bool: @@ -69,8 +72,7 @@ def test_discover_ok( caplog.set_level(logging.DEBUG) folder = tmp_path / into folder.mkdir(parents=True, exist_ok=True) - name = f"{impl}{version}{_THREADED}{f'-{arch}' * bool(arch)}{suffix}" - dest = folder / name + dest = folder / f"{impl}{version}{_THREADED}{f'-{arch}' * bool(arch)}{suffix}" Path(str(dest)).symlink_to(_CURRENT.executable) pyvenv = Path(_CURRENT.executable).parents[1] / "pyvenv.cfg" with contextlib.suppress(FileNotFoundError): diff --git a/tests/test_discovery.py b/tests/test_discovery.py index b170f05..f64e431 100644 --- a/tests/test_discovery.py +++ b/tests/test_discovery.py @@ -1,5 +1,6 @@ from __future__ import annotations +import contextlib import logging import os import stat @@ -17,7 +18,7 @@ from python_discovery._discovery import IS_WIN, LazyPathDump, get_paths, propose_interpreters if TYPE_CHECKING: - from collections.abc import Callable + from collections.abc import Callable, Iterable from pytest_mock import MockerFixture @@ -45,28 +46,22 @@ def test_discovery_via_path( ) -> None: caplog.set_level(logging.DEBUG) current = PythonInfo.current_system(session_cache) - name = "somethingVeryCryptic" - threaded = "t" if current.free_threaded else "" - if case == "lower": - name = name.lower() - elif case == "upper": - name = name.upper() - if specificity == "more": - core_ver = current.version_info.major - exe_ver = ".".join(str(i) for i in current.version_info[0:2]) + threaded - elif specificity == "less": - core_ver = ".".join(str(i) for i in current.version_info[0:3]) - exe_ver = current.version_info.major - elif specificity == "none": # pragma: no branch, the branch depends on the interpreter's own version - core_ver = ".".join(str(i) for i in current.version_info[0:3]) - exe_ver = "" - core = "" if specificity == "none" else f"{name}{core_ver}{threaded}" - exe_name = f"{name}{exe_ver}{'.exe' if sys.platform == 'win32' else ''}" + name = {"mixed": "somethingVeryCryptic", "lower": "somethingverycryptic", "upper": "SOMETHINGVERYCRYPTIC"}[case] + threaded = "t" * current.free_threaded + minor_version = ".".join(str(i) for i in current.version_info[0:2]) + full_version = ".".join(str(i) for i in current.version_info[0:3]) + core_ver, exe_ver = { + "more": (current.version_info.major, minor_version + threaded), + "less": (full_version, current.version_info.major), + "none": (full_version, ""), + }[specificity] + core = f"{name}{core_ver}{threaded}" * (specificity != "none") + exe_name = f"{name}{exe_ver}{_EXE_SUFFIX}" target = tmp_path / current.install_path("scripts") target.mkdir(parents=True) Path(str(target / exe_name)).symlink_to(sys.executable) pyvenv_cfg = Path(sys.executable).parents[1] / "pyvenv.cfg" - if pyvenv_cfg.exists(): # pragma: no branch, the branch depends on the host interpreter layout + with contextlib.suppress(FileNotFoundError): (target / pyvenv_cfg.name).write_bytes(pyvenv_cfg.read_bytes()) monkeypatch.setenv("PATH", os.pathsep.join([str(target), *os.environ.get("PATH", "").split(os.pathsep)])) interpreter = get_interpreter(core, []) @@ -101,7 +96,7 @@ def test_discovery_via_path_literal_symlink(tmp_path: Path) -> None: @pytest.mark.skipif(sys.implementation.name != "cpython", reason="requires CPython") @pytest.mark.parametrize("name", [pytest.param("pypy", id="pypy"), pytest.param("Python", id="mixed-case-python")]) def test_pypy_spec_rejects_cpython_alias(name: str, tmp_path: Path, session_cache: DiskCache) -> None: - threaded = "t" if PythonInfo.current_system(session_cache).free_threaded else "" + threaded = "t" * PythonInfo.current_system(session_cache).free_threaded version = f"{sys.version_info.major}.{sys.version_info.minor}{threaded}" (tmp_path / f"{name}{version}{_EXE_SUFFIX}").symlink_to(sys.executable) env = {"PATH": str(tmp_path), "UV_PYTHON_INSTALL_DIR": str(tmp_path / "missing")} @@ -115,7 +110,7 @@ def test_pypy_spec_rejects_cpython_alias(name: str, tmp_path: Path, session_cach @pytest.mark.skipif(sys.implementation.name != "cpython", reason="requires CPython") def test_generic_python_spec_accepts_mixed_case_alias(tmp_path: Path, session_cache: DiskCache) -> None: - threaded = "t" if PythonInfo.current_system(session_cache).free_threaded else "" + threaded = "t" * PythonInfo.current_system(session_cache).free_threaded version = f"{sys.version_info.major}.{sys.version_info.minor}{threaded}" alias = tmp_path / f"Python{version}{_EXE_SUFFIX}" alias.symlink_to(sys.executable) @@ -242,11 +237,11 @@ def test_discovery_invalid_specifier_raises(session_cache: DiskCache) -> None: @pytest.mark.parametrize( - "use_iterator", - [pytest.param(False, id="list"), pytest.param(True, id="iterator")], + "wrap", + [pytest.param(list, id="list"), pytest.param(iter, id="iterator")], ) def test_discovery_fallback_reuses_preferred_paths( - tmp_path: Path, session_cache: DiskCache, mocker: MockerFixture, *, use_iterator: bool + tmp_path: Path, session_cache: DiskCache, mocker: MockerFixture, *, wrap: Callable[[list[str]], Iterable[str]] ) -> None: executable = tmp_path / "preferred" / _VENV_PYTHON executable.parent.mkdir(parents=True) @@ -257,7 +252,7 @@ def test_discovery_fallback_reuses_preferred_paths( paths = [str(executable)] result = get_interpreter( ["missing-interpreter", ">=3"], - try_first_with=iter(paths) if use_iterator else paths, + try_first_with=wrap(paths), cache=session_cache, env={**os.environ, "PATH": str(tmp_path), "UV_PYTHON_INSTALL_DIR": str(tmp_path / "missing")}, predicate=lambda info: Path(info.executable) == executable, @@ -489,9 +484,8 @@ def test_shim_resolved_to_real_binary( monkeypatch.setenv("PATH", str(root / "shims")) monkeypatch.setenv(env_var, str(root)) monkeypatch.setenv("PYENV_VERSION", "2.7.18") - monkeypatch.delenv("MISE_DATA_DIR", raising=False) if env_var != "MISE_DATA_DIR" else None - monkeypatch.delenv("ASDF_DATA_DIR", raising=False) if env_var != "ASDF_DATA_DIR" else None - monkeypatch.delenv("PYENV_ROOT", raising=False) if env_var != "PYENV_ROOT" else None + for name in {"MISE_DATA_DIR", "ASDF_DATA_DIR", "PYENV_ROOT"} - {env_var}: + monkeypatch.delenv(name, raising=False) with patch("python_discovery._discovery.PathPythonInfo.from_exe", autospec=True) as mock_from_exe: mock_from_exe.return_value = None @@ -783,7 +777,7 @@ def reject_first(info: PythonInfo) -> bool: proposed.append(str(info.executable)) return len(proposed) > 1 - threaded = "t" if current.free_threaded else "" + threaded = "t" * current.free_threaded result = get_interpreter([f"{major}.{minor}{threaded}", sys.executable], [], session_cache, predicate=reject_first) # rejecting the first proposal must not abort discovery; a later proposal (same or fallback spec) is accepted - # on hosts where every alias resolves to one interpreter the fallback re-proposes it, so executables may match @@ -842,7 +836,7 @@ def test_discovery_uv_store_install_dir_overrides_the_platform_default( uv_probed: UvProbe, expand_user: bool, ) -> None: - monkeypatch.setenv("UV_PYTHON_INSTALL_DIR", "~/store" if expand_user else str(uv_home / "store")) + monkeypatch.setenv("UV_PYTHON_INSTALL_DIR", ("~/store", str(uv_home / "store"))[not expand_user]) uv_plant(uv_home / ".local" / "share" / "uv" / "python") executable = uv_plant(uv_home / "store") @@ -1044,7 +1038,7 @@ def test_discovery_uv_patch_release_matches_minor_spec( info.executable = str(executable) info.system_executable = str(executable) info.version_info = info.version_info._replace( - major=3, minor=99, micro=2 if releaselevel == "final" else 0, releaselevel=releaselevel, serial=2 + major=3, minor=99, micro={"final": 2}.get(releaselevel, 0), releaselevel=releaselevel, serial=2 ) mocker.patch("python_discovery._discovery.PathPythonInfo.from_exe", return_value=info, autospec=True) diff --git a/tests/windows/conftest.py b/tests/windows/conftest.py index ecf1e0c..90e7db7 100644 --- a/tests/windows/conftest.py +++ b/tests/windows/conftest.py @@ -53,13 +53,6 @@ def _create_winreg_mock() -> ModuleType: return winreg -class _RegistryData(NamedTuple): - enum_collect: dict[int, list[str | OSError]] - value_collect: dict[int, dict[str, tuple[str, int] | OSError]] - key_open: dict[int, dict[str, int]] - hive_open: dict[tuple[int, str, int, int], int | OSError] - - def _load_registry_data(winreg: ModuleType) -> _RegistryData: # Load winreg mock values using the given (possibly mock) winreg module. loc: dict[str, object] = {} @@ -69,6 +62,13 @@ def _load_registry_data(winreg: ModuleType) -> _RegistryData: return _RegistryData(loc["_ENUM_COLLECT"], loc["_VALUE_COLLECT"], loc["_KEY_OPEN"], loc["_HIVE_OPEN"]) # ty: ignore[invalid-return-type] the namespace is built by exec, so it has no static types +class _RegistryData(NamedTuple): + enum_collect: dict[int, list[str | OSError]] + value_collect: dict[int, dict[str, tuple[str, int] | OSError]] + key_open: dict[int, dict[str, int]] + hive_open: dict[tuple[int, str, int, int], int | OSError] + + def _make_enum_key(enum_collect: dict[int, list[str | OSError]]) -> Callable[[object, int], str]: def _enum_key(key: object, at: int) -> str: result = enum_collect[(key.value if isinstance(key, _Key) else key)][at] From c77b10fb6305d88d6a6b2b4cbe6a8bd406b18b30 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Sat, 7 Nov 2026 08:36:20 -0800 Subject: [PATCH 19/25] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor(windows):?= =?UTF-8?q?=20name=20the=20registry=20result=20and=20drop=20single-use=20h?= =?UTF-8?q?elpers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Registry specs and parsed versions are NamedTuples, and the arch, version, threaded and stop-reader helpers are inlined where they have one caller. Verified on Windows 11 (tox 3.14): the Windows-only failures left are POSIX-path assumptions in test_py_info_collect and test_py_info_extra. --- src/python_discovery/_cached_py_info.py | 16 +-- src/python_discovery/_windows/_cancel_io.py | 7 +- src/python_discovery/_windows/_pep514.py | 123 +++++++++----------- src/python_discovery/_windows/_propose.py | 8 +- tests/test_discovery.py | 113 ++++++++++-------- tests/test_py_info_collect.py | 36 +++--- tests/test_py_info_extra.py | 17 +-- tests/test_query_timeout.py | 4 +- tests/windows/conftest.py | 3 +- tests/windows/test_windows_pep514.py | 47 ++++---- 10 files changed, 185 insertions(+), 189 deletions(-) diff --git a/src/python_discovery/_cached_py_info.py b/src/python_discovery/_cached_py_info.py index 4af368e..249d790 100644 --- a/src/python_discovery/_cached_py_info.py +++ b/src/python_discovery/_cached_py_info.py @@ -28,7 +28,7 @@ from ._py_info_collect import MIN_INTERROGATE_VERSION, UNSUPPORTED_EXIT_CODE, UNSUPPORTED_MARKER if sys.platform == "win32": # pragma: win32 cover - from ._windows._cancel_io import stop_query_reader + from ._windows._cancel_io import _stop_query_reader if TYPE_CHECKING: from collections.abc import Generator, Mapping @@ -320,7 +320,11 @@ def _collect_query_output(process: Popen[str], timeout: float | None) -> _QueryO process.kill() # A descendant may keep the pipes open after the candidate exits. process.wait() - _stop_query_readers(readers) + for reader in readers: + if sys.platform == "win32": # pragma: win32 cover + _stop_query_reader(reader) + else: + reader.join() return _QueryOutput("", "timed out", -1) if state.read_errors: raise state.read_errors[0] @@ -379,14 +383,6 @@ def _query_completed(process: Popen[str], readers: tuple[Thread, Thread], timeou return True -def _stop_query_readers(readers: tuple[Thread, Thread]) -> None: - for reader in readers: - if sys.platform == "win32": # pragma: win32 cover - stop_query_reader(reader) - else: - reader.join() - - class _QueryOutput(NamedTuple): out: str err: str diff --git a/src/python_discovery/_windows/_cancel_io.py b/src/python_discovery/_windows/_cancel_io.py index 499a9cf..a1f468d 100644 --- a/src/python_discovery/_windows/_cancel_io.py +++ b/src/python_discovery/_windows/_cancel_io.py @@ -10,7 +10,7 @@ from threading import Thread -def stop_query_reader(reader: Thread) -> None: +def _stop_query_reader(reader: Thread) -> None: deadline = monotonic() + 1 while reader.is_alive() and monotonic() < deadline: if (thread_id := reader.native_id) is not None: @@ -46,8 +46,3 @@ def _cancel_synchronous_io(thread_id: int) -> None: finally: if not close(handle): raise WinError(get_last_error()) - - -__all__ = [ - "stop_query_reader", -] diff --git a/src/python_discovery/_windows/_pep514.py b/src/python_discovery/_windows/_pep514.py index 1ae3713..2f23e52 100644 --- a/src/python_discovery/_windows/_pep514.py +++ b/src/python_discovery/_windows/_pep514.py @@ -9,13 +9,11 @@ import winreg from logging import basicConfig, getLogger from pathlib import PureWindowsPath -from typing import TYPE_CHECKING, Any, Final +from typing import TYPE_CHECKING, Any, Final, NamedTuple if TYPE_CHECKING: from collections.abc import Generator - _RegistrySpec = tuple[str, int | None, int | None, int, bool, str, str | None] - _LOGGER: Final[logging.Logger] = getLogger(__name__) _ARCH_RE: Final[re.Pattern[str]] = re.compile( r""" @@ -58,14 +56,24 @@ def _enum_keys(key: Any) -> Generator[str, None, None]: # ruff:ignore[any-type] at += 1 -def get_value(key: Any, value_name: str | None) -> Any: # ruff:ignore[any-type] winreg handles have no importable type on other platforms +def _get_value(key: Any, value_name: str | None) -> Any: # ruff:ignore[any-type] winreg handles have no importable type on other platforms try: return winreg.QueryValueEx(key, value_name)[0] # ty: ignore[unresolved-attribute] winreg is Windows-only, so type checkers cannot resolve it except OSError: return None -def discover_pythons() -> Generator[_RegistrySpec, None, None]: +class _RegistrySpec(NamedTuple): + company: str + major: int | None + minor: int | None + arch: int + threaded: bool + exe: str + args: str | None + + +def _discover_pythons() -> Generator[_RegistrySpec, None, None]: for hive, hive_name, key, flags, default_arch in [ (winreg.HKEY_CURRENT_USER, "HKEY_CURRENT_USER", r"Software\Python", 0, 64), # ty: ignore[unresolved-attribute] winreg is Windows-only, so type checkers cannot resolve it (winreg.HKEY_LOCAL_MACHINE, "HKEY_LOCAL_MACHINE", r"Software\Python", winreg.KEY_WOW64_64KEY, 64), # ty: ignore[unresolved-attribute] winreg is Windows-only, so type checkers cannot resolve it @@ -105,25 +113,44 @@ def _process_company( def _process_tag(hive_name: str, company: str, company_key: Any, tag: str, default_arch: int) -> _RegistrySpec | None: # ruff:ignore[any-type] winreg handles have no importable type on other platforms with winreg.OpenKeyEx(company_key, tag) as tag_key: # ty: ignore[unresolved-attribute] winreg is Windows-only, so type checkers cannot resolve it - if ( - version := _load_version_data(hive_name, company, tag, tag_key) - ) is not None: # if failed to get version bail - major, minor, _ = version - if (arch := _load_arch_data(hive_name, company, tag, tag_key, default_arch)) is not None: - if (exe_data := load_exe(hive_name, company, company_key, tag)) is not None: - exe, args = exe_data - return company, major, minor, arch, _load_threaded(hive_name, company, tag, tag_key), exe, args - return None + for candidate, key_path in [ + (_get_value(tag_key, "SysVersion"), f"{hive_name}/{company}/{tag}/SysVersion"), + (tag, f"{hive_name}/{company}/{tag}"), + ]: + if candidate is None: + continue + try: + version = _parse_version(candidate) + except ValueError as sys_version: + _msg(key_path, sys_version) + else: + break + else: return None - return None + arch = default_arch + if (arch_str := _get_value(tag_key, "SysArchitecture")) is not None: + try: + arch = _parse_arch(arch_str) + except ValueError as sys_arch: + _msg(f"{hive_name}/{company}/{tag}/SysArchitecture", sys_arch) + if (exe_data := _load_exe(hive_name, company, company_key, tag)) is None: + return None + exe, args = exe_data + display_name = _get_value(tag_key, "DisplayName") + if display_name is not None and not isinstance(display_name, str): + _msg(f"{hive_name}/{company}/{tag}/DisplayName", f"display name is not string: {display_name!r}") + threaded = bool(_THREADED_TAG_RE.match(tag)) or ( + isinstance(display_name, str) and "freethreaded" in display_name.lower() + ) + return _RegistrySpec(company, version.major, version.minor, arch, threaded, exe, args) -def load_exe(hive_name: str, company: str, company_key: Any, tag: str) -> tuple[str, str | None] | None: # ruff:ignore[any-type] winreg handles have no importable type on other platforms +def _load_exe(hive_name: str, company: str, company_key: Any, tag: str) -> tuple[str, str | None] | None: # ruff:ignore[any-type] winreg handles have no importable type on other platforms key_path = f"{hive_name}/{company}/{tag}" try: with winreg.OpenKeyEx(company_key, rf"{tag}\InstallPath") as ip_key, ip_key: # ty: ignore[unresolved-attribute] winreg is Windows-only, so type checkers cannot resolve it if (exe := _resolve_exe(ip_key, key_path)) is not None and os.path.exists(exe): - if (args := get_value(ip_key, "ExecutableArguments")) is None or isinstance(args, str): + if (args := _get_value(ip_key, "ExecutableArguments")) is None or isinstance(args, str): return exe, args if args and args.strip() else None _msg(f"{key_path}/ExecutableArguments", f"expected a string, got {type(args).__name__}") return None @@ -134,8 +161,8 @@ def load_exe(hive_name: str, company: str, company_key: Any, tag: str) -> tuple[ def _resolve_exe(ip_key: Any, key_path: str) -> str | None: # ruff:ignore[any-type] winreg handles have no importable type on other platforms - if (exe := get_value(ip_key, "ExecutablePath")) is None: - if (exe := get_value(ip_key, None)) is None: + if (exe := _get_value(ip_key, "ExecutablePath")) is None: + if (exe := _get_value(ip_key, None)) is None: _msg(key_path, "no ExecutablePath or default for it") return None if isinstance(exe, str): @@ -146,17 +173,7 @@ def _resolve_exe(ip_key: Any, key_path: str) -> str | None: # ruff:ignore[any-t return exe -def _load_arch_data(hive_name: str, company: str, tag: str, tag_key: Any, default_arch: int) -> int | None: # ruff:ignore[any-type] winreg handles have no importable type on other platforms - if (arch_str := get_value(tag_key, "SysArchitecture")) is not None: - key_path = f"{hive_name}/{company}/{tag}/SysArchitecture" - try: - return parse_arch(arch_str) - except ValueError as sys_arch: - _msg(key_path, sys_arch) - return default_arch - - -def parse_arch(arch_str: Any) -> int: # ruff:ignore[any-type] winreg handles have no importable type on other platforms +def _parse_arch(arch_str: Any) -> int: # ruff:ignore[any-type] winreg handles have no importable type on other platforms if isinstance(arch_str, str): if match := _ARCH_RE.match(arch_str): return int(next(iter(match.groups()))) @@ -166,29 +183,17 @@ def parse_arch(arch_str: Any) -> int: # ruff:ignore[any-type] winreg handles ha raise ValueError(error) -def _load_version_data( - hive_name: str, - company: str, - tag: str, - tag_key: Any, # ruff:ignore[any-type] winreg handles have no importable type on other platforms -) -> tuple[int | None, int | None, int | None] | None: - for candidate, key_path in [ - (get_value(tag_key, "SysVersion"), f"{hive_name}/{company}/{tag}/SysVersion"), - (tag, f"{hive_name}/{company}/{tag}"), - ]: - if candidate is not None: - try: - return parse_version(candidate) - except ValueError as sys_version: - _msg(key_path, sys_version) - return None +class _Version(NamedTuple): + major: int | None + minor: int | None + micro: int | None -def parse_version(version_str: Any) -> tuple[int | None, int | None, int | None]: # ruff:ignore[any-type] winreg handles have no importable type on other platforms +def _parse_version(version_str: Any) -> _Version: # ruff:ignore[any-type] winreg handles have no importable type on other platforms if isinstance(version_str, str): if match := _VERSION_RE.match(version_str): major, minor, micro = match["major"], match["minor"], match["micro"] - return ( + return _Version( int(major) if major is not None else None, int(minor) if minor is not None else None, int(micro) if micro is not None else None, @@ -199,33 +204,15 @@ def parse_version(version_str: Any) -> tuple[int | None, int | None, int | None] raise ValueError(error) -def _load_threaded(hive_name: str, company: str, tag: str, tag_key: Any) -> bool: # ruff:ignore[any-type] winreg handles have no importable type on other platforms - if (display_name := get_value(tag_key, "DisplayName")) is not None: - if isinstance(display_name, str): - if "freethreaded" in display_name.lower(): - return True - else: - _msg(f"{hive_name}/{company}/{tag}/DisplayName", f"display name is not string: {display_name!r}") - return bool(_THREADED_TAG_RE.match(tag)) - - -def _msg(path: str, what: object) -> None: +def _msg(path: str, what: str | Exception) -> None: _LOGGER.warning("PEP-514 violation in Windows Registry at %s error: %s", path, what) def _run() -> None: basicConfig() - sys.stdout.write("\n".join(sorted([repr(spec) for spec in discover_pythons()]))) + sys.stdout.write("\n".join(sorted([repr(tuple(spec)) for spec in _discover_pythons()]))) sys.stdout.write("\n") -__all__ = [ - "discover_pythons", - "load_exe", - "parse_arch", - "parse_version", -] - - if __name__ == "__main__": _run() diff --git a/src/python_discovery/_windows/_propose.py b/src/python_discovery/_windows/_propose.py index 4582e9e..b150b21 100644 --- a/src/python_discovery/_windows/_propose.py +++ b/src/python_discovery/_windows/_propose.py @@ -5,7 +5,7 @@ from python_discovery._py_info import PythonInfo from python_discovery._py_spec import PythonSpec -from ._pep514 import discover_pythons +from ._pep514 import _discover_pythons if TYPE_CHECKING: from collections.abc import Generator, Mapping @@ -18,7 +18,7 @@ def propose_interpreters( cache: PyInfoCache | None, env: Mapping[str, str], ) -> Generator[PythonInfo, None, None]: - existing = list(discover_pythons()) + existing = list(_discover_pythons()) existing.sort( key=lambda item: ( *tuple(-1 if j is None else j for j in item[1:4]), @@ -37,7 +37,7 @@ def propose_interpreters( skip_pre_filter = implementation.lower() != "cpython" registry_spec = PythonSpec("", implementation, major, minor, None, arch, exe, free_threaded=threaded) if skip_pre_filter or registry_spec.satisfies(spec): - interpreter = Pep514PythonInfo._from_exe_with_args( # ruff:ignore[private-member-access] same package; the Windows proposer builds the same info object + interpreter = _Pep514PythonInfo._from_exe_with_args( # ruff:ignore[private-member-access] same package; the Windows proposer builds the same info object exe, cache, env=env, @@ -48,7 +48,7 @@ def propose_interpreters( yield interpreter -class Pep514PythonInfo(PythonInfo): +class _Pep514PythonInfo(PythonInfo): # A Python information acquired from PEP-514. ... diff --git a/tests/test_discovery.py b/tests/test_discovery.py index f64e431..3d1472e 100644 --- a/tests/test_discovery.py +++ b/tests/test_discovery.py @@ -9,7 +9,6 @@ from pathlib import Path from subprocess import check_call, check_output from typing import TYPE_CHECKING, Final, Protocol -from unittest.mock import patch from uuid import uuid4 import pytest @@ -379,18 +378,18 @@ def test_get_paths_skips_relative_entries(tmp_path: Path, monkeypatch: pytest.Mo assert list(get_paths({"PATH": f"{relative}{os.pathsep}{absolute_dir}"})) == [absolute_dir] -def test_get_paths_skips_relative_default(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: +def test_get_paths_skips_relative_default( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, mocker: MockerFixture +) -> None: monkeypatch.chdir(tmp_path) (tmp_path / "python3").touch() absolute_dir = tmp_path / "absolute" absolute_dir.mkdir() (absolute_dir / "python3").touch() - with ( - patch("python_discovery._discovery.os.confstr", side_effect=AttributeError, create=True, autospec=True), - patch("python_discovery._discovery.os.defpath", f".{os.pathsep}{absolute_dir}"), - ): - assert list(get_paths({})) == [absolute_dir] + mocker.patch("python_discovery._discovery.os.confstr", side_effect=AttributeError, create=True, autospec=True) + mocker.patch("python_discovery._discovery.os.defpath", f".{os.pathsep}{absolute_dir}") + assert list(get_paths({})) == [absolute_dir] @pytest.mark.parametrize( @@ -476,6 +475,7 @@ def test_shim_resolved_to_real_binary( monkeypatch: pytest.MonkeyPatch, env_var: str, versions_path: tuple[str, ...], + mocker: MockerFixture, ) -> None: root = _create_version_manager(tmp_path, env_var) real_binary = _create_versioned_binary(root, versions_path, "2.7.18", "python2.7") @@ -487,10 +487,10 @@ def test_shim_resolved_to_real_binary( for name in {"MISE_DATA_DIR", "ASDF_DATA_DIR", "PYENV_ROOT"} - {env_var}: monkeypatch.delenv(name, raising=False) - with patch("python_discovery._discovery.PathPythonInfo.from_exe", autospec=True) as mock_from_exe: - mock_from_exe.return_value = None - get_interpreter("python2.7", []) - assert mock_from_exe.call_args_list[0][0][0] == str(real_binary) + mock_from_exe = mocker.patch("python_discovery._discovery.PathPythonInfo.from_exe", autospec=True) + mock_from_exe.return_value = None + get_interpreter("python2.7", []) + assert mock_from_exe.call_args_list[0][0][0] == str(real_binary) def _create_version_manager(tmp_path: Path, env_var: str) -> Path: @@ -509,7 +509,9 @@ def _create_versioned_binary(root: Path, versions_path: tuple[str, ...], version return exe -def test_shim_not_resolved_without_version_manager_env(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: +def test_shim_not_resolved_without_version_manager_env( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, mocker: MockerFixture +) -> None: shims_dir = tmp_path / "shims" shims_dir.mkdir() shim = shims_dir / _PYTHON27_NAME @@ -520,13 +522,15 @@ def test_shim_not_resolved_without_version_manager_env(tmp_path: Path, monkeypat monkeypatch.delenv("MISE_DATA_DIR", raising=False) monkeypatch.delenv("ASDF_DATA_DIR", raising=False) - with patch("python_discovery._discovery.PathPythonInfo.from_exe", autospec=True) as mock_from_exe: - mock_from_exe.return_value = None - get_interpreter("python2.7", []) - assert mock_from_exe.call_args_list[0][0][0] == str(shim) + mock_from_exe = mocker.patch("python_discovery._discovery.PathPythonInfo.from_exe", autospec=True) + mock_from_exe.return_value = None + get_interpreter("python2.7", []) + assert mock_from_exe.call_args_list[0][0][0] == str(shim) -def test_shim_falls_through_when_binary_missing(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: +def test_shim_falls_through_when_binary_missing( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, mocker: MockerFixture +) -> None: root = _create_version_manager(tmp_path, "PYENV_ROOT") shim = root / "shims" / _PYTHON27_NAME shim.touch(mode=0o755) @@ -535,13 +539,13 @@ def test_shim_falls_through_when_binary_missing(tmp_path: Path, monkeypatch: pyt monkeypatch.setenv("PYENV_ROOT", str(root)) monkeypatch.setenv("PYENV_VERSION", "2.7.18") - with patch("python_discovery._discovery.PathPythonInfo.from_exe", autospec=True) as mock_from_exe: - mock_from_exe.return_value = None - get_interpreter("python2.7", []) - assert mock_from_exe.call_args_list[0][0][0] == str(shim) + mock_from_exe = mocker.patch("python_discovery._discovery.PathPythonInfo.from_exe", autospec=True) + mock_from_exe.return_value = None + get_interpreter("python2.7", []) + assert mock_from_exe.call_args_list[0][0][0] == str(shim) -def test_shim_uses_python_version_file(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: +def test_shim_uses_python_version_file(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, mocker: MockerFixture) -> None: root = _create_version_manager(tmp_path, "PYENV_ROOT") real_binary = _create_versioned_binary(root, ("versions",), "2.7.18", "python2.7") (root / "shims" / _PYTHON27_NAME).touch(mode=0o755) @@ -552,10 +556,10 @@ def test_shim_uses_python_version_file(tmp_path: Path, monkeypatch: pytest.Monke monkeypatch.delenv("PYENV_VERSION", raising=False) monkeypatch.chdir(tmp_path) - with patch("python_discovery._discovery.PathPythonInfo.from_exe", autospec=True) as mock_from_exe: - mock_from_exe.return_value = None - get_interpreter("python2.7", []) - assert mock_from_exe.call_args_list[0][0][0] == str(real_binary) + mock_from_exe = mocker.patch("python_discovery._discovery.PathPythonInfo.from_exe", autospec=True) + mock_from_exe.return_value = None + get_interpreter("python2.7", []) + assert mock_from_exe.call_args_list[0][0][0] == str(real_binary) @pytest.mark.parametrize( @@ -569,7 +573,11 @@ def test_shim_uses_python_version_file(tmp_path: Path, monkeypatch: pytest.Monke ], ) def test_shim_skips_path_like_version( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch, version: str, target: str + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + version: str, + target: str, + mocker: MockerFixture, ) -> None: root = _create_version_manager(tmp_path, "PYENV_ROOT") real_binary = _create_versioned_binary(root, ("versions",), "2.7.18", "python2.7") @@ -589,13 +597,15 @@ def test_shim_skips_path_like_version( monkeypatch.delenv("PYENV_VERSION", raising=False) monkeypatch.chdir(tmp_path) - with patch("python_discovery._discovery.PathPythonInfo.from_exe", autospec=True) as mock_from_exe: - mock_from_exe.return_value = None - get_interpreter("python2.7", []) - assert mock_from_exe.call_args_list[0][0][0] == str(real_binary) + mock_from_exe = mocker.patch("python_discovery._discovery.PathPythonInfo.from_exe", autospec=True) + mock_from_exe.return_value = None + get_interpreter("python2.7", []) + assert mock_from_exe.call_args_list[0][0][0] == str(real_binary) -def test_shim_skips_empty_environment_version(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: +def test_shim_skips_empty_environment_version( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, mocker: MockerFixture +) -> None: root = _create_version_manager(tmp_path, "PYENV_ROOT") real_binary = _create_versioned_binary(root, ("versions",), "2.7.18", "python2.7") _create_versioned_binary(root / "versions", (), "", "python2.7") @@ -605,10 +615,10 @@ def test_shim_skips_empty_environment_version(tmp_path: Path, monkeypatch: pytes monkeypatch.setenv("PYENV_ROOT", str(root)) monkeypatch.setenv("PYENV_VERSION", ":2.7.18") - with patch("python_discovery._discovery.PathPythonInfo.from_exe", autospec=True) as mock_from_exe: - mock_from_exe.return_value = None - get_interpreter("python2.7", []) - assert mock_from_exe.call_args_list[0][0][0] == str(real_binary) + mock_from_exe = mocker.patch("python_discovery._discovery.PathPythonInfo.from_exe", autospec=True) + mock_from_exe.return_value = None + get_interpreter("python2.7", []) + assert mock_from_exe.call_args_list[0][0][0] == str(real_binary) @pytest.mark.parametrize( @@ -695,7 +705,9 @@ def _probed() -> tuple[str, ...]: return _probed -def test_shim_pyenv_version_env_takes_priority_over_file(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: +def test_shim_pyenv_version_env_takes_priority_over_file( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, mocker: MockerFixture +) -> None: root = _create_version_manager(tmp_path, "PYENV_ROOT") _create_versioned_binary(root, ("versions",), "2.7.18", "python2.7") env_binary = _create_versioned_binary(root, ("versions",), "2.7.15", "python2.7") @@ -707,13 +719,13 @@ def test_shim_pyenv_version_env_takes_priority_over_file(tmp_path: Path, monkeyp monkeypatch.setenv("PYENV_VERSION", "2.7.15") monkeypatch.chdir(tmp_path) - with patch("python_discovery._discovery.PathPythonInfo.from_exe", autospec=True) as mock_from_exe: - mock_from_exe.return_value = None - get_interpreter("python2.7", []) - assert mock_from_exe.call_args_list[0][0][0] == str(env_binary) + mock_from_exe = mocker.patch("python_discovery._discovery.PathPythonInfo.from_exe", autospec=True) + mock_from_exe.return_value = None + get_interpreter("python2.7", []) + assert mock_from_exe.call_args_list[0][0][0] == str(env_binary) -def test_shim_uses_global_version_file(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: +def test_shim_uses_global_version_file(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, mocker: MockerFixture) -> None: root = _create_version_manager(tmp_path, "PYENV_ROOT") real_binary = _create_versioned_binary(root, ("versions",), "2.7.18", "python2.7") (root / "shims" / _PYTHON27_NAME).touch(mode=0o755) @@ -726,15 +738,16 @@ def test_shim_uses_global_version_file(tmp_path: Path, monkeypatch: pytest.Monke monkeypatch.delenv("PYENV_VERSION", raising=False) monkeypatch.chdir(workdir) - with patch("python_discovery._discovery.PathPythonInfo.from_exe", autospec=True) as mock_from_exe: - mock_from_exe.return_value = None - get_interpreter("python2.7", []) - assert mock_from_exe.call_args_list[0][0][0] == str(real_binary) + mock_from_exe = mocker.patch("python_discovery._discovery.PathPythonInfo.from_exe", autospec=True) + mock_from_exe.return_value = None + get_interpreter("python2.7", []) + assert mock_from_exe.call_args_list[0][0][0] == str(real_binary) def test_shim_colon_separated_pyenv_version_picks_first_match( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, + mocker: MockerFixture, ) -> None: root = _create_version_manager(tmp_path, "PYENV_ROOT") _create_versioned_binary(root, ("versions",), "2.7.18", "python2.7") @@ -745,10 +758,10 @@ def test_shim_colon_separated_pyenv_version_picks_first_match( monkeypatch.setenv("PYENV_ROOT", str(root)) monkeypatch.setenv("PYENV_VERSION", "3.9.1:2.7.15") - with patch("python_discovery._discovery.PathPythonInfo.from_exe", autospec=True) as mock_from_exe: - mock_from_exe.return_value = None - get_interpreter("python2.7", []) - assert mock_from_exe.call_args_list[0][0][0] == str(second_binary) + mock_from_exe = mocker.patch("python_discovery._discovery.PathPythonInfo.from_exe", autospec=True) + mock_from_exe.return_value = None + get_interpreter("python2.7", []) + assert mock_from_exe.call_args_list[0][0][0] == str(second_binary) def test_predicate_filters_interpreters(session_cache: DiskCache) -> None: diff --git a/tests/test_py_info_collect.py b/tests/test_py_info_collect.py index 89764ac..b48e25b 100644 --- a/tests/test_py_info_collect.py +++ b/tests/test_py_info_collect.py @@ -271,30 +271,34 @@ def test_system_executable_from_existing_base(mocker: MockerFixture, tmp_path: P @pytest.mark.usefixtures("no_framework") -@pytest.mark.parametrize( - "same_file", - [ - pytest.param(False, id="distinct"), - pytest.param(True, id="alias"), - ], -) -def test_system_executable_prefers_exact_version(mocker: MockerFixture, tmp_path: Path, *, same_file: bool) -> None: +def test_system_executable_prefers_exact_version_for_distinct_file(mocker: MockerFixture, tmp_path: Path) -> None: + bin_dir = tmp_path / "bin" + bin_dir.mkdir() + versioned = bin_dir / "python3.13" + versioned.touch() + generic = bin_dir / "python3" + generic.touch() + _venv(mocker) + mocker.patch.object(sys, "_base_executable", str(generic), create=True) + mocker.patch.object(sys, "version_info", VersionInfo(3, 13, 0, "final", 0)) + assert PythonInfo().system_executable == str(versioned) + + +@pytest.mark.usefixtures("no_framework") +def test_system_executable_prefers_generic_alias_of_exact_version(mocker: MockerFixture, tmp_path: Path) -> None: bin_dir = tmp_path / "bin" bin_dir.mkdir() versioned = bin_dir / "python3.13" versioned.touch() generic = bin_dir / "python3" - if same_file: - generic.symlink_to(versioned.name) - landmark = tmp_path / "lib" / Path(os.__file__).parent.name / "os.py" - landmark.parent.mkdir(parents=True) - landmark.touch() - else: - generic.touch() + generic.symlink_to(versioned.name) + landmark = tmp_path / "lib" / Path(os.__file__).parent.name / "os.py" + landmark.parent.mkdir(parents=True) + landmark.touch() _venv(mocker) mocker.patch.object(sys, "_base_executable", str(generic), create=True) mocker.patch.object(sys, "version_info", VersionInfo(3, 13, 0, "final", 0)) - assert PythonInfo().system_executable == str(generic if same_file else versioned) + assert PythonInfo().system_executable == str(generic) def test_system_executable_versioned_fallback(mocker: MockerFixture, tmp_path: Path) -> None: diff --git a/tests/test_py_info_extra.py b/tests/test_py_info_extra.py index 951c9b4..7bc629b 100644 --- a/tests/test_py_info_extra.py +++ b/tests/test_py_info_extra.py @@ -6,7 +6,6 @@ import sys from pathlib import Path from typing import TYPE_CHECKING, Final -from unittest.mock import MagicMock import pytest @@ -179,12 +178,12 @@ def test_resolve_to_system_repeated_prefix_with_parent_segments(tmp_path: Path, assert result.system_executable == repeated.executable -def test_discover_exe_cache_hit() -> None: +def test_discover_exe_cache_hit(mocker: MockerFixture) -> None: info = copy.deepcopy(_CURRENT) cached = copy.deepcopy(_CURRENT) PythonInfo._cache_exe_discovery["/some/prefix", True, info.version_info] = cached try: - result = info.discover_exe(MagicMock(spec=PyInfoCache), prefix="/some/prefix", exact=True) + result = info.discover_exe(mocker.MagicMock(spec=PyInfoCache), prefix="/some/prefix", exact=True) assert result is cached finally: del PythonInfo._cache_exe_discovery["/some/prefix", True, info.version_info] @@ -206,10 +205,10 @@ def test_discover_exe_cache_keeps_requested_version(tmp_path: Path) -> None: PythonInfo.clear_cache(cache) -def test_check_exe_none_path(tmp_path: Path) -> None: +def test_check_exe_none_path(tmp_path: Path, mocker: MockerFixture) -> None: info = copy.deepcopy(_CURRENT) result = info._check_exe( - MagicMock(spec=PyInfoCache), str(tmp_path), "nonexistent", [], dict(os.environ), exact=True + mocker.MagicMock(spec=PyInfoCache), str(tmp_path), "nonexistent", [], dict(os.environ), exact=True ) assert result is None @@ -411,7 +410,9 @@ def test_check_exe_from_exe_returns_none(tmp_path: Path, mocker: MockerFixture) info = copy.deepcopy(_CURRENT) (tmp_path / "python").touch() mocker.patch.object(PythonInfo, "from_exe", return_value=None, autospec=True) - result = info._check_exe(MagicMock(spec=PyInfoCache), str(tmp_path), "python", [], dict(os.environ), exact=True) + result = info._check_exe( + mocker.MagicMock(spec=PyInfoCache), str(tmp_path), "python", [], dict(os.environ), exact=True + ) assert result is None @@ -423,7 +424,7 @@ def test_check_exe_mismatch_not_exact(tmp_path: Path, mocker: MockerFixture) -> mocker.patch.object(PythonInfo, "from_exe", return_value=other, autospec=True) discovered: list[PythonInfo] = [] result = info._check_exe( - MagicMock(spec=PyInfoCache), str(tmp_path), "python", discovered, dict(os.environ), exact=False + mocker.MagicMock(spec=PyInfoCache), str(tmp_path), "python", discovered, dict(os.environ), exact=False ) assert result is None assert len(discovered) == 1 @@ -437,7 +438,7 @@ def test_check_exe_mismatch_exact(tmp_path: Path, mocker: MockerFixture) -> None mocker.patch.object(PythonInfo, "from_exe", return_value=other, autospec=True) discovered: list[PythonInfo] = [] result = info._check_exe( - MagicMock(spec=PyInfoCache), str(tmp_path), "python", discovered, dict(os.environ), exact=True + mocker.MagicMock(spec=PyInfoCache), str(tmp_path), "python", discovered, dict(os.environ), exact=True ) assert result is None assert len(discovered) == 0 diff --git a/tests/test_query_timeout.py b/tests/test_query_timeout.py index 03b1255..bf67f32 100644 --- a/tests/test_query_timeout.py +++ b/tests/test_query_timeout.py @@ -33,9 +33,7 @@ def test_from_exe_query_timeout( mocker: MockerFixture, caplog: pytest.LogCaptureFixture, raw: str | None, expected: float | None ) -> None: collect = mocker.patch("python_discovery._cached_py_info._collect_query_output", wraps=_collect_query_output) - env = {key: value for key, value in os.environ.items() if key != "PY_DISCOVERY_TIMEOUT"} - if raw is not None: - env["PY_DISCOVERY_TIMEOUT"] = raw + env = {key: value for key, value in {**os.environ, "PY_DISCOVERY_TIMEOUT": raw}.items() if value is not None} result = PythonInfo.from_exe(sys.executable, env=env, ignore_cache=True, resolve_to_host=False) assert result is not None assert result.version_info == sys.version_info diff --git a/tests/windows/conftest.py b/tests/windows/conftest.py index 90e7db7..ff3ac0f 100644 --- a/tests/windows/conftest.py +++ b/tests/windows/conftest.py @@ -153,10 +153,9 @@ def _populate_pyinfo_cache(monkeypatch: pytest.MonkeyPatch) -> None: def _mock_pyinfo(major: int, minor: int, arch: int, exe: str, threaded: bool) -> MagicMock: - from python_discovery import PythonInfo from python_discovery._py_info import VersionInfo - info = MagicMock(spec=PythonInfo, base_prefix=str(Path(exe).parent)) + info = MagicMock(base_prefix=str(Path(exe).parent)) info.executable = info.original_executable = info.system_executable = exe info.implementation = "CPython" info.architecture = arch diff --git a/tests/windows/test_windows_pep514.py b/tests/windows/test_windows_pep514.py index 7b95781..f2fc53c 100644 --- a/tests/windows/test_windows_pep514.py +++ b/tests/windows/test_windows_pep514.py @@ -20,9 +20,9 @@ @pytest.mark.usefixtures("_mock_registry") def test_pep514_discovers_interpreters() -> None: - from python_discovery._windows._pep514 import discover_pythons + from python_discovery._windows._pep514 import _discover_pythons - interpreters = list(discover_pythons()) + interpreters = list(_discover_pythons()) assert len(interpreters) == 12 companies = {i[0] for i in interpreters} assert "ContinuumAnalytics" in companies @@ -32,30 +32,30 @@ def test_pep514_discovers_interpreters() -> None: @pytest.mark.usefixtures("_mock_registry") def test_pep514_parse_functions() -> None: - from python_discovery._windows._pep514 import parse_arch, parse_version + from python_discovery._windows._pep514 import _parse_arch, _parse_version - assert parse_arch("64bit") == 64 - assert parse_arch("32bit") == 32 + assert _parse_arch("64bit") == 64 + assert _parse_arch("32bit") == 32 with pytest.raises(ValueError, match="invalid format"): - parse_arch("magic") + _parse_arch("magic") with pytest.raises(ValueError, match="arch is not string"): - parse_arch(100) + _parse_arch(100) - assert parse_version("3.12") == (3, 12, None) - assert parse_version("3.12.1") == (3, 12, 1) - assert parse_version("3") == (3, None, None) + assert _parse_version("3.12") == (3, 12, None) + assert _parse_version("3.12.1") == (3, 12, 1) + assert _parse_version("3") == (3, None, None) with pytest.raises(ValueError, match="invalid format"): - parse_version("3.X") + _parse_version("3.X") with pytest.raises(ValueError, match="version is not string"): - parse_version(2778) + _parse_version(2778) @pytest.mark.skipif(sys.platform != "win32", reason="path joining differs on POSIX") @pytest.mark.usefixtures("_mock_registry") def test_pep514() -> None: - from python_discovery._windows._pep514 import discover_pythons + from python_discovery._windows._pep514 import _discover_pythons - interpreters = list(discover_pythons()) + interpreters = list(_discover_pythons()) assert interpreters == [ ("ContinuumAnalytics", 3, 10, 32, False, "C:\\Users\\user\\Miniconda3\\python.exe", None), ("ContinuumAnalytics", 3, 10, 64, False, "C:\\Users\\user\\Miniconda3-64\\python.exe", None), @@ -195,10 +195,10 @@ def test_pep514_rejects_non_absolute_executable( registered = {"ExecutablePath": executable_path, None: install_path} mocker.patch.object(pep514.winreg, "OpenKeyEx", return_value=MagicMock()) - mocker.patch.object(pep514, "get_value", side_effect=lambda _key, name: registered.get(name)) - mocker.patch.object(pep514.os.path, "exists", return_value=True) + mocker.patch.object(pep514, "_get_value", side_effect=lambda _key, name: registered.get(name), autospec=True) + mocker.patch.object(pep514.os.path, "exists", return_value=True, autospec=True) - assert pep514.load_exe("HKCU", "PythonCore", MagicMock(), "3.11") is None + assert pep514._load_exe("HKCU", "PythonCore", MagicMock(), "3.11") is None assert "expected an absolute executable path" in caplog.text @@ -207,12 +207,12 @@ def load_exe_with_args(mocker: MockerFixture) -> Callable[[object], tuple[str, s from python_discovery._windows import _pep514 as pep514 mocker.patch.object(pep514.winreg, "OpenKeyEx", return_value=MagicMock()) - mocker.patch.object(pep514, "_resolve_exe", return_value=r"C:\py.exe") - mocker.patch.object(pep514.os.path, "exists", return_value=True) + mocker.patch.object(pep514, "_resolve_exe", return_value=r"C:\py.exe", autospec=True) + mocker.patch.object(pep514.os.path, "exists", return_value=True, autospec=True) def load(registered: object) -> tuple[str, str | None] | None: - mocker.patch.object(pep514, "get_value", return_value=registered) - return pep514.load_exe("HKCU", "PythonCore", MagicMock(), "3.11") + mocker.patch.object(pep514, "_get_value", return_value=registered, autospec=True) + return pep514._load_exe("HKCU", "PythonCore", MagicMock(), "3.11") return load @@ -255,7 +255,10 @@ def test_pep514_launcher_arguments_select_interpreter(mocker: MockerFixture, tmp pytest.skip("the launcher cannot select 3.11") # ty: ignore[too-many-positional-arguments] # ty ships a stale pytest stub for 3.9 selected, major, minor, arch = json.loads(probe.stdout) mocker.patch.object( - _propose, "discover_pythons", return_value=[("PythonCore", major, minor, arch, False, launcher, "-3.11")] + _propose, + "_discover_pythons", + return_value=[("PythonCore", major, minor, arch, False, launcher, "-3.11")], + autospec=True, ) info = next( From d9c9e3ef261b49769a795c5b6897b2d16c562c22 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Sat, 7 Nov 2026 08:36:20 -0800 Subject: [PATCH 20/25] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor(test):=20cl?= =?UTF-8?q?ear=20the=20remaining=20simplint=20findings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use the mocker fixture for autospec patches and the cache doubles, type the registry and satisfies doubles, order the Windows conftest helpers after their users, and split the alias case from the distinct-file case. --- tests/py_info/test_py_info.py | 4 +++- tests/test_discovery.py | 2 +- tests/windows/conftest.py | 12 +++++++----- 3 files changed, 11 insertions(+), 7 deletions(-) diff --git a/tests/py_info/test_py_info.py b/tests/py_info/test_py_info.py index 6553418..af6e9df 100644 --- a/tests/py_info/test_py_info.py +++ b/tests/py_info/test_py_info.py @@ -23,6 +23,8 @@ from packaging.markers import Environment from pytest_mock import MockerFixture + from python_discovery import PyInfoCache + _IS_PYPY: Final[bool] = PythonInfo.current_system().implementation == "PyPy" _CURRENT: Final[PythonInfo] = PythonInfo.current_system() @@ -240,7 +242,7 @@ def _make_py_info(of: PyInfoMock) -> PythonInfo: mocker.patch.object(target_py_info, "_find_possible_exe_names", return_value=names, autospec=True) mocker.patch.object(target_py_info, "_find_possible_folders", return_value=[str(tmp_path)], autospec=True) - def func(exe_path: str, _cache: object = None, **_kwargs: object) -> PythonInfo: + def func(exe_path: str, _cache: PyInfoCache | None = None, **_kwargs: object) -> PythonInfo: return discovered_with_path[exe_path] mocker.patch.object(target_py_info, "from_exe", side_effect=func, autospec=True) diff --git a/tests/test_discovery.py b/tests/test_discovery.py index 3d1472e..2649881 100644 --- a/tests/test_discovery.py +++ b/tests/test_discovery.py @@ -387,7 +387,7 @@ def test_get_paths_skips_relative_default( absolute_dir.mkdir() (absolute_dir / "python3").touch() - mocker.patch("python_discovery._discovery.os.confstr", side_effect=AttributeError, create=True, autospec=True) + mocker.patch("python_discovery._discovery.os.confstr", side_effect=AttributeError, create=True) mocker.patch("python_discovery._discovery.os.defpath", f".{os.pathsep}{absolute_dir}") assert list(get_paths({})) == [absolute_dir] diff --git a/tests/windows/conftest.py b/tests/windows/conftest.py index ff3ac0f..2785a32 100644 --- a/tests/windows/conftest.py +++ b/tests/windows/conftest.py @@ -15,6 +15,8 @@ from typing_extensions import Self + from python_discovery import PythonSpec + @pytest.fixture def _mock_registry(monkeypatch: pytest.MonkeyPatch) -> None: @@ -69,8 +71,8 @@ class _RegistryData(NamedTuple): hive_open: dict[tuple[int, str, int, int], int | OSError] -def _make_enum_key(enum_collect: dict[int, list[str | OSError]]) -> Callable[[object, int], str]: - def _enum_key(key: object, at: int) -> str: +def _make_enum_key(enum_collect: dict[int, list[str | OSError]]) -> Callable[[_Key | int, int], str]: + def _enum_key(key: _Key | int, at: int) -> str: result = enum_collect[(key.value if isinstance(key, _Key) else key)][at] if isinstance(result, OSError): raise result @@ -94,8 +96,8 @@ def __exit__( def _make_query_value_ex( value_collect: dict[int, dict[str, tuple[str, int] | OSError]], -) -> Callable[[object, str], tuple[str, int]]: - def _query_value_ex(key: object, value_name: str) -> tuple[str, int]: +) -> Callable[[_Key | int, str], tuple[str, int]]: + def _query_value_ex(key: _Key | int, value_name: str) -> tuple[str, int]: result = value_collect[(key.value if isinstance(key, _Key) else key)][value_name] if isinstance(result, OSError): raise result @@ -164,7 +166,7 @@ def _mock_pyinfo(major: int, minor: int, arch: int, exe: str, threaded: bool) -> info.sysconfig_platform = "win-amd64" if arch == 64 else "win32" info.machine = "x86_64" if arch == 64 else "x86" - def satisfies(spec: object, _impl_must_match: bool = False) -> bool: + def satisfies(spec: PythonSpec, **_kwargs: object) -> bool: if spec.implementation is not None and spec.implementation.lower() != "cpython": return False if spec.architecture is not None and spec.architecture != arch: From 7d6ea1192c5b95ff0351eea6b4634c41949a7ff0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Sun, 8 Nov 2026 11:18:31 -0800 Subject: [PATCH 21/25] =?UTF-8?q?=F0=9F=90=9B=20fix(windows):=20restore=20?= =?UTF-8?q?stop=5Fquery=5Freader=20name?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 0.7.1 autofix renamed stop_query_reader to a private name and dropped its __all__. Other modules import it, so simplint 0.8.0 wants it listed in __all__ rather than private. Restoring the public name keeps it importable as before. --- src/python_discovery/_cached_py_info.py | 4 ++-- src/python_discovery/_windows/_cancel_io.py | 7 ++++++- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/python_discovery/_cached_py_info.py b/src/python_discovery/_cached_py_info.py index 249d790..473fefe 100644 --- a/src/python_discovery/_cached_py_info.py +++ b/src/python_discovery/_cached_py_info.py @@ -28,7 +28,7 @@ from ._py_info_collect import MIN_INTERROGATE_VERSION, UNSUPPORTED_EXIT_CODE, UNSUPPORTED_MARKER if sys.platform == "win32": # pragma: win32 cover - from ._windows._cancel_io import _stop_query_reader + from ._windows._cancel_io import stop_query_reader if TYPE_CHECKING: from collections.abc import Generator, Mapping @@ -322,7 +322,7 @@ def _collect_query_output(process: Popen[str], timeout: float | None) -> _QueryO process.wait() for reader in readers: if sys.platform == "win32": # pragma: win32 cover - _stop_query_reader(reader) + stop_query_reader(reader) else: reader.join() return _QueryOutput("", "timed out", -1) diff --git a/src/python_discovery/_windows/_cancel_io.py b/src/python_discovery/_windows/_cancel_io.py index a1f468d..499a9cf 100644 --- a/src/python_discovery/_windows/_cancel_io.py +++ b/src/python_discovery/_windows/_cancel_io.py @@ -10,7 +10,7 @@ from threading import Thread -def _stop_query_reader(reader: Thread) -> None: +def stop_query_reader(reader: Thread) -> None: deadline = monotonic() + 1 while reader.is_alive() and monotonic() < deadline: if (thread_id := reader.native_id) is not None: @@ -46,3 +46,8 @@ def _cancel_synchronous_io(thread_id: int) -> None: finally: if not close(handle): raise WinError(get_last_error()) + + +__all__ = [ + "stop_query_reader", +] From fb5edb1d67ead1a49041abf1c7f604ee662ad7da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Sun, 8 Nov 2026 11:23:32 -0800 Subject: [PATCH 22/25] =?UTF-8?q?=F0=9F=A7=AA=20test:=20build=20mocks=20th?= =?UTF-8?q?rough=20mocker=20with=20specs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tests create mocks through the mocker fixture that pytest-mock already provides, and every mock takes a spec so a misspelled attribute fails the test. The winreg fake is cached because _pep514 binds winreg at import; a fresh module per test left it patching a stale copy. --- tests/test_cached_py_info.py | 59 ++++++++++++++++------------ tests/test_discovery.py | 2 +- tests/test_py_info_collect.py | 20 ++++++---- tests/windows/conftest.py | 22 ++++++----- tests/windows/test_windows_pep514.py | 14 ++++--- 5 files changed, 68 insertions(+), 49 deletions(-) diff --git a/tests/test_cached_py_info.py b/tests/test_cached_py_info.py index 5931eba..2b85a73 100644 --- a/tests/test_cached_py_info.py +++ b/tests/test_cached_py_info.py @@ -12,7 +12,6 @@ from threading import enumerate as active_threads from time import monotonic from typing import TYPE_CHECKING, Final -from unittest.mock import MagicMock, patch import pytest @@ -33,7 +32,7 @@ if TYPE_CHECKING: from typing import IO - from pytest_mock import MockerFixture + from pytest_mock import MockerFixture, MockType def test_gen_cookie_length() -> None: @@ -94,20 +93,24 @@ def test_run_subprocess_bad_exe() -> None: def test_run_subprocess_invalid_json(mocker: MockerFixture) -> None: - mocker.patch("python_discovery._cached_py_info.Popen", return_value=_mock_process("not json", "", 0), autospec=True) + mocker.patch( + "python_discovery._cached_py_info.Popen", return_value=_mock_process(mocker, "not json", "", 0), autospec=True + ) failure, result = _run_subprocess(PythonInfo, sys.executable, dict(os.environ)) assert failure is not None assert result is None -def _mock_process(out: str, err: str, code: int) -> MagicMock: +def _mock_process(mocker: MockerFixture, out: str, err: str, code: int) -> MockType: streams: list[IO[str]] = [] for value in (out, err): read_fd, write_fd = os.pipe() with os.fdopen(write_fd, "wb") as writer: writer.write(value.encode()) streams.append(os.fdopen(read_fd, "r", encoding="utf-8")) - return MagicMock(stdout=streams[0], stderr=streams[1], returncode=code, **{"wait.return_value": code}) + return mocker.MagicMock( + spec=Popen, stdout=streams[0], stderr=streams[1], returncode=code, **{"wait.return_value": code} + ) @pytest.mark.parametrize( @@ -126,7 +129,7 @@ def test_from_exe_rejects_wrong_shape_json(tmp_path: Path, mocker: MockerFixture wrapped = f"{start_cookie}{output}{end_cookie}" mocker.patch( "python_discovery._cached_py_info.Popen", - side_effect=[_mock_process(wrapped, "", 0), _mock_process(wrapped, "", 0)], + side_effect=[_mock_process(mocker, wrapped, "", 0), _mock_process(mocker, wrapped, "", 0)], autospec=True, ) mocker.patch( @@ -139,7 +142,7 @@ def test_from_exe_rejects_json_without_cookies(tmp_path: Path, mocker: MockerFix output = PythonInfo().to_json() mocker.patch( "python_discovery._cached_py_info.Popen", - side_effect=[_mock_process(output, "", 0), _mock_process(output, "", 0)], + side_effect=[_mock_process(mocker, output, "", 0), _mock_process(mocker, output, "", 0)], autospec=True, ) assert PythonInfo.from_exe(str(tmp_path / "python"), raise_on_error=False, ignore_cache=True) is None @@ -150,11 +153,13 @@ def test_run_subprocess_with_cookies(mocker: MockerFixture) -> None: end_cookie = "b" * 32 out = f"pre{start_cookie[::-1]}{json.dumps(PythonInfo().to_dict())}{end_cookie[::-1]}post" - mocker.patch("python_discovery._cached_py_info.Popen", return_value=_mock_process(out, "", 0), autospec=True) + mocker.patch( + "python_discovery._cached_py_info.Popen", return_value=_mock_process(mocker, out, "", 0), autospec=True + ) mocker.patch("python_discovery._cached_py_info.gen_cookie", side_effect=[start_cookie, end_cookie], autospec=True) - with patch("sys.stdout", autospec=True) as mock_stdout: - failure, result = _run_subprocess(PythonInfo, sys.executable, dict(os.environ)) + mock_stdout = mocker.patch("sys.stdout", autospec=True) + failure, result = _run_subprocess(PythonInfo, sys.executable, dict(os.environ)) assert failure is None assert result is not None @@ -174,7 +179,7 @@ def test_run_subprocess_timeout(mocker: MockerFixture) -> None: def test_run_subprocess_nonzero_exit(mocker: MockerFixture) -> None: mocker.patch( "python_discovery._cached_py_info.Popen", - return_value=_mock_process("some output", "some error", 1), + return_value=_mock_process(mocker, "some output", "some error", 1), autospec=True, ) failure, result = _run_subprocess(PythonInfo, sys.executable, dict(os.environ)) @@ -211,13 +216,13 @@ def test_collect_query_output_limit(stream: str, timeout: float | None) -> None: ) -def test_collect_query_output_normalizes_trailing_newline() -> None: - assert _collect_query_output(_mock_process("\r", "", 0), 1) == ("\n", "", 0) +def test_collect_query_output_normalizes_trailing_newline(mocker: MockerFixture) -> None: + assert _collect_query_output(_mock_process(mocker, "\r", "", 0), 1) == ("\n", "", 0) -def test_collect_query_output_decodes_split_utf8() -> None: - with patch("python_discovery._cached_py_info.os.read", side_effect=lambda fd, _: read(fd, 1), autospec=True): - assert _collect_query_output(_mock_process("é", "", 0), 1) == ("é", "", 0) +def test_collect_query_output_decodes_split_utf8(mocker: MockerFixture) -> None: + mocker.patch("python_discovery._cached_py_info.os.read", side_effect=lambda fd, _: read(fd, 1), autospec=True) + assert _collect_query_output(_mock_process(mocker, "é", "", 0), 1) == ("é", "", 0) @pytest.mark.parametrize( @@ -276,28 +281,30 @@ def test_collect_query_output_read_error(mocker: MockerFixture) -> None: pytest.param(StringIO(""), None, id="stderr"), ], ) -def test_collect_query_output_requires_pipes(stdout: StringIO | None, stderr: StringIO | None) -> None: - process = MagicMock(spec=Popen, stdout=stdout, stderr=stderr) +def test_collect_query_output_requires_pipes( + mocker: MockerFixture, stdout: StringIO | None, stderr: StringIO | None +) -> None: + process = mocker.MagicMock(spec=Popen, stdout=stdout, stderr=stderr) with pytest.raises(ValueError, match="query output pipes are required"): _collect_query_output(process, 1) -def test_load_cached_py_info_valid() -> None: - result = _load_cached_py_info(PythonInfo, MagicMock(spec=ContentStore), PythonInfo().to_dict()) +def test_load_cached_py_info_valid(mocker: MockerFixture) -> None: + result = _load_cached_py_info(PythonInfo, mocker.MagicMock(spec=ContentStore), PythonInfo().to_dict()) assert result is not None assert isinstance(result, PythonInfo) -def test_load_cached_py_info_bad_data() -> None: - store = MagicMock(spec=ContentStore) +def test_load_cached_py_info_bad_data(mocker: MockerFixture) -> None: + store = mocker.MagicMock(spec=ContentStore) result = _load_cached_py_info(PythonInfo, store, {"bad": "data"}) assert result is None store.remove.assert_called_once() def test_load_cached_py_info_system_exe_missing(mocker: MockerFixture) -> None: - store = MagicMock(spec=ContentStore) + store = mocker.MagicMock(spec=ContentStore) content = PythonInfo().to_dict() content["system_executable"] = "/nonexistent/python" mocker.patch("os.path.exists", return_value=False, autospec=True) @@ -542,16 +549,16 @@ def test_from_exe_recovers_unusable_cache_path(tmp_path: Path, folder: str, suff @pytest.fixture -def popen(mocker: MockerFixture) -> MagicMock: +def popen(mocker: MockerFixture) -> MockType: start, end = "a" * 32, "b" * 32 mocker.patch("python_discovery._cached_py_info.gen_cookie", side_effect=[start, end], autospec=True) info = PythonInfo() info.executable = _SELECTED - process = _mock_process(f"{start[::-1]}{info.to_json()}{end[::-1]}", "", 0) + process = _mock_process(mocker, f"{start[::-1]}{info.to_json()}{end[::-1]}", "", 0) return mocker.patch("python_discovery._cached_py_info.Popen", return_value=process, autospec=True) -def test_executable_args_precede_query_script(popen: MagicMock) -> None: +def test_executable_args_precede_query_script(popen: MockType) -> None: _run_subprocess(PythonInfo, _LAUNCHER, dict(os.environ), _ARGS) cmd = popen.call_args.args[0] diff --git a/tests/test_discovery.py b/tests/test_discovery.py index 2649881..4d8e8b6 100644 --- a/tests/test_discovery.py +++ b/tests/test_discovery.py @@ -387,7 +387,7 @@ def test_get_paths_skips_relative_default( absolute_dir.mkdir() (absolute_dir / "python3").touch() - mocker.patch("python_discovery._discovery.os.confstr", side_effect=AttributeError, create=True) + monkeypatch.delattr(os, "confstr", raising=False) mocker.patch("python_discovery._discovery.os.defpath", f".{os.pathsep}{absolute_dir}") assert list(get_paths({})) == [absolute_dir] diff --git a/tests/test_py_info_collect.py b/tests/test_py_info_collect.py index b48e25b..4220b59 100644 --- a/tests/test_py_info_collect.py +++ b/tests/test_py_info_collect.py @@ -8,6 +8,7 @@ from itertools import takewhile from pathlib import Path from subprocess import run +from types import ModuleType from typing import TYPE_CHECKING, Final import pytest @@ -19,9 +20,8 @@ if TYPE_CHECKING: from collections.abc import Callable - from unittest.mock import Mock - from pytest_mock import MockerFixture + from pytest_mock import MockerFixture, MockType _SCRIPT: Final[Path] = Path(python_discovery.__file__).parent / "_py_info_collect.py" @@ -334,14 +334,18 @@ def test_tcl_tk_libs_queried_with_env(tmp_path: Path, mocker: MockerFixture) -> assert (info.tcl_lib, info.tk_lib) == ("/tcl-lib", str(tk_dir)) -def _fake_tkinter(mocker: MockerFixture, eval_side_effect: Callable[[str], str] | Exception) -> Mock: - evaluate = mocker.Mock(side_effect=eval_side_effect) - profile = mocker.Mock(return_value=None) +def _fake_tkinter(mocker: MockerFixture, eval_side_effect: Callable[[str], str] | Exception) -> MockType: + evaluate = mocker.MagicMock(spec=lambda *_: None, side_effect=eval_side_effect) + profile = mocker.MagicMock(spec=lambda *_: None, return_value=None) interpreter = type( "Tk", (), {"__init__": lambda self, **_: self.readprofile("Tk", "Tk"), "readprofile": profile, "eval": evaluate} ) - module = mocker.MagicMock(TclError=_TclError, Tk=interpreter) - module.Tcl.side_effect = lambda: interpreter(useTk=False) + module = mocker.MagicMock( + spec=ModuleType, + TclError=_TclError, + Tk=interpreter, + Tcl=mocker.MagicMock(spec=lambda: None, side_effect=lambda: interpreter(useTk=False)), + ) mocker.patch.dict(sys.modules, {"tkinter": module}) return profile @@ -365,7 +369,7 @@ def evaluate(script: str) -> str: raise AssertionError _fake_tkinter(mocker, evaluate) - mocker.patch.dict(sys.modules, {"_tkinter": mocker.MagicMock(TK_VERSION="8.6")}) + mocker.patch.dict(sys.modules, {"_tkinter": mocker.MagicMock(spec=ModuleType, TK_VERSION="8.6")}) mocker.patch.dict(os.environ, {"TCL_LIBRARY": str(tcl_dir)}) info = PythonInfo() diff --git a/tests/windows/conftest.py b/tests/windows/conftest.py index 2785a32..b859498 100644 --- a/tests/windows/conftest.py +++ b/tests/windows/conftest.py @@ -3,16 +3,17 @@ import os import sys from contextlib import AbstractContextManager, contextmanager +from functools import cache from pathlib import Path from types import ModuleType, TracebackType from typing import TYPE_CHECKING, NamedTuple, Protocol -from unittest.mock import MagicMock import pytest if TYPE_CHECKING: from collections.abc import Callable, Generator + from pytest_mock import MockerFixture, MockType from typing_extensions import Self from python_discovery import PythonSpec @@ -41,6 +42,8 @@ def _mock_exists(path: str) -> bool: monkeypatch.setattr("os.path.exists", _mock_exists) +# _pep514 binds winreg at import, so every test must patch the same fake module. +@cache def _create_winreg_mock() -> ModuleType: # Create a mock winreg module that works on all platforms. winreg = ModuleType("winreg") @@ -49,9 +52,10 @@ def _create_winreg_mock() -> ModuleType: winreg.KEY_READ = 0x20019 # ty: ignore[unresolved-attribute] winreg is Windows-only, so type checkers cannot resolve it winreg.KEY_WOW64_64KEY = 0x0100 # ty: ignore[unresolved-attribute] winreg is Windows-only, so type checkers cannot resolve it winreg.KEY_WOW64_32KEY = 0x0200 # ty: ignore[unresolved-attribute] winreg is Windows-only, so type checkers cannot resolve it - winreg.EnumKey = MagicMock() # ty: ignore[unresolved-attribute] winreg is Windows-only, so type checkers cannot resolve it - winreg.QueryValueEx = MagicMock() # ty: ignore[unresolved-attribute] winreg is Windows-only, so type checkers cannot resolve it - winreg.OpenKeyEx = MagicMock() # ty: ignore[unresolved-attribute] winreg is Windows-only, so type checkers cannot resolve it + # the fixture swaps these for registry lookups; the attributes must exist for mocker to patch them + winreg.EnumKey = None # ty: ignore[unresolved-attribute] winreg is Windows-only, so type checkers cannot resolve it + winreg.QueryValueEx = None # ty: ignore[unresolved-attribute] winreg is Windows-only, so type checkers cannot resolve it + winreg.OpenKeyEx = None # ty: ignore[unresolved-attribute] winreg is Windows-only, so type checkers cannot resolve it return winreg @@ -131,7 +135,7 @@ def __call__(self, *args: object) -> AbstractContextManager[_Key | int | OSError @pytest.fixture -def _populate_pyinfo_cache(monkeypatch: pytest.MonkeyPatch) -> None: +def _populate_pyinfo_cache(monkeypatch: pytest.MonkeyPatch, mocker: MockerFixture) -> None: from python_discovery._cached_py_info import _CACHE python_core_path = "C:\\Users\\user\\AppData\\Local\\Programs\\Python" @@ -150,14 +154,14 @@ def _populate_pyinfo_cache(monkeypatch: pytest.MonkeyPatch) -> None: ("CompanyA", 3, 6, 64, False, "Z:\\CompanyA\\Python\\3.6\\python.exe"), ] for _, major, minor, arch, threaded, exe in interpreters: - info = _mock_pyinfo(major, minor, arch, exe, threaded) + info = _mock_pyinfo(mocker, major, minor, arch, exe, threaded) monkeypatch.setitem(_CACHE, (Path(info.executable), None), info) -def _mock_pyinfo(major: int, minor: int, arch: int, exe: str, threaded: bool) -> MagicMock: - from python_discovery._py_info import VersionInfo +def _mock_pyinfo(mocker: MockerFixture, major: int, minor: int, arch: int, exe: str, threaded: bool) -> MockType: + from python_discovery._py_info import PythonInfo, VersionInfo - info = MagicMock(base_prefix=str(Path(exe).parent)) + info = mocker.MagicMock(spec=PythonInfo, base_prefix=str(Path(exe).parent)) info.executable = info.original_executable = info.system_executable = exe info.implementation = "CPython" info.architecture = arch diff --git a/tests/windows/test_windows_pep514.py b/tests/windows/test_windows_pep514.py index f2fc53c..d27c908 100644 --- a/tests/windows/test_windows_pep514.py +++ b/tests/windows/test_windows_pep514.py @@ -6,9 +6,9 @@ import subprocess import sys import textwrap +from contextlib import AbstractContextManager from pathlib import Path from typing import TYPE_CHECKING -from unittest.mock import MagicMock import pytest @@ -194,11 +194,13 @@ def test_pep514_rejects_non_absolute_executable( from python_discovery._windows import _pep514 as pep514 registered = {"ExecutablePath": executable_path, None: install_path} - mocker.patch.object(pep514.winreg, "OpenKeyEx", return_value=MagicMock()) + mocker.patch.object( + pep514.winreg, "OpenKeyEx", return_value=mocker.MagicMock(spec=AbstractContextManager), autospec=True + ) mocker.patch.object(pep514, "_get_value", side_effect=lambda _key, name: registered.get(name), autospec=True) mocker.patch.object(pep514.os.path, "exists", return_value=True, autospec=True) - assert pep514._load_exe("HKCU", "PythonCore", MagicMock(), "3.11") is None + assert pep514._load_exe("HKCU", "PythonCore", object(), "3.11") is None assert "expected an absolute executable path" in caplog.text @@ -206,13 +208,15 @@ def test_pep514_rejects_non_absolute_executable( def load_exe_with_args(mocker: MockerFixture) -> Callable[[object], tuple[str, str | None] | None]: from python_discovery._windows import _pep514 as pep514 - mocker.patch.object(pep514.winreg, "OpenKeyEx", return_value=MagicMock()) + mocker.patch.object( + pep514.winreg, "OpenKeyEx", return_value=mocker.MagicMock(spec=AbstractContextManager), autospec=True + ) mocker.patch.object(pep514, "_resolve_exe", return_value=r"C:\py.exe", autospec=True) mocker.patch.object(pep514.os.path, "exists", return_value=True, autospec=True) def load(registered: object) -> tuple[str, str | None] | None: mocker.patch.object(pep514, "_get_value", return_value=registered, autospec=True) - return pep514._load_exe("HKCU", "PythonCore", MagicMock(), "3.11") + return pep514._load_exe("HKCU", "PythonCore", object(), "3.11") return load From c7f99c3c8c03c7ea2d2164c9dbd1fea4d45bda95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Sun, 8 Nov 2026 11:25:00 -0800 Subject: [PATCH 23/25] =?UTF-8?q?=F0=9F=A7=AA=20test:=20give=20winreg=20fi?= =?UTF-8?q?xture=20and=20fakes=20precise=20types?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fake registry takes str, int and None values, so the fixtures say so instead of object. The mock values load through runpy, which returns the module's globals, so the loader needs no Any annotation. --- tests/windows/conftest.py | 9 +++------ tests/windows/test_windows_pep514.py | 10 ++++++---- 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/tests/windows/conftest.py b/tests/windows/conftest.py index b859498..4795737 100644 --- a/tests/windows/conftest.py +++ b/tests/windows/conftest.py @@ -1,6 +1,7 @@ from __future__ import annotations import os +import runpy import sys from contextlib import AbstractContextManager, contextmanager from functools import cache @@ -60,12 +61,8 @@ def _create_winreg_mock() -> ModuleType: def _load_registry_data(winreg: ModuleType) -> _RegistryData: - # Load winreg mock values using the given (possibly mock) winreg module. - loc: dict[str, object] = {} - glob: dict[str, object] = {"winreg": winreg} - mock_value_str = (Path(__file__).parent / "winreg_mock_values.py").read_text(encoding="utf-8") - exec(mock_value_str, glob, loc) # ruff:ignore[exec-builtin] the mock values are Python source kept in a separate file - return _RegistryData(loc["_ENUM_COLLECT"], loc["_VALUE_COLLECT"], loc["_KEY_OPEN"], loc["_HIVE_OPEN"]) # ty: ignore[invalid-return-type] the namespace is built by exec, so it has no static types + values = runpy.run_path(str(Path(__file__).parent / "winreg_mock_values.py"), init_globals={"winreg": winreg}) + return _RegistryData(values["_ENUM_COLLECT"], values["_VALUE_COLLECT"], values["_KEY_OPEN"], values["_HIVE_OPEN"]) class _RegistryData(NamedTuple): diff --git a/tests/windows/test_windows_pep514.py b/tests/windows/test_windows_pep514.py index d27c908..b156417 100644 --- a/tests/windows/test_windows_pep514.py +++ b/tests/windows/test_windows_pep514.py @@ -205,7 +205,7 @@ def test_pep514_rejects_non_absolute_executable( @pytest.fixture -def load_exe_with_args(mocker: MockerFixture) -> Callable[[object], tuple[str, str | None] | None]: +def load_exe_with_args(mocker: MockerFixture) -> Callable[[str | int | None], tuple[str, str | None] | None]: from python_discovery._windows import _pep514 as pep514 mocker.patch.object( @@ -214,7 +214,7 @@ def load_exe_with_args(mocker: MockerFixture) -> Callable[[object], tuple[str, s mocker.patch.object(pep514, "_resolve_exe", return_value=r"C:\py.exe", autospec=True) mocker.patch.object(pep514.os.path, "exists", return_value=True, autospec=True) - def load(registered: object) -> tuple[str, str | None] | None: + def load(registered: str | int | None) -> tuple[str, str | None] | None: mocker.patch.object(pep514, "_get_value", return_value=registered, autospec=True) return pep514._load_exe("HKCU", "PythonCore", object(), "3.11") @@ -232,14 +232,16 @@ def load(registered: object) -> tuple[str, str | None] | None: ], ) def test_pep514_loads_executable_arguments( - load_exe_with_args: Callable[[object], tuple[str, str | None] | None], registered: str | None, expected: str | None + load_exe_with_args: Callable[[str | int | None], tuple[str, str | None] | None], + registered: str | None, + expected: str | None, ) -> None: assert load_exe_with_args(registered) == (r"C:\py.exe", expected) @pytest.mark.usefixtures("_mock_registry") def test_pep514_rejects_non_string_executable_arguments( - load_exe_with_args: Callable[[object], tuple[str, str | None] | None], + load_exe_with_args: Callable[[str | int | None], tuple[str, str | None] | None], ) -> None: assert load_exe_with_args(42) is None From db6e90a3b3a25bfebac3c82813604bd072b19a46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Sun, 8 Nov 2026 11:27:14 -0800 Subject: [PATCH 24/25] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor(test):=20in?= =?UTF-8?q?line=20the=20registry=20fake's=20helpers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each registry lookup was a factory called once from the fixture, so the closures now sit in the fixture. A shared _key_value keeps the fixture under the complexity limit. --- tests/windows/conftest.py | 114 ++++++++++++++------------------------ 1 file changed, 43 insertions(+), 71 deletions(-) diff --git a/tests/windows/conftest.py b/tests/windows/conftest.py index 4795737..0525ae9 100644 --- a/tests/windows/conftest.py +++ b/tests/windows/conftest.py @@ -1,18 +1,19 @@ from __future__ import annotations +import importlib import os import runpy import sys -from contextlib import AbstractContextManager, contextmanager +from contextlib import contextmanager from functools import cache from pathlib import Path from types import ModuleType, TracebackType -from typing import TYPE_CHECKING, NamedTuple, Protocol +from typing import TYPE_CHECKING import pytest if TYPE_CHECKING: - from collections.abc import Callable, Generator + from collections.abc import Generator from pytest_mock import MockerFixture, MockType from typing_extensions import Self @@ -22,23 +23,48 @@ @pytest.fixture def _mock_registry(monkeypatch: pytest.MonkeyPatch) -> None: - if sys.platform != "win32": - winreg = _create_winreg_mock() - monkeypatch.setitem(sys.modules, "winreg", winreg) - else: - import winreg + winreg = _create_winreg_mock() if sys.platform != "win32" else importlib.import_module("winreg") + monkeypatch.setitem(sys.modules, "winreg", winreg) - enum_collect, value_collect, key_open, hive_open = _load_registry_data(winreg) + values = runpy.run_path(str(Path(__file__).parent / "winreg_mock_values.py"), init_globals={"winreg": winreg}) + enum_collect: dict[int, list[str | OSError]] = values["_ENUM_COLLECT"] + value_collect: dict[int, dict[str, tuple[str, int] | OSError]] = values["_VALUE_COLLECT"] + key_open: dict[int, dict[str, int]] = values["_KEY_OPEN"] + hive_open: dict[tuple[int, str, int, int], int | OSError] = values["_HIVE_OPEN"] + + def _enum_key(key: _Key | int, at: int) -> str: + result = enum_collect[_key_value(key)][at] + if isinstance(result, OSError): + raise result + return result - monkeypatch.setattr(winreg, "EnumKey", _make_enum_key(enum_collect)) - monkeypatch.setattr(winreg, "QueryValueEx", _make_query_value_ex(value_collect)) - monkeypatch.setattr(winreg, "OpenKeyEx", _make_open_key_ex(key_open, hive_open)) + @contextmanager + def _open_key_ex(*args: object) -> Generator[_Key | int | OSError]: + if len(args) == 2: + key, value = args + result = _Key(key_open[_key_value(key)][value]) + elif len(args) == 4: + result = hive_open[args] + else: + raise RuntimeError + value = result.value if isinstance(result, _Key) else result + if isinstance(value, OSError): + raise value + yield result + + def _query_value_ex(key: _Key | int, value_name: str) -> tuple[str, int]: + result = value_collect[_key_value(key)][value_name] + if isinstance(result, OSError): + raise result + return result + + monkeypatch.setattr(winreg, "EnumKey", _enum_key) + monkeypatch.setattr(winreg, "QueryValueEx", _query_value_ex) + monkeypatch.setattr(winreg, "OpenKeyEx", _open_key_ex) real_exists = os.path.exists def _mock_exists(path: str) -> bool: - if isinstance(path, str) and ("\\" in path or path.startswith(("C:", "Z:"))): - return True - return real_exists(path) + return (isinstance(path, str) and ("\\" in path or path.startswith(("C:", "Z:")))) or real_exists(path) monkeypatch.setattr("os.path.exists", _mock_exists) @@ -60,26 +86,8 @@ def _create_winreg_mock() -> ModuleType: return winreg -def _load_registry_data(winreg: ModuleType) -> _RegistryData: - values = runpy.run_path(str(Path(__file__).parent / "winreg_mock_values.py"), init_globals={"winreg": winreg}) - return _RegistryData(values["_ENUM_COLLECT"], values["_VALUE_COLLECT"], values["_KEY_OPEN"], values["_HIVE_OPEN"]) - - -class _RegistryData(NamedTuple): - enum_collect: dict[int, list[str | OSError]] - value_collect: dict[int, dict[str, tuple[str, int] | OSError]] - key_open: dict[int, dict[str, int]] - hive_open: dict[tuple[int, str, int, int], int | OSError] - - -def _make_enum_key(enum_collect: dict[int, list[str | OSError]]) -> Callable[[_Key | int, int], str]: - def _enum_key(key: _Key | int, at: int) -> str: - result = enum_collect[(key.value if isinstance(key, _Key) else key)][at] - if isinstance(result, OSError): - raise result - return result # ty: ignore[invalid-return-type] the namespace is built by exec, so it has no static types - - return _enum_key +def _key_value(key: _Key | int) -> int: + return key.value if isinstance(key, _Key) else key class _Key: @@ -95,42 +103,6 @@ def __exit__( return None -def _make_query_value_ex( - value_collect: dict[int, dict[str, tuple[str, int] | OSError]], -) -> Callable[[_Key | int, str], tuple[str, int]]: - def _query_value_ex(key: _Key | int, value_name: str) -> tuple[str, int]: - result = value_collect[(key.value if isinstance(key, _Key) else key)][value_name] - if isinstance(result, OSError): - raise result - return result - - return _query_value_ex - - -def _make_open_key_ex( - key_open: dict[int, dict[str, int]], hive_open: dict[tuple[int, str, int, int], int | OSError] -) -> _OpenKeyEx: - @contextmanager - def _open_key_ex(*args: object) -> Generator[_Key | int | OSError]: - if len(args) == 2: - key, value = args - result = _Key(key_open[(key.value if isinstance(key, _Key) else key)][value]) - elif len(args) == 4: - result = hive_open[args] - else: - raise RuntimeError - value = result.value if isinstance(result, _Key) else result - if isinstance(value, OSError): - raise value - yield result - - return _open_key_ex - - -class _OpenKeyEx(Protocol): - def __call__(self, *args: object) -> AbstractContextManager[_Key | int | OSError]: ... - - @pytest.fixture def _populate_pyinfo_cache(monkeypatch: pytest.MonkeyPatch, mocker: MockerFixture) -> None: from python_discovery._cached_py_info import _CACHE From c8b469e50a5819980b124ea84a7f46372df7e36c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Sun, 8 Nov 2026 11:28:24 -0800 Subject: [PATCH 25/25] =?UTF-8?q?=F0=9F=A7=AA=20test:=20drop=20branches=20?= =?UTF-8?q?and=20loops=20from=20test=20bodies?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CPython-only discovery assertion becomes its own test, skipped on other implementations, instead of an if around the assertion. The launcher probe moves into the skip condition, and the timeout cache test calls the query twice in a row rather than looping over two values. --- tests/test_discovery.py | 16 +++++++++++----- tests/test_query_timeout.py | 22 ++++++++++++++++------ tests/windows/test_windows_pep514.py | 13 +++++++++---- 3 files changed, 36 insertions(+), 15 deletions(-) diff --git a/tests/test_discovery.py b/tests/test_discovery.py index 4d8e8b6..b03ab33 100644 --- a/tests/test_discovery.py +++ b/tests/test_discovery.py @@ -3,6 +3,7 @@ import contextlib import logging import os +import platform import stat import sys from copy import deepcopy @@ -450,11 +451,16 @@ def test_discovery_via_version_specifier(session_cache: DiskCache) -> None: assert interpreter.version_info.major == major assert minor <= interpreter.version_info.minor < minor + 10 - spec = f"cpython>={major}.{minor}" - interpreter = get_interpreter(spec, [], session_cache) - if current.implementation == "CPython": # pragma: no branch, the branch depends on the host interpreter - assert interpreter is not None - assert interpreter.implementation == "CPython" + +@pytest.mark.skipif(platform.python_implementation() != "CPython", reason="the spec asserts a CPython host") +def test_discovery_via_cpython_specifier(session_cache: DiskCache) -> None: + current = PythonInfo.current_system(session_cache) + + interpreter = get_interpreter( + f"cpython>={current.version_info.major}.{current.version_info.minor}", [], session_cache + ) + assert interpreter is not None + assert interpreter.implementation == "CPython" _POSIX_PERMISSIONS: Final[pytest.MarkDecorator] = pytest.mark.skipif( diff --git a/tests/test_query_timeout.py b/tests/test_query_timeout.py index bf67f32..565f545 100644 --- a/tests/test_query_timeout.py +++ b/tests/test_query_timeout.py @@ -78,12 +78,22 @@ def test_from_exe_timeout_warning_cache_is_bounded(caplog: pytest.LogCaptureFixt assert result is not None assert result.version_info == sys.version_info caplog.clear() - for raw in ("invalid-timeout-128", "invalid-timeout-0"): - result = PythonInfo.from_exe( - sys.executable, env={**os.environ, "PY_DISCOVERY_TIMEOUT": raw}, ignore_cache=True, resolve_to_host=False - ) - assert result is not None - assert result.version_info == sys.version_info + result = PythonInfo.from_exe( + sys.executable, + env={**os.environ, "PY_DISCOVERY_TIMEOUT": "invalid-timeout-128"}, + ignore_cache=True, + resolve_to_host=False, + ) + assert result is not None + assert result.version_info == sys.version_info + result = PythonInfo.from_exe( + sys.executable, + env={**os.environ, "PY_DISCOVERY_TIMEOUT": "invalid-timeout-0"}, + ignore_cache=True, + resolve_to_host=False, + ) + assert result is not None + assert result.version_info == sys.version_info assert [record.getMessage() for record in caplog.records] == [ "ignoring PY_DISCOVERY_TIMEOUT='invalid-timeout-0', not a positive number of seconds; using 15.0" ] diff --git a/tests/windows/test_windows_pep514.py b/tests/windows/test_windows_pep514.py index b156417..23d5a13 100644 --- a/tests/windows/test_windows_pep514.py +++ b/tests/windows/test_windows_pep514.py @@ -246,7 +246,14 @@ def test_pep514_rejects_non_string_executable_arguments( assert load_exe_with_args(42) is None -@pytest.mark.skipif(sys.platform != "win32" or shutil.which("py") is None, reason="requires the Windows launcher") +def _launcher_selects_311() -> bool: + launcher = shutil.which("py") + return launcher is not None and subprocess.run([launcher, "-3.11", "-c", "pass"], check=False).returncode == 0 + + +@pytest.mark.skipif( + sys.platform != "win32" or not _launcher_selects_311(), reason="requires the Windows launcher with Python 3.11" +) def test_pep514_launcher_arguments_select_interpreter(mocker: MockerFixture, tmp_path: Path) -> None: from python_discovery import DiskCache, PythonSpec from python_discovery._windows import _propose @@ -256,9 +263,7 @@ def test_pep514_launcher_arguments_select_interpreter(mocker: MockerFixture, tmp script = ( "import json, struct, sys; print(json.dumps([sys.executable, *sys.version_info[:2], struct.calcsize('P') * 8]))" ) - probe = subprocess.run([launcher, "-3.11", "-c", script], capture_output=True, text=True, check=False) - if probe.returncode: - pytest.skip("the launcher cannot select 3.11") # ty: ignore[too-many-positional-arguments] # ty ships a stale pytest stub for 3.9 + probe = subprocess.run([launcher, "-3.11", "-c", script], capture_output=True, text=True, check=True) selected, major, minor, arch = json.loads(probe.stdout) mocker.patch.object( _propose,