diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index f838f33..2222e7f 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 @@ -43,15 +62,18 @@ 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: - id: docstrfmt + priority: 3 files: | (?x) ^docs/changelog[.]rst$ # Only the generated release history. @@ -62,7 +84,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/docs/conf.py b/docs/conf.py index 8cb145b..c297a7b 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] Sphinx reads the name `copyright` 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/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" diff --git a/src/python_discovery/__init__.py b/src/python_discovery/__init__.py index 85cd9d8..7b3c5aa 100644 --- a/src/python_discovery/__init__.py +++ b/src/python_discovery/__init__.py @@ -2,16 +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 -__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/_cache.py b/src/python_discovery/_cache.py index 541af3a..16a07b1 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: ``True`` if the content is cached, ``False`` otherwise. + """ ... 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: the cached content, or ``None``. + """ ... 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: a context manager; the lock is held inside its ``with`` block. + """ ... @@ -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: the content store for *path*. """ ... @@ -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: the content store for *path*. + """ + 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,14 +213,9 @@ 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] + 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": @@ -157,73 +229,12 @@ 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 - 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: @@ -233,13 +244,12 @@ 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 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..473fefe 100644 --- a/src/python_discovery/_cached_py_info.py +++ b/src/python_discovery/_cached_py_info.py @@ -18,10 +18,10 @@ 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 +from typing import TYPE_CHECKING, Final, NamedTuple from ._cache import NoOpCache from ._py_info import PythonInfo @@ -37,33 +37,23 @@ 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] +def from_exe( # ruff:ignore[too-many-arguments] each argument is a separate probe input cls: type[PythonInfo], cache: PyInfoCache | None, exe: str, @@ -90,14 +80,14 @@ 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, 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,22 +104,7 @@ 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] +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, @@ -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, @@ -274,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, @@ -317,7 +250,56 @@ def _run_subprocess( return None, result -def _collect_query_output(process: Popen[str], timeout: float | None) -> tuple[str, str, int]: +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) -> _QueryOutput: stdout, stderr = process.stdout, process.stderr if stdout is None or stderr is None: msg = "query output pipes are required" @@ -338,13 +320,17 @@ def _collect_query_output(process: Popen[str], timeout: float | None) -> tuple[s process.kill() # A descendant may keep the pipes open after the candidate exits. process.wait() - _stop_query_readers(readers) - return "", "timed out", -1 + 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] 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) @@ -355,27 +341,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 +356,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 +370,23 @@ 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 + + +class _QueryOutput(NamedTuple): + out: str + err: str + code: int def _query_failure(exe: str, out: str, err: str | None, code: int | None) -> RuntimeError: @@ -436,16 +403,52 @@ 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) -> _CookieSlice: + # 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 _CookieSlice(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 _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: + 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 +460,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..a2667ac 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,12 +41,13 @@ 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`. :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 +56,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], @@ -134,7 +68,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,30 +83,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 +91,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: @@ -205,6 +106,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], @@ -235,20 +150,29 @@ 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._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): 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 +184,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 +349,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 +359,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 +394,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 @@ -383,29 +414,50 @@ 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] - ): - 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("+") + 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 +492,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: each matching interpreter, in discovery order. + """ + 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] each argument is a separate spec input + 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..cf05729 100644 --- a/src/python_discovery/_py_info.py +++ b/src/python_discovery/_py_info.py @@ -44,8 +44,50 @@ def _get_path_extensions() -> list[str]: EXTENSIONS: Final[list[str]] = _get_path_extensions() -class PythonInfo: # ruff:ignore[too-many-public-methods] - """Contains information for a Python interpreter.""" +class PythonInfo: # ruff:ignore[too-many-public-methods] PythonInfo is the public query surface; splitting it breaks callers + """ + Contains information for a Python interpreter. + + :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 implementation: str @@ -82,108 +124,184 @@ 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()))) + self.__dict__.update(self.from_dict(PythonInfoCollector().to_dict()).__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: the interpreter information rebuilt from *data*. + :raises ValueError: if *data* lacks an annotated field. """ - 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 - @property - def version_str(self) -> str: - """The full version as ``major.minor.micro`` string (e.g. ``3.13.2``).""" - return ".".join(str(i) for i in self.version_info[0:3]) + @classmethod + def current(cls, cache: PyInfoCache | None = None) -> PythonInfo: + """ + Locate the current host interpreter information. - @property - def version_release_str(self) -> str: - """The release version as ``major.minor`` string (e.g. ``3.13``).""" - return ".".join(str(i) for i in self.version_info[0:2]) + :param cache: interpreter metadata cache; when ``None`` results are not cached. + :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) + if result is None: + msg = "failed to query current Python interpreter" + raise RuntimeError(msg) + cls._current = result + return cls._current - @property - def python_name(self) -> str: - """The python executable name as ``pythonX.Y`` (e.g. ``python3.13``).""" - version_info = self.version_info - return f"python{version_info.major}.{version_info.minor}" + @classmethod + def from_exe( # ruff:ignore[too-many-arguments] each argument is a separate probe input + 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. - @property - def is_old_virtualenv(self) -> bool: - """``True`` if this interpreter runs inside an old-style virtualenv (has ``real_prefix``).""" - return self.real_prefix is not None + :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: the interpreter information, or ``None`` when it cannot be queried. + """ + 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, + ) - @property - 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 + @classmethod + def _from_exe_with_args( # ruff:ignore[too-many-arguments] each argument is a separate probe input + 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] - 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. + 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, + ) - :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. + 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: """ - pattern = self.sysconfig_paths.get(key) - if pattern is None: - return "" - if any(format_spec for _, _, format_spec, _ in Formatter().parse(pattern)): - msg = f"sysconfig path {key!r} contains format specifiers" - raise ValueError(msg) - if config_var is None: - config_var = self.sysconfig_vars - else: - base = self.sysconfig_vars.copy() - base.update(config_var) - config_var = base - return pattern.format(**config_var).replace("/", sep) + Locate the current system interpreter information, resolving through any virtualenv layers. - @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 + :param cache: interpreter metadata cache; when ``None`` results are not cached. + :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) + if result is None: + msg = "failed to query current system Python interpreter" + raise RuntimeError(msg) + cls._current_system = result + return cls._current_system - @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 + @classmethod + def from_json(cls, payload: str) -> PythonInfo: + """ + Deserialize interpreter information from a JSON string. - @property - def system_exec_prefix(self) -> str: - """The exec prefix of the system Python this interpreter is based on.""" - return self.real_prefix or self.base_exec_prefix or self.exec_prefix + :param payload: JSON produced by :meth:`to_json`. + :returns: the interpreter information decoded from *payload*. + """ + return cls.from_dict(json.loads(payload).copy()) - @property - def system_exe(self) -> str: + @classmethod + def resolve_to_system( + cls, cache: PyInfoCache | None, target: PythonInfo, *, env: Mapping[str, str] | None = None + ) -> PythonInfo: """ - The executable of the system Python this interpreter is based on. + Walk virtualenv/venv prefix chains to find the underlying system interpreter. - :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. + :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: the system interpreter that *target* resolves to. + :raises RuntimeError: if the prefixes form a cycle. """ - return self.system_executable or self.executable + 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, (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) + 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( @@ -219,6 +337,18 @@ def __str__(self) -> str: ), ) + @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`.""" @@ -233,16 +363,107 @@ def machine(self) -> str: 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, + def version_str(self) -> str: + """The full version as ``major.minor.micro`` string (e.g. ``3.13.2``).""" + return ".".join(str(i) for i in self.version_info[0:3]) + + @property + def version_release_str(self) -> str: + """The release version as ``major.minor`` string (e.g. ``3.13``).""" + return ".".join(str(i) for i in self.version_info[0:2]) + + @property + def python_name(self) -> str: + """The python executable name as ``pythonX.Y`` (e.g. ``python3.13``).""" + version_info = self.version_info + return f"python{version_info.major}.{version_info.minor}" + + @property + def is_old_virtualenv(self) -> bool: + """``True`` if this interpreter runs inside an old-style virtualenv (has ``real_prefix``).""" + return self.real_prefix is not None + + @property + 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. + + :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: 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: + return "" + if any(format_spec for _, _, format_spec, _ in Formatter().parse(pattern)): + msg = f"sysconfig path {key!r} contains format specifiers" + raise ValueError(msg) + if config_var is None: + config_var = self.sysconfig_vars + else: + base = self.sysconfig_vars.copy() + base.update(config_var) + config_var = base + return pattern.format(**config_var).replace("/", sep) + + @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: 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 + # 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.""" + return self.real_prefix or self.base_exec_prefix or self.exec_prefix + + @property + def system_exe(self) -> str: + """ + The executable of the system Python this interpreter is based on. + + :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 @classmethod def clear_cache(cls, cache: PyInfoCache) -> None: @@ -251,17 +472,18 @@ 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. :param spec: the specification to check against. :param impl_must_match: when ``True``, the implementation name must match exactly. + :returns: ``True`` if this interpreter satisfies *spec*. """ if spec.path and not self._satisfies_path(spec): return False @@ -298,7 +520,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: @@ -313,7 +535,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}" @@ -321,182 +543,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: the JSON document. """ - 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: the interpreter information as a dictionary. """ - 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 +577,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: 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: @@ -520,8 +587,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,7 +604,56 @@ def discover_exe( msg = "failed to detect {} in {}".format("|".join(possible_names), os.pathsep.join(possible_folders)) raise RuntimeError(msg) - def _check_exe( # ruff:ignore[too-many-arguments] + 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] imported only when this path is walked + + 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: + 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] each argument is a separate probe input self, cache: PyInfoCache | None, folder: str, @@ -588,60 +704,24 @@ 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): + """ + 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 @@ -655,7 +735,7 @@ class MarkerEnvironment(TypedDict): sys_platform: str -KNOWN_ARCHITECTURES: frozenset[str] = frozenset({ +KNOWN_ARCHITECTURES: Final[frozenset[str]] = frozenset({ "arm64", "loongarch64", "ppc", @@ -680,6 +760,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: the architecture string as the platform reports it. + :returns: the normalized architecture name. """ low = isa.lower() return { @@ -697,7 +780,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..83fd59e 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} @@ -39,8 +40,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() @@ -57,7 +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 - self.architecture = 32 if struct.calcsize("P") == _32BIT_POINTER_SIZE 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 @@ -113,123 +113,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 +145,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 +177,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 +194,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 +216,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 @@ -341,11 +245,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 {} @@ -363,9 +267,103 @@ 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, 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: + 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, 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 + + 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.""" - data = {var: (getattr(self, var) if var != "_creators" else None) for var in vars(self)} + # Convert the collected information to a plain dictionary. + data = {var: (getattr(self, var) if var != "_creators" else None) for var in self.__dict__} data["version_info"] = self.version_info._asdict() return data @@ -394,6 +392,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..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 @@ -16,28 +16,26 @@ (?:-(?P[a-zA-Z0-9_.]+))? # ISA (e.g. arm64, x86_64, i86pc.64bit) $ """ -PATTERN = re.compile( - r""" +_PATTERN: Final[re.Pattern[str]] = re.compile( + 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 = re.compile( - r""" +_SPECIFIER_PATTERN: Final[re.Pattern[str]] = re.compile( + 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 @@ -46,30 +44,36 @@ InvalidVersion = ValueError -def _int_or_none(val: str | None) -> int | None: - return None if val is None else int(val) +class _VersionParts(NamedTuple): + major: int | None + minor: int | None + micro: int | None -def _parse_version_parts(version: str) -> tuple[int | None, int | None, 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) 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: - return versions[0], versions[1], versions[2] - if len(versions) == 2: # ruff:ignore[magic-value-comparison] - return versions[0], versions[1], None + if len(versions) == max_version_parts: + 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 _VersionParts(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 _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: - 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: @@ -98,7 +102,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), @@ -108,7 +112,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) @@ -132,11 +136,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. @@ -146,7 +152,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, @@ -180,6 +186,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: 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(): @@ -190,6 +197,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 +222,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: 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)), @@ -219,13 +243,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] one return per distinct satisfaction outcome + """ + Check if this spec is compatible with the given *spec* (e.g. PEP-514 on Windows). + + :param spec: the requirement to check against. + :returns: ``True`` if this spec is compatible with *spec*. + """ + 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 +312,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..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,14 +30,13 @@ _SPECIFIER_RE: Final[re.Pattern[str]] = re.compile( r""" ^ - (===|==|~=|!=|<=|>=|<|>) # operator + (?P===|==|~=|!=|<=|>=|<|>) # operator \s* - (.+) # version string + (?P.+) # version string $ """, re.VERBOSE, ) -_PRE_ORDER: Final[dict[str, int]] = {"a": 1, "b": 2, "rc": 3} @dataclass(**DC_KW) @@ -68,21 +67,23 @@ 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: the parsed version. + :raises ValueError: if *version_str* is not a valid version. """ stripped = version_str.strip() 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), ) @@ -94,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: @@ -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: @@ -153,13 +155,15 @@ 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: the parsed specifier. + :raises ValueError: if *spec_str* is not a valid specifier. """ stripped = spec_str.strip() 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: @@ -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: ``True`` if *version_str* satisfies this specifier. """ try: candidate = SimpleVersion.from_string(version_str) if isinstance(version_str, str) else version_str @@ -195,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 == "!=": @@ -204,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 == "~=": @@ -240,20 +259,6 @@ 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) class SimpleSpecifierSet: @@ -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: the parsed specifier set. """ 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: ``True`` if *version_str* satisfies every specifier. + """ + 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/__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/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..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""" @@ -29,9 +27,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, @@ -48,33 +46,43 @@ ) -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]: +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.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) + yield from _process_set(hive, hive_name, key, flags, default_arch) -def process_set( +def _process_set( hive: int, hive_name: str, key: str, @@ -82,85 +90,90 @@ 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] - for company in enum_keys(root_key): + 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 - 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] + 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] - for tag in enum_keys(company_key): - spec = process_tag(hive_name, company, company_key, tag, default_arch) - if spec is not None: + 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] - version = load_version_data(hive_name, company, tag, tag_key) - if version 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: - exe, args = exe_data - threaded = load_threaded(hive_name, company, tag, tag_key) - return company, major, minor, arch, threaded, exe, args - return None +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 + 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] +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): + 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") +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") 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: - 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] +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,32 +183,20 @@ def parse_arch(arch_str: Any) -> int: # ruff:ignore[any-type] raise ValueError(error) -def load_version_data( - hive_name: str, - company: str, - tag: str, - tag_key: Any, # ruff:ignore[any-type] -) -> 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] +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): - g1, g2, g3 = match.groups() - 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, + major, minor, micro = match["major"], match["minor"], match["micro"] + 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, ) error = f"invalid format {version_str}" else: @@ -203,26 +204,13 @@ 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: - 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}") - 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() - interpreters = [repr(spec) for spec in discover_pythons()] - sys.stdout.write("\n".join(sorted(interpreters))) + sys.stdout.write("\n".join(sorted([repr(tuple(spec)) for spec in _discover_pythons()]))) sys.stdout.write("\n") diff --git a/src/python_discovery/_windows/_propose.py b/src/python_discovery/_windows/_propose.py index 57846cb..b150b21 100644 --- a/src/python_discovery/_windows/_propose.py +++ b/src/python_discovery/_windows/_propose.py @@ -5,44 +5,39 @@ 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 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, cache: PyInfoCache | None, env: Mapping[str, str], ) -> Generator[PythonInfo, None, None]: - existing = list(discover_pythons()) + 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, ) + 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) 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, @@ -53,7 +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/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 8d4420e..af6e9df 100644 --- a/tests/py_info/test_py_info.py +++ b/tests/py_info/test_py_info.py @@ -23,15 +23,21 @@ from packaging.markers import Environment from pytest_mock import MockerFixture -IS_PYPY = PythonInfo.current_system().implementation == "PyPy" + from python_discovery import PyInfoCache -CURRENT = PythonInfo.current_system() +_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 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 +85,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}-{_OTHER_ARCHITECTURE}", ) - 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}{_UNTHREADED}", ) - matches = CURRENT.satisfies(parsed_spec, impl_must_match=True) + matches = _CURRENT.satisfies(parsed_spec, impl_must_match=True) assert matches is False @@ -125,13 +134,12 @@ 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) 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 @@ -155,7 +163,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 @@ -168,29 +176,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", ), ], ) @@ -207,7 +218,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 @@ -215,37 +226,34 @@ 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) - system_executable = CURRENT.system_executable + py_info = _make_py_info(info) + system_executable = _CURRENT.system_executable 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] - if pos == position: - selected = py_info + py_info.base_executable = str(path) # ty: ignore[unresolved-attribute] the test stub is a plain object, not a PythonInfo discovered_with_path[str(path)] = py_info 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: + 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) + 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 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 + 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]: @@ -262,7 +270,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") @@ -274,9 +282,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 @@ -302,40 +310,36 @@ 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 + 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" - 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, + _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() @@ -348,7 +352,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] @@ -359,18 +363,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 +382,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 +407,39 @@ 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) + mocker.patch("python_discovery._py_info.platform.machine", return_value=runtime_isa, autospec=True) assert info.machine == runtime_isa 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}" + 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": "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 "" - 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 + assert _CURRENT.satisfies(parsed_spec, impl_must_match=True) is True @pytest.mark.parametrize( @@ -448,7 +450,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 +458,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 +475,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 +534,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,11 +558,11 @@ 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] - for d, plat in zip(discovered, discovered_platforms): - d.sysconfig_platform = plat + discovered = [copy.deepcopy(_CURRENT) for _ in discovered_platforms] + 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] @@ -574,7 +575,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..8089e43 100644 --- a/tests/py_info/test_py_info_exe_based_of.py +++ b/tests/py_info/test_py_info_exe_based_of.py @@ -1,7 +1,9 @@ from __future__ import annotations +import contextlib import logging from pathlib import Path +from typing import TYPE_CHECKING, Final import pytest @@ -10,7 +12,12 @@ from python_discovery._discovery import IS_WIN from python_discovery._py_info import EXTENSIONS -CURRENT = PythonInfo.current() +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[Callable[[str], str]] = str.lower if not fs_is_case_sensitive() else str def _fs_supports_symlink() -> bool: @@ -19,29 +26,38 @@ 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 relative: # pragma: no branch - 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) -BASE = _discover_base_folders() +_BASE: Final[tuple[str, ...]] = _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", + [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", + [pytest.param(_CURRENT.implementation, id="current"), pytest.param("python", id="python")], +) def test_discover_ok( tmp_path: Path, suffix: str, @@ -56,27 +72,19 @@ 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 - dest = folder / name - Path(str(dest)).symlink_to(CURRENT.executable) - pyvenv = Path(CURRENT.executable).parents[1] / "pyvenv.cfg" - if pyvenv.exists(): # pragma: no branch + 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): (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() - 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 - 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..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 @@ -82,8 +84,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 +136,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 +150,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 +177,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: @@ -229,7 +227,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) @@ -237,7 +235,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) @@ -294,7 +292,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() @@ -303,8 +301,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 @@ -345,7 +342,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()) == [] @@ -372,8 +369,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_cache_recovery.py b/tests/test_cache_recovery.py index 5d332d5..f10ec93 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 @@ -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,7 @@ 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 @@ -66,9 +62,28 @@ def test_from_exe_recovers_invalid_system_executable( ) +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 + assert (json.loads(result.to_json()), json.loads(cache_file.read_text(encoding="utf-8"))) == ( + expected["content"], + expected, + ) + + +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/test_cached_py_info.py b/tests/test_cached_py_info.py index 3dae27e..2b85a73 100644 --- a/tests/test_cached_py_info.py +++ b/tests/test_cached_py_info.py @@ -12,11 +12,10 @@ 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 -from python_discovery import DiskCache, PythonInfo +from python_discovery import ContentStore, DiskCache, PythonInfo from python_discovery._cached_py_info import ( _MAX_QUERY_OUTPUT, LogCmd, @@ -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: @@ -48,8 +47,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 @@ -62,8 +60,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") @@ -74,10 +72,10 @@ 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 + pass # pragma: no cover, the cache file is absent on this path def test_run_subprocess_success() -> None: @@ -95,12 +93,26 @@ 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(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(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 mocker.MagicMock( + spec=Popen, stdout=streams[0], stderr=streams[1], returncode=code, **{"wait.return_value": code} + ) + + @pytest.mark.parametrize( "output", [ @@ -117,9 +129,12 @@ 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( + "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 @@ -127,32 +142,24 @@ 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 -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]) + 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") 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 @@ -160,7 +167,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) @@ -168,7 +177,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(mocker, "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) @@ -203,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)): - 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( @@ -243,8 +256,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() @@ -253,8 +266,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) @@ -268,33 +281,33 @@ 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(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: - store = MagicMock() - content = PythonInfo().to_dict() - result = _load_cached_py_info(PythonInfo, store, content) +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() +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() + store = mocker.MagicMock(spec=ContentStore) 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() @@ -333,8 +346,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)) @@ -347,6 +359,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) @@ -356,7 +369,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) @@ -366,23 +381,12 @@ 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) -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 +397,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 +412,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") @@ -533,17 +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]) + mocker.patch("python_discovery._cached_py_info.gen_cookie", side_effect=[start, end], autospec=True) info = PythonInfo() info.executable = _SELECTED - out = f"{start[::-1]}{info.to_json()}{end[::-1]}" - process = _mock_process(out, "", 0) - return mocker.patch("python_discovery._cached_py_info.Popen", return_value=process) + 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] @@ -566,6 +581,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) @@ -579,7 +595,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 d498fa9..b03ab33 100644 --- a/tests/test_discovery.py +++ b/tests/test_discovery.py @@ -1,14 +1,15 @@ from __future__ import annotations +import contextlib import logging import os +import platform import stat import sys from copy import deepcopy from pathlib import Path from subprocess import check_call, check_output -from typing import TYPE_CHECKING, Final -from unittest.mock import patch +from typing import TYPE_CHECKING, Final, Protocol from uuid import uuid4 import pytest @@ -17,10 +18,18 @@ 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 +_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 @pytest.mark.skipif(not Path(sys.executable).is_symlink() and not Path(sys.executable).is_file(), reason="no symlink") @@ -37,32 +46,24 @@ 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 - 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) - 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 + with contextlib.suppress(FileNotFoundError): (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 @@ -95,9 +96,9 @@ 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' 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,9 +110,9 @@ 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' if IS_WIN else ''}" + alias = tmp_path / f"Python{version}{_EXE_SUFFIX}" alias.symlink_to(sys.executable) selected = get_interpreter( @@ -173,8 +174,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 @@ -191,7 +191,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() @@ -209,8 +209,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) @@ -238,22 +237,22 @@ 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" / ("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)) 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"], - 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, @@ -293,8 +292,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 @@ -309,8 +308,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])]) @@ -323,7 +322,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", @@ -380,18 +379,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), - patch("python_discovery._discovery.os.defpath", f".{os.pathsep}{absolute_dir}"), - ): - assert list(get_paths({})) == [absolute_dir] + monkeypatch.delattr(os, "confstr", raising=False) + mocker.patch("python_discovery._discovery.os.defpath", f".{os.pathsep}{absolute_dir}") + assert list(get_paths({})) == [absolute_dir] @pytest.mark.parametrize( @@ -403,7 +402,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 +415,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}"})) @@ -429,11 +427,10 @@ 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) - 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 @@ -454,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 - 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( @@ -466,22 +468,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"), [ @@ -495,29 +481,46 @@ 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") - shim = root / "shims" / ("python2.7.exe" if IS_WIN else "python2.7") - shim.touch(mode=0o755) + (root / "shims" / _PYTHON27_NAME).touch(mode=0o755) 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) + + 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: + root = tmp_path / env_var.lower() + root.mkdir() + (root / "shims").mkdir() + return root - with patch("python_discovery._discovery.PathPythonInfo.from_exe") 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) + +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_SUFFIX}" + 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: +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 / ("python2.7.exe" if IS_WIN else "python2.7") + shim = shims_dir / _PYTHON27_NAME shim.touch(mode=0o755) monkeypatch.setenv("PATH", str(shims_dir)) @@ -525,32 +528,33 @@ 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: - 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" / ("python2.7.exe" if IS_WIN else "python2.7") + shim = root / "shims" / _PYTHON27_NAME shim.touch(mode=0o755) monkeypatch.setenv("PATH", str(root / "shims")) 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: - 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") - shim = root / "shims" / ("python2.7.exe" if IS_WIN else "python2.7") - shim.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")) @@ -558,10 +562,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") 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( @@ -575,7 +579,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") @@ -587,8 +595,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" / _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")) @@ -596,27 +603,28 @@ 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: - 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") - shim = root / "shims" / ("python2.7.exe" if IS_WIN else "python2.7") - shim.touch(mode=0o755) + (root / "shims" / _PYTHON27_NAME).touch(mode=0o755) monkeypatch.setenv("PATH", str(root / "shims")) 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: - 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( @@ -633,7 +641,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) @@ -645,7 +653,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( @@ -690,11 +698,11 @@ 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) - 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", []) @@ -703,12 +711,13 @@ 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") - shim = root / "shims" / ("python2.7.exe" if IS_WIN else "python2.7") - shim.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")) @@ -716,17 +725,16 @@ 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: - 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") - shim = root / "shims" / ("python2.7.exe" if IS_WIN else "python2.7") - shim.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() @@ -736,30 +744,30 @@ 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: - 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") 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" / _PYTHON27_NAME).touch(mode=0o755) monkeypatch.setenv("PATH", str(root / "shims")) 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: - 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: @@ -788,7 +796,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 @@ -803,7 +811,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 +822,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() @@ -824,10 +831,14 @@ 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]]: - """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 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) def _probed(**kwargs: object) -> list[str]: get_interpreter(_NO_UV_MATCH, [], **kwargs) @@ -841,10 +852,10 @@ 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")) + 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") @@ -872,7 +883,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], @@ -896,7 +907,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: @@ -922,7 +933,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, @@ -940,7 +951,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")) @@ -951,7 +962,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") @@ -970,7 +981,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: @@ -992,7 +1003,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, @@ -1005,7 +1016,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() == [] @@ -1013,11 +1024,11 @@ 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") + 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] @@ -1046,9 +1057,9 @@ 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) + 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)}) @@ -1064,7 +1075,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 2c17fd3..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,28 +18,26 @@ ) 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 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 + 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 @@ -48,11 +46,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( @@ -61,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 @@ -71,39 +65,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() - 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) + (tmp_path / "bad_file").touch() + 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"})) def test_active_versions_pyenv_version(monkeypatch: pytest.MonkeyPatch) -> None: @@ -173,8 +157,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,13 +171,12 @@ 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 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) @@ -205,11 +187,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..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 +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 @@ -20,7 +22,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() @@ -28,8 +30,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) @@ -42,9 +48,9 @@ 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) + 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)) @@ -133,11 +139,11 @@ 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)) - 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()) @@ -149,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( @@ -158,10 +164,10 @@ 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) + from_exe = mocker.patch("python_discovery._discovery.PathPythonInfo.from_exe", return_value=None, autospec=True) list(iter_interpreters()) @@ -174,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()) @@ -207,6 +212,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] @@ -216,12 +222,11 @@ 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)]), + autospec=True, ) assert list(iter_interpreters(cache=session_cache)) == [] @@ -237,11 +242,10 @@ 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) + mocker.patch("python_discovery._discovery.PathPythonInfo.from_exe", return_value=fake_info, autospec=True) assert fake_info in list(iter_interpreters(cache=session_cache)) @@ -297,7 +301,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} @@ -457,7 +463,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, @@ -469,9 +475,9 @@ 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 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") @@ -487,7 +493,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, @@ -509,15 +515,15 @@ 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 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.""" + # 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") @@ -526,6 +532,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 eda865f..4220b59 100644 --- a/tests/test_py_info_collect.py +++ b/tests/test_py_info_collect.py @@ -8,7 +8,8 @@ from itertools import takewhile from pathlib import Path from subprocess import run -from typing import TYPE_CHECKING +from types import ModuleType +from typing import TYPE_CHECKING, Final import pytest import vermin @@ -19,15 +20,14 @@ 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 = 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: - 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()) @@ -100,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, ) @@ -208,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) @@ -270,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: @@ -306,7 +311,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() @@ -320,30 +325,34 @@ 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: - 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 -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")]) @@ -360,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() @@ -378,7 +387,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() @@ -389,6 +400,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 ed68ec9..7bc629b 100644 --- a/tests/test_py_info_extra.py +++ b/tests/test_py_info_extra.py @@ -5,30 +5,28 @@ import os import sys from pathlib import Path -from typing import TYPE_CHECKING -from unittest.mock import MagicMock +from typing import TYPE_CHECKING, Final 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: from pytest_mock import MockerFixture -CURRENT = PythonInfo.current_system() +_CURRENT: Final[PythonInfo] = PythonInfo.current_system() 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 == "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: - mocker.patch("platform.python_implementation", return_value="GraalVM") + mocker.patch("platform.python_implementation", return_value="GraalVM", autospec=True) info = PythonInfo() assert info.implementation == "GraalPy" @@ -39,12 +37,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 +54,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 +74,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 +87,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,53 +95,50 @@ 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" - 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) @@ -151,17 +146,17 @@ 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" - 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 @@ -176,33 +171,33 @@ 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) assert result.system_executable == repeated.executable -def test_discover_exe_cache_hit() -> None: - info = copy.deepcopy(CURRENT) - cached = copy.deepcopy(CURRENT) +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(), 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] 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) @@ -210,38 +205,40 @@ 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: - info = copy.deepcopy(CURRENT) - result = info._check_exe(MagicMock(), str(tmp_path), "nonexistent", [], dict(os.environ), exact=True) +def test_check_exe_none_path(tmp_path: Path, mocker: MockerFixture) -> None: + info = copy.deepcopy(_CURRENT) + result = info._check_exe( + mocker.MagicMock(spec=PyInfoCache), 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 +264,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 +295,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 +307,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 +323,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 +334,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) @@ -368,35 +363,36 @@ 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) 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 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 @@ -404,56 +400,59 @@ 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 def test_check_exe_from_exe_returns_none(tmp_path: Path, mocker: MockerFixture) -> None: - info = copy.deepcopy(CURRENT) - exe = tmp_path / "python" - exe.touch() - mocker.patch.object(PythonInfo, "from_exe", return_value=None) - result = info._check_exe(MagicMock(), str(tmp_path), "python", [], dict(os.environ), exact=True) + info = copy.deepcopy(_CURRENT) + (tmp_path / "python").touch() + mocker.patch.object(PythonInfo, "from_exe", return_value=None, autospec=True) + result = info._check_exe( + mocker.MagicMock(spec=PyInfoCache), 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) - other.architecture = 32 if info.architecture == 64 else 64 - mocker.patch.object(PythonInfo, "from_exe", return_value=other) + info = copy.deepcopy(_CURRENT) + (tmp_path / "python").touch() + other = copy.deepcopy(_CURRENT) + 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(MagicMock(), str(tmp_path), "python", discovered, dict(os.environ), exact=False) + result = info._check_exe( + mocker.MagicMock(spec=PyInfoCache), str(tmp_path), "python", discovered, dict(os.environ), exact=False + ) assert result is None assert len(discovered) == 1 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) - other.architecture = 32 if info.architecture == 64 else 64 - mocker.patch.object(PythonInfo, "from_exe", return_value=other) + info = copy.deepcopy(_CURRENT) + (tmp_path / "python").touch() + other = copy.deepcopy(_CURRENT) + 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(MagicMock(), str(tmp_path), "python", discovered, dict(os.environ), exact=True) + result = info._check_exe( + mocker.MagicMock(spec=PyInfoCache), str(tmp_path), "python", discovered, dict(os.environ), exact=True + ) assert result is None assert len(discovered) == 0 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,10 +461,10 @@ 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) + 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()] @@ -474,10 +473,10 @@ 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) + 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 @@ -485,15 +484,15 @@ 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" - 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" @@ -502,10 +501,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) @@ -516,6 +515,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"}) @@ -525,21 +525,24 @@ 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( 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"}) @@ -548,11 +551,11 @@ 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" - 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" @@ -565,14 +568,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) + 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"): - 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 +588,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..5208797 100644 --- a/tests/test_py_spec.py +++ b/tests/test_py_spec.py @@ -29,12 +29,18 @@ 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 - content = vars(spec) - del content["str_spec"] - del content["path"] - assert all(v is None for v in content.values()) + assert (spec.str_spec, spec.path) == (text, text) + 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( @@ -82,8 +88,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: @@ -117,7 +122,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) @@ -170,9 +179,8 @@ def _version_not_satisfies_pairs() -> list[tuple[str, str]]: temp = sat_ver.copy() temp[patch] += offset if temp[patch] < 0: - continue # pragma: no cover - sat = ".".join(str(i) for i in temp) - target.add((req, sat)) + 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) @@ -193,10 +201,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: @@ -206,14 +214,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 +260,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 +277,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 @@ -335,7 +339,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/test_py_spec_extra.py b/tests/test_py_spec_extra.py index e8ab73f..b4ce4eb 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,9 +103,8 @@ 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) - assert pat.fullmatch(f"{name}.exe" if windows else name) is not None + pat = PythonSpec("", None, None, None, None, None, None).generate_re(windows=windows, all_implementations=True) + assert pat.fullmatch(f"{name}{'.exe' * windows}") is not None @pytest.mark.parametrize( @@ -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_query_timeout.py b/tests/test_query_timeout.py index 03b1255..565f545 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 @@ -80,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/test_specifier.py b/tests/test_specifier.py index dca3024..29f987b 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: @@ -44,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 @@ -69,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 @@ -83,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 @@ -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 @@ -209,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 @@ -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 @@ -273,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 ebce8b6..0525ae9 100644 --- a/tests/windows/conftest.py +++ b/tests/windows/conftest.py @@ -1,93 +1,48 @@ from __future__ import annotations +import importlib import os +import runpy import sys from contextlib import contextmanager +from functools import cache from pathlib import Path from types import ModuleType, TracebackType from typing import TYPE_CHECKING -from unittest.mock import MagicMock import pytest if TYPE_CHECKING: from collections.abc import Generator + from pytest_mock import MockerFixture, MockType from typing_extensions import Self + from python_discovery import PythonSpec -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] - return winreg - - -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], -]: - """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] - return loc["enum_collect"], loc["value_collect"], loc["key_open"], loc["hive_open"] # ty: ignore[invalid-return-type] - - -class _Key: - def __init__(self, value: object) -> None: - self.value = value - - def __enter__(self) -> Self: - return self - - def __exit__( - self, exc_type: type[BaseException] | None, exc_val: BaseException | None, exc_tb: TracebackType | None - ) -> None: - return None +@pytest.fixture +def _mock_registry(monkeypatch: pytest.MonkeyPatch) -> None: + winreg = _create_winreg_mock() if sys.platform != "win32" else importlib.import_module("winreg") + monkeypatch.setitem(sys.modules, "winreg", winreg) -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 - + 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 _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] + def _enum_key(key: _Key | int, at: int) -> str: + result = enum_collect[_key_value(key)][at] if isinstance(result, OSError): raise result return result - 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]: + def _open_key_ex(*args: object) -> Generator[_Key | int | OSError]: 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(key)][value]) elif len(args) == 4: result = hive_open[args] else: @@ -97,62 +52,59 @@ def _open_key_ex(*args: object) -> Generator[_Key | object]: raise value yield result - return _open_key_ex - - -@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) + 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", _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)) + 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) -def _mock_pyinfo(major: int, minor: int, arch: int, exe: str, threaded: bool = False) -> MagicMock: - from python_discovery._py_info import VersionInfo +# _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") + 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 + # 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 - info = MagicMock() - info.base_prefix = str(Path(exe).parent) - info.executable = info.original_executable = info.system_executable = exe - info.implementation = "CPython" - info.architecture = arch - info.version_info = VersionInfo(major, minor, 0, "final", 0) - info.free_threaded = threaded - 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: - if spec.implementation is not None and spec.implementation.lower() != "cpython": - return False - if spec.architecture is not None and spec.architecture != arch: - return False - if spec.free_threaded is not None and spec.free_threaded != threaded: - return False - if spec.major is not None and spec.major != major: - return False - return not (spec.minor is not None and spec.minor != minor) +def _key_value(key: _Key | int) -> int: + return key.value if isinstance(key, _Key) else key - info.satisfies = satisfies - return info + +class _Key: + def __init__(self, value: int) -> None: + self.value = value + + def __enter__(self) -> Self: + return self + + def __exit__( + self, exc_type: type[BaseException] | None, exc_val: BaseException | None, exc_tb: TracebackType | None + ) -> None: + return None @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" @@ -171,5 +123,32 @@ 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(mocker: MockerFixture, major: int, minor: int, arch: int, exe: str, threaded: bool) -> MockType: + from python_discovery._py_info import PythonInfo, VersionInfo + + 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 + info.version_info = VersionInfo(major, minor, 0, "final", 0) + info.free_threaded = threaded + info.sysconfig_platform = "win-amd64" if arch == 64 else "win32" + info.machine = "x86_64" if arch == 64 else "x86" + + 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: + return False + if spec.free_threaded is not None and spec.free_threaded != threaded: + return False + if spec.major is not None and spec.major != major: + return False + return not (spec.minor is not None and spec.minor != minor) + + info.satisfies = satisfies + return info diff --git a/tests/windows/test_windows.py b/tests/windows/test_windows.py index 65c0789..15dd58a 100644 --- a/tests/windows/test_windows.py +++ b/tests/windows/test_windows.py @@ -25,23 +25,38 @@ 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 python_discovery._windows import propose_interpreters + from python_discovery._windows._propose 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 diff --git a/tests/windows/test_windows_pep514.py b/tests/windows/test_windows_pep514.py index 7b95781..23d5a13 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 @@ -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), @@ -194,25 +194,29 @@ 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, "get_value", side_effect=lambda _key, name: registered.get(name)) - mocker.patch.object(pep514.os.path, "exists", return_value=True) + 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 @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(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.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) - return pep514.load_exe("HKCU", "PythonCore", MagicMock(), "3.11") + 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") return load @@ -228,19 +232,28 @@ 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 -@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 @@ -250,12 +263,13 @@ 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, "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( diff --git a/tests/windows/winreg_mock_values.py b/tests/windows/winreg_mock_values.py index e218180..aee5b20 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[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( @@ -8,7 +10,7 @@ "The system cannot find the file specified", ), } -key_open = { +_KEY_OPEN: Final[dict[int, dict[str, int]]] = { 78701152: { "Anaconda310-32\\InstallPath": 78703200, "Anaconda310-32": 78703568, @@ -44,7 +46,7 @@ "3.6": 88820000, }, } -value_collect = { +_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), @@ -143,7 +145,7 @@ "DisplayName": OSError(2, "The system cannot find the file specified"), }, } -enum_collect = { +_ENUM_COLLECT: Final[dict[int, list[str | OSError]]] = { 78701856: [ "ContinuumAnalytics", "PythonCore",