diff --git a/.github/workflows/check.yaml b/.github/workflows/check.yaml
new file mode 100644
index 0000000..a6ea1cd
--- /dev/null
+++ b/.github/workflows/check.yaml
@@ -0,0 +1,54 @@
+name: check
+on:
+ workflow_dispatch:
+ push:
+ branches: ["main"]
+ tags-ignore: ["**"]
+ pull_request:
+ schedule:
+ - cron: "0 8 * * *"
+
+concurrency:
+ group: check-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+ strategy:
+ fail-fast: false
+ matrix:
+ env:
+ - "3.14"
+ - "3.13"
+ - "3.12"
+ - "3.11"
+ - "3.10"
+ - "3.9"
+ - "3.8"
+ - type-3.8
+ - type-3.14
+ - dev
+ - pkg_meta
+ steps:
+ - uses: actions/checkout@v6
+ with:
+ fetch-depth: 0
+ - name: Install the latest version of uv
+ uses: astral-sh/setup-uv@v7
+ with:
+ enable-cache: true
+ cache-dependency-glob: "pyproject.toml"
+ github-token: ${{ secrets.GITHUB_TOKEN }}
+ - name: Install tox
+ run: uv tool install --python-preference only-managed --python 3.14 tox --with tox-uv
+ - name: Install Python
+ if: startsWith(matrix.env, '3.') && matrix.env != '3.14'
+ run: uv python install --python-preference only-managed ${{ matrix.env }}
+ - name: Setup test suite
+ run: tox run -vv --notest --skip-missing-interpreters false -e ${{ matrix.env }}
+ - name: Run test suite
+ run: tox run --skip-pkg-install -e ${{ matrix.env }}
+ env:
+ PYTEST_ADDOPTS: "-vv --durations=20"
+ DIFF_AGAINST: HEAD
diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml
deleted file mode 100644
index f717bf6..0000000
--- a/.github/workflows/check.yml
+++ /dev/null
@@ -1,91 +0,0 @@
-name: check
-on:
- workflow_dispatch:
- push:
- branches: "main"
- tags-ignore: ["**"]
- pull_request:
- schedule:
- - cron: "0 8 * * *"
-
-concurrency:
- group: check-${{ github.ref }}
- cancel-in-progress: true
-
-jobs:
- test:
- name: test ${{ matrix.py }} on ${{ matrix.os }}
- runs-on: ${{ matrix.os }}
- strategy:
- fail-fast: false
- matrix:
- py:
- - "3.12"
- - "3.11"
- - "3.10"
- - "3.9"
- - "3.8"
- - "3.7"
- - "pypy3.10"
- - "pypy3.7"
- os:
- - ubuntu-latest
- - windows-latest
- - macos-13
- steps:
- - name: Setup python for tox
- uses: actions/setup-python@v5
- with:
- python-version: "3.12"
- - uses: actions/checkout@v4
- with:
- fetch-depth: 0
- - name: Install tox
- run: python -m pip install tox
- - name: Setup python for test ${{ matrix.py }}
- uses: actions/setup-python@v5
- with:
- python-version: ${{ matrix.py }}
- - name: Setup test suite
- run: tox r -e ${{ matrix.py }} --skip-missing-interpreters false -vv --notest
- env:
- FORCE_COLOR: "1"
- - name: Run test suite
- run: tox r -e ${{ matrix.py }} --skip-missing-interpreters false --skip-pkg-install
- env:
- FORCE_COLOR: "1"
- PYTEST_ADDOPTS: "-vv --durations=20"
- CI_RUN: "yes"
- DIFF_AGAINST: HEAD
-
- check:
- name: tox env ${{ matrix.tox_env }} on ${{ matrix.os }}
- runs-on: ${{ matrix.os }}
- strategy:
- fail-fast: false
- matrix:
- tox_env:
- - type
- - dev
- - docs
- - pkg_meta
- os:
- - ubuntu-latest
- - windows-latest
- exclude:
- - { os: windows-latest, tox_env: pkg_meta } # would be the same
- - { os: ubuntu-latest, tox_env: docs } # runs on readthedocs.org already
- steps:
- - uses: actions/checkout@v4
- with:
- fetch-depth: 0
- - name: Setup Python 3.12
- uses: actions/setup-python@v5
- with:
- python-version: "3.12"
- - name: Install tox
- run: python -m pip install tox
- - name: Run check for ${{ matrix.tox_env }}
- run: tox -e ${{ matrix.tox_env }}
- env:
- UPGRADE_ADVISORY: "yes"
diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml
new file mode 100644
index 0000000..7b53b9f
--- /dev/null
+++ b/.github/workflows/release.yaml
@@ -0,0 +1,48 @@
+name: Release to PyPI
+on:
+ push:
+ tags: ["*"]
+
+env:
+ dists-artifact-name: python-package-distributions
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v6
+ with:
+ fetch-depth: 0
+ - name: Install the latest version of uv
+ uses: astral-sh/setup-uv@v7
+ with:
+ enable-cache: true
+ cache-dependency-glob: "pyproject.toml"
+ github-token: ${{ secrets.GITHUB_TOKEN }}
+ - name: Build package
+ run: uv build --python 3.14 --python-preference only-managed --sdist --wheel . --out-dir dist
+ - name: Store the distribution packages
+ uses: actions/upload-artifact@v6
+ with:
+ name: ${{ env.dists-artifact-name }}
+ path: dist/*
+
+ release:
+ needs:
+ - build
+ runs-on: ubuntu-latest
+ environment:
+ name: release
+ url: https://pypi.org/project/python-discovery/${{ github.ref_name }}
+ permissions:
+ id-token: write
+ steps:
+ - name: Download all the dists
+ uses: actions/download-artifact@v7
+ with:
+ name: ${{ env.dists-artifact-name }}
+ path: dist/
+ - name: Publish to PyPI
+ uses: pypa/gh-action-pypi-publish@v1.13.0
+ with:
+ attestations: true
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
deleted file mode 100644
index ff4d156..0000000
--- a/.github/workflows/release.yml
+++ /dev/null
@@ -1,27 +0,0 @@
-name: Release to PyPI
-on:
- push:
- tags: ["*"]
-
-jobs:
- release:
- runs-on: ubuntu-latest
- environment:
- name: release
- url: https://pypi.org/p/pyproject-api
- permissions:
- id-token: write
- steps:
- - name: Setup python to build package
- uses: actions/setup-python@v5
- with:
- python-version: "3.12"
- - name: Install build
- run: python -m pip install build
- - uses: actions/checkout@v4
- with:
- fetch-depth: 0
- - name: Build package
- run: pyproject-build -s -w . -o dist
- - name: Publish to PyPI
- uses: pypa/gh-action-pypi-publish@v1.10.1
diff --git a/.gitignore b/.gitignore
index f841c9c..78893fa 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,9 +1,5 @@
-*.py[cod]
-*.swp
-__pycache__
-/src/py_discovery/_version.py
-build
-dist
+*.pyc
*.egg-info
-.tox
-/.*_cache
+dist/
+.tox/
+/src/python_discovery/_version.py
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index be2509a..bde487e 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -1,29 +1,36 @@
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
- rev: v4.6.0
+ rev: v6.0.0
hooks:
- id: end-of-file-fixer
- id: trailing-whitespace
+ - repo: https://github.com/python-jsonschema/check-jsonschema
+ rev: 0.36.1
+ hooks:
+ - id: check-github-workflows
+ args: ["--verbose"]
- repo: https://github.com/codespell-project/codespell
- rev: v2.3.0
+ rev: v2.4.1
hooks:
- id: codespell
- args: ["--write-changes"]
- - repo: https://github.com/tox-dev/tox-ini-fmt
- rev: "1.3.1"
- hooks:
- - id: tox-ini-fmt
- args: ["-p", "fix"]
+ additional_dependencies: ["tomli>=2.4"]
- repo: https://github.com/tox-dev/pyproject-fmt
- rev: "2.2.1"
+ rev: "v2.11.1"
hooks:
- id: pyproject-fmt
- repo: https://github.com/astral-sh/ruff-pre-commit
- rev: "v0.6.3"
+ rev: "v0.14.14"
hooks:
- id: ruff-format
- id: ruff
args: ["--fix", "--unsafe-fixes", "--exit-non-zero-on-fix"]
+ - repo: https://github.com/rbubley/mirrors-prettier
+ rev: "v3.8.1"
+ hooks:
+ - id: prettier
+ additional_dependencies:
+ - prettier@3.8.1
+ - "@prettier/plugin-xml@3.4.2"
- repo: meta
hooks:
- id: check-hooks-apply
diff --git a/.readthedocs.yaml b/.readthedocs.yaml
new file mode 100644
index 0000000..60e9a43
--- /dev/null
+++ b/.readthedocs.yaml
@@ -0,0 +1,8 @@
+version: 2
+build:
+ os: ubuntu-lts-latest
+ tools: {}
+ commands:
+ - curl -LsSf https://astral.sh/uv/install.sh | sh
+ - ~/.local/bin/uv tool install tox --with tox-uv -p 3.14 --managed-python
+ - ~/.local/bin/tox run -e docs --
diff --git a/.readthedocs.yml b/.readthedocs.yml
index 8629196..e140f35 100644
--- a/.readthedocs.yml
+++ b/.readthedocs.yml
@@ -4,5 +4,5 @@ build:
tools:
python: "3.12"
commands:
- - pip install tox
- - tox r -e docs -- "${READTHEDOCS_OUTPUT}"/html
+ - pip install tox
+ - tox r -e docs -- "${READTHEDOCS_OUTPUT}"/html
diff --git a/README.md b/README.md
index 8b7e5c9..c440ecb 100644
--- a/README.md
+++ b/README.md
@@ -1,8 +1,8 @@
-# [`py-discovery`](https://py-discovery.readthedocs.io/en/latest/)
+# [`python-discovery`](https://python-discovery.readthedocs.io/en/latest/)
-[](https://pypi.org/project/py-discovery/)
+[](https://pypi.org/project/python-discovery/)
[](https://pypi.org/project/py-discovery/)
-[](https://pepy.tech/project/py-discovery)
-[](https://github.com/tox-dev/py-discovery/actions/workflows/check.yml)
-[](https://py-discovery.readthedocs.io/en/latest/?badge=latest)
+versions](https://img.shields.io/pypi/pyversions/python-discovery.svg)](https://pypi.org/project/python-discovery/)
+[](https://pepy.tech/project/python-discovery)
+[](https://github.com/tox-dev/python-discovery/actions/workflows/check.yml)
+[](https://python-discovery.readthedocs.io/en/latest/?badge=latest)
diff --git a/docs/_static/custom.css b/docs/_static/custom.css
new file mode 100644
index 0000000..f2e1419
--- /dev/null
+++ b/docs/_static/custom.css
@@ -0,0 +1,4 @@
+.sidebar-logo img {
+ max-width: 100%;
+ width: 100%;
+}
diff --git a/docs/_static/logo.svg b/docs/_static/logo.svg
new file mode 100644
index 0000000..78748e1
--- /dev/null
+++ b/docs/_static/logo.svg
@@ -0,0 +1,11 @@
+
diff --git a/docs/conf.py b/docs/conf.py
index 6773e5f..96dc78d 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -1,40 +1,48 @@
-# noqa: D100
+"""Sphinx configuration for python-discovery documentation."""
+
from __future__ import annotations
-from py_discovery import __version__
+from datetime import datetime, timezone
+
+from python_discovery import __version__
-project = name = "py_discovery"
company = "tox-dev"
-copyright = f"{company}" # noqa: A001
-version, release = __version__, __version__.split("+")[0]
+name = "python-discovery"
+version = ".".join(__version__.split(".")[:2])
+release = __version__
+copyright = f"2026-{datetime.now(tz=timezone.utc).year}, {company}" # noqa: A001
extensions = [
+ "sphinx.ext.autodoc",
"sphinx.ext.autosectionlabel",
"sphinx.ext.extlinks",
- "sphinx.ext.autodoc",
- "sphinx_autodoc_typehints",
- "sphinx.ext.viewcode",
"sphinx.ext.intersphinx",
+ "sphinx_autodoc_typehints",
+ "sphinxcontrib.mermaid",
]
-master_doc, source_suffix = "index", ".rst"
-
-html_theme = "furo"
-html_title, html_last_updated_fmt = "py-discovery docs", "%Y-%m-%dT%H:%M:%S"
-pygments_style, pygments_dark_style = "sphinx", "monokai"
-
-autoclass_content, autodoc_typehints = "both", "none"
-autodoc_default_options = {"members": True, "member-order": "bysource", "undoc-members": True, "show-inheritance": True}
-inheritance_alias = {}
-extlinks = {
- "issue": ("https://github.com/tox-dev/py-discovery/issues/%s", "#%s"),
- "pull": ("https://github.com/tox-dev/py-discovery/pull/%s", "PR #%s"),
- "user": ("https://github.com/%s", "@%s"),
-}
intersphinx_mapping = {
"python": ("https://docs.python.org/3", None),
- "packaging": ("https://packaging.pypa.io/en/latest", None),
}
-nitpicky = True
-nitpick_ignore = []
+templates_path = []
+source_suffix = ".rst"
+exclude_patterns = ["_build"]
+
+main_doc = "index"
+pygments_style = "default"
+always_document_param_types = True
+project = name
+
+html_theme = "furo"
+html_title = project
+html_last_updated_fmt = datetime.now(tz=timezone.utc).isoformat()
+pygments_dark_style = "monokai"
+html_show_sourcelink = False
+html_static_path = ["_static"]
+html_theme_options = {
+ "light_logo": "logo.svg",
+ "dark_logo": "logo.svg",
+ "sidebar_hide_name": True,
+}
+html_css_files = ["custom.css"]
diff --git a/docs/explanation.rst b/docs/explanation.rst
new file mode 100644
index 0000000..9b219d2
--- /dev/null
+++ b/docs/explanation.rst
@@ -0,0 +1,153 @@
+How it works
+============
+
+Where does python-discovery look?
+-------------------------------
+
+When you call :func:`~python_discovery.get_interpreter`, the library checks several locations in
+order. It stops as soon as it finds an interpreter that matches your spec.
+
+.. mermaid::
+
+ flowchart TD
+ Start["get_interpreter()"] --> AbsPath{"Is spec an
absolute path?"}
+ AbsPath -->|Yes| TryAbs["Use path directly"]
+ AbsPath -->|No| TryFirst["try_first_with paths"]
+ TryFirst --> RelPath{"Is spec a
relative path?"}
+ RelPath -->|Yes| TryRel["Resolve relative to cwd"]
+ RelPath -->|No| Current["Current interpreter"]
+ Current --> Win{"Windows?"}
+ Win -->|Yes| PEP514["PEP 514 registry"]
+ Win -->|No| PATH
+ PEP514 --> PATH["PATH search"]
+ PATH --> Shims["Version-manager shims
(pyenv / mise / asdf)"]
+ Shims --> UV["uv-managed Pythons"]
+
+ TryAbs --> Verify
+ TryRel --> Verify
+ UV --> Verify
+
+ Verify{{"Verify candidate
(subprocess call)"}}
+ Verify -->|Matches spec| Cache["Cache and return"]
+ Verify -->|No match| Next["Try next candidate"]
+
+ style Start fill:#4a90d9,stroke:#2a5f8f,color:#fff
+ style Verify fill:#d9904a,stroke:#8f5f2a,color:#fff
+ style Cache fill:#4a9f4a,stroke:#2a6f2a,color:#fff
+ style Next fill:#d94a4a,stroke:#8f2a2a,color:#fff
+
+Each candidate is verified by running it as a subprocess and collecting its metadata (version,
+architecture, platform, sysconfig values, etc.). This subprocess call is the expensive part, which
+is why results are cached.
+
+How version-manager shims are handled
+-----------------------------------------
+
+Version managers like `pyenv `_ install thin wrapper scripts called
+**shims** (e.g., ``~/.pyenv/shims/python3.12``) that redirect to the real interpreter. python-discovery
+detects these shims and resolves them to the actual binary.
+
+.. mermaid::
+
+ flowchart TD
+ Shim["Shim detected"] --> EnvVar{"PYENV_VERSION
set?"}
+ EnvVar -->|Yes| Use["Use that version"]
+ EnvVar -->|No| File{".python-version
file exists?"}
+ File -->|Yes| Use
+ File -->|No| Global{"pyenv global
version exists?"}
+ Global -->|Yes| Use
+ Global -->|No| Skip["Skip shim"]
+
+ style Shim fill:#4a90d9,stroke:#2a5f8f,color:#fff
+ style Use fill:#4a9f4a,stroke:#2a6f2a,color:#fff
+ style Skip fill:#d94a4a,stroke:#8f2a2a,color:#fff
+
+`mise `_ and `asdf `_ work similarly, using the
+``MISE_DATA_DIR`` and ``ASDF_DATA_DIR`` environment variables to locate their installations.
+
+How caching works
+-------------------
+
+Querying an interpreter requires a subprocess call, which is slow. The cache avoids repeating this
+work by storing the result as a JSON file keyed by the interpreter's path.
+
+.. mermaid::
+
+ flowchart TD
+ Lookup["py_info(path)"] --> Exists{"Cache hit?"}
+ Exists -->|Yes| Read["Read JSON"]
+ Exists -->|No| Run["Run subprocess"]
+ Run --> Write["Write JSON
(with filelock)"]
+ Write --> Return["Return PythonInfo"]
+ Read --> Return
+
+ style Lookup fill:#4a90d9,stroke:#2a5f8f,color:#fff
+ style Return fill:#4a9f4a,stroke:#2a6f2a,color:#fff
+ style Run fill:#d9904a,stroke:#8f5f2a,color:#fff
+
+The built-in :class:`~python_discovery.DiskCache` stores files under ``/py_info/4/.json``
+with `filelock `_-based locking for safe concurrent access. You
+can also pass ``cache=None`` to disable caching, or implement your own backend (see
+:doc:`/how-to/standalone-usage`).
+
+Spec format reference
+-----------------------
+
+A spec string follows the pattern ``[impl][version][t][-arch][-machine]``. Every part is optional.
+
+.. mermaid::
+
+ flowchart TD
+ Spec["Spec string"] --> Impl["impl
(optional)"]
+ Impl --> Version["version
(optional)"]
+ Version --> T["t
(optional)"]
+ T --> Arch["-arch
(optional)"]
+ Arch --> Machine["-machine
(optional)"]
+
+ style Impl fill:#4a90d9,stroke:#2a5f8f,color:#fff
+ style Version fill:#4a9f4a,stroke:#2a6f2a,color:#fff
+ style T fill:#d9904a,stroke:#8f5f2a,color:#fff
+ style Arch fill:#d94a4a,stroke:#8f2a2a,color:#fff
+ style Machine fill:#904ad9,stroke:#5f2a8f,color:#fff
+
+**Parts explained:**
+
+- **impl** -- the Python implementation name. ``python`` and ``py`` both mean "any implementation"
+ (usually CPython). Use ``cpython``, ``pypy``, or ``graalpy`` to be explicit.
+- **version** -- dotted version number (``3``, ``3.12``, or ``3.12.1``). You can also write
+ ``312`` as shorthand for ``3.12``.
+- **t** -- appended directly after the version. Matches free-threaded (no-GIL) builds only.
+- **-arch** -- ``-32`` or ``-64`` for 32-bit or 64-bit interpreters.
+- **-machine** -- the CPU instruction set: ``-arm64``, ``-x86_64``, ``-aarch64``, ``-riscv64``, etc.
+
+**Full examples:**
+
+.. list-table::
+ :header-rows: 1
+ :widths: 30 70
+
+ * - Spec
+ - Meaning
+ * - ``3.12``
+ - Any Python 3.12
+ * - ``python3.12``
+ - CPython 3.12
+ * - ``cpython3.12``
+ - Explicitly CPython 3.12
+ * - ``pypy3.9``
+ - PyPy 3.9
+ * - ``python3.13t``
+ - Free-threaded (no-GIL) CPython 3.13
+ * - ``python3.12-64``
+ - 64-bit CPython 3.12
+ * - ``python3.12-64-arm64``
+ - 64-bit CPython 3.12 on ARM64
+ * - ``/usr/bin/python3``
+ - Absolute path, used directly (no search)
+ * - ``>=3.11,<3.13``
+ - :pep:`440` version specifier (any Python in range)
+ * - ``cpython>=3.11``
+ - :pep:`440` specifier restricted to CPython
+
+:pep:`440` specifiers (``>=``, ``<=``, ``~=``, ``!=``, ``==``, ``===``) are supported. Multiple
+specifiers can be comma-separated, for example ``>=3.11,<3.13``.
diff --git a/docs/how-to/standalone-usage.rst b/docs/how-to/standalone-usage.rst
new file mode 100644
index 0000000..576de3e
--- /dev/null
+++ b/docs/how-to/standalone-usage.rst
@@ -0,0 +1,143 @@
+How-to guides
+=============
+
+Search specific directories first
+-----------------------------------
+
+If you know a likely location for the interpreter, pass it via ``try_first_with`` to check there
+before the normal search. This is useful when you have a custom Python install outside the
+standard locations.
+
+.. code-block:: python
+
+ from python_discovery import get_interpreter
+
+ info = get_interpreter("python3.12", try_first_with=["/opt/python/bin"])
+ if info is not None:
+ print(info.executable)
+
+Restrict the search environment
+---------------------------------
+
+By default, python-discovery reads environment variables like ``PATH`` and ``PYENV_ROOT`` from your
+shell. You can override these to control exactly where the library looks.
+
+.. mermaid::
+
+ flowchart TD
+ Env["Custom env dict"] --> Call["get_interpreter(spec, env=env)"]
+ Call --> PATH["PATH"]
+ Call --> Pyenv["PYENV_ROOT"]
+ Call --> UV["UV_PYTHON_INSTALL_DIR"]
+ Call --> Mise["MISE_DATA_DIR"]
+
+ style Env fill:#4a90d9,stroke:#2a5f8f,color:#fff
+
+.. code-block:: python
+
+ import os
+
+ from python_discovery import get_interpreter
+
+ env = {**os.environ, "PATH": "/usr/local/bin:/usr/bin"}
+ result = get_interpreter("python3.12", env=env)
+
+Read interpreter metadata
+---------------------------
+
+Once you have a :class:`~python_discovery.PythonInfo`, you can inspect everything about the interpreter.
+
+.. mermaid::
+
+ classDiagram
+ class PythonInfo {
+ +executable: str
+ +system_executable: str
+ +implementation: str
+ +version_info: VersionInfo
+ +architecture: int
+ +platform: str
+ +sysconfig_vars: dict
+ +sysconfig_paths: dict
+ +machine: str
+ +free_threaded: bool
+ }
+
+.. code-block:: python
+
+ from pathlib import Path
+
+ from python_discovery import DiskCache, get_interpreter
+
+ cache = DiskCache(root=Path("~/.cache/python-discovery").expanduser())
+ info = get_interpreter("python3.12", cache=cache)
+
+ info.executable # Resolved path to the binary.
+ info.system_executable # The underlying system interpreter (outside any venv).
+ info.implementation # "CPython", "PyPy", "GraalPy", etc.
+ info.version_info # VersionInfo(major, minor, micro, releaselevel, serial).
+ info.architecture # 64 or 32.
+ info.platform # sys.platform value ("linux", "darwin", "win32").
+ info.machine # ISA: "arm64", "x86_64", etc.
+ info.free_threaded # True if this is a no-GIL build.
+ info.sysconfig_vars # All sysconfig.get_config_vars() values.
+ info.sysconfig_paths # All sysconfig.get_paths() values.
+
+Implement a custom cache backend
+-----------------------------------
+
+The built-in :class:`~python_discovery.DiskCache` stores results as JSON files with
+`filelock `_-based locking. If you need a different storage
+strategy (e.g., in-memory, database-backed), implement the :class:`~python_discovery.PyInfoCache`
+protocol.
+
+.. mermaid::
+
+ classDiagram
+ class PyInfoCache {
+ <>
+ +py_info(path) ContentStore
+ +py_info_clear() None
+ }
+ class ContentStore {
+ <>
+ +exists() bool
+ +read() dict | None
+ +write(content) None
+ +remove() None
+ +locked() context
+ }
+ class DiskCache {
+ +root: Path
+ }
+ PyInfoCache <|.. DiskCache
+ PyInfoCache --> ContentStore
+
+.. code-block:: python
+
+ from pathlib import Path
+
+ from python_discovery import ContentStore, PyInfoCache
+
+
+ class MyContentStore:
+ def __init__(self, path: Path) -> None:
+ self._path = path
+
+ def exists(self) -> bool: ...
+
+ def read(self) -> dict | None: ...
+
+ def write(self, content: dict) -> None: ...
+
+ def remove(self) -> None: ...
+
+ def locked(self): ...
+
+
+ class MyCache:
+ def py_info(self, path: Path) -> MyContentStore: ...
+
+ def py_info_clear(self) -> None: ...
+
+Any object that matches the protocol works -- no inheritance required.
diff --git a/docs/index.rst b/docs/index.rst
index c7ded2c..4f0bd7b 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -1,20 +1,48 @@
-``py-discovery``
-=================
+python-discovery
+============
-``py-discovery`` aims to abstract away discovering Python interpreters on a user machine.
+You may have multiple Python versions installed on your machine -- system Python, versions from
+`pyenv `_, `mise `_,
+`asdf `_, `uv `_, or the Windows registry
+(:pep:`514`). ``python-discovery`` finds the right one for you.
-API
-+++
+Give it a requirement like ``python3.12`` or ``>=3.11,<3.13``, and it searches all known locations,
+verifies each candidate, and returns detailed metadata about the match. Results are cached to disk so
+repeated lookups are fast.
-.. currentmodule:: py_discovery
+.. code-block:: python
-.. autodata:: __version__
+ from pathlib import Path
-.. automodule:: py_discovery
- :members:
- :undoc-members:
+ from python_discovery import DiskCache, get_interpreter
+
+ cache = DiskCache(root=Path("~/.cache/python-discovery").expanduser())
+ result = get_interpreter("python3.12", cache=cache)
+ if result is not None:
+ print(result.executable) # /usr/bin/python3.12
+ print(result.implementation) # CPython
+ print(result.version_info[:3]) # (3, 12, 1)
+
+.. toctree::
+ :caption: Tutorials
+ :hidden:
+
+ tutorial/getting-started
+
+.. toctree::
+ :caption: How-to Guides
+ :hidden:
+
+ how-to/standalone-usage
+
+.. toctree::
+ :caption: Reference
+ :hidden:
+
+ reference/api
.. toctree::
+ :caption: Explanation
:hidden:
- self
+ explanation
diff --git a/docs/reference/api.rst b/docs/reference/api.rst
new file mode 100644
index 0000000..eae8565
--- /dev/null
+++ b/docs/reference/api.rst
@@ -0,0 +1,7 @@
+API reference
+=============
+
+.. automodule:: python_discovery
+ :members:
+ :undoc-members:
+ :show-inheritance:
diff --git a/docs/tutorial/getting-started.rst b/docs/tutorial/getting-started.rst
new file mode 100644
index 0000000..6d59713
--- /dev/null
+++ b/docs/tutorial/getting-started.rst
@@ -0,0 +1,180 @@
+Getting started
+===============
+
+Installation
+------------
+
+.. code-block:: console
+
+ pip install python-discovery
+
+Core concepts
+-------------
+
+Before diving into code, here are the key ideas:
+
+- **Interpreter** -- a Python executable on your system (e.g., ``/usr/bin/python3.12``).
+- **Spec** -- a short string describing what you are looking for (e.g., ``python3.12``, ``pypy3.9``, ``>=3.11``).
+- **Discovery** -- the process of searching your system for an interpreter that matches a spec.
+- **Cache** -- a disk store that remembers previously discovered interpreters so the next lookup is instant.
+
+Inspecting the current interpreter
+------------------------------------
+
+The simplest use case: get information about the Python that is running right now.
+
+.. mermaid::
+
+ flowchart TD
+ Call["PythonInfo.current_system(cache)"] --> Info["PythonInfo"]
+ Info --> Exe["executable: /usr/bin/python3.12"]
+ Info --> Ver["version_info: (3, 12, 1)"]
+ Info --> Impl["implementation: CPython"]
+ Info --> Arch["architecture: 64"]
+
+ style Call fill:#4a90d9,stroke:#2a5f8f,color:#fff
+ style Info fill:#4a9f4a,stroke:#2a6f2a,color:#fff
+
+.. code-block:: python
+
+ from pathlib import Path
+
+ from python_discovery import DiskCache, PythonInfo
+
+ cache = DiskCache(root=Path("~/.cache/python-discovery").expanduser())
+ info = PythonInfo.current_system(cache)
+
+ print(info.executable) # /usr/bin/python3.12
+ print(info.version_info[:3]) # (3, 12, 1)
+ print(info.implementation) # CPython (or PyPy, GraalPy, etc.)
+ print(info.architecture) # 64 (or 32)
+
+The returned :class:`~python_discovery.PythonInfo` object contains everything the library knows about that interpreter:
+paths, version numbers, sysconfig variables, platform details, and more.
+
+Finding a different interpreter
+--------------------------------
+
+Usually you need a *specific* Python version, not the one currently running. Pass a **spec** string
+to :func:`~python_discovery.get_interpreter` to search your system.
+
+.. mermaid::
+
+ flowchart TD
+ Spec["Spec: python3.12"] --> Call["get_interpreter(spec, cache)"]
+ Call --> Found{"Match found?"}
+ Found -->|Yes| Info["PythonInfo with full metadata"]
+ Found -->|No| Nil["None"]
+
+ style Spec fill:#4a90d9,stroke:#2a5f8f,color:#fff
+ style Info fill:#4a9f4a,stroke:#2a6f2a,color:#fff
+ style Nil fill:#d94a4a,stroke:#8f2a2a,color:#fff
+
+.. code-block:: python
+
+ from pathlib import Path
+
+ from python_discovery import DiskCache, get_interpreter
+
+ cache = DiskCache(root=Path("~/.cache/python-discovery").expanduser())
+ result = get_interpreter("python3.12", cache=cache)
+ if result is not None:
+ print(result.executable)
+
+You can pass multiple specs as a list -- the library tries each one in order and returns the first match.
+
+.. code-block:: python
+
+ result = get_interpreter(["python3.12", "python3.11"], cache=cache)
+
+Writing specs
+-------------
+
+A spec tells python-discovery what to look for. The simplest form is just a version number like ``3.12``.
+You can add more constraints to narrow the search.
+
+.. mermaid::
+
+ flowchart TD
+ Spec["Spec string"] --> Impl["impl
(optional)"]
+ Impl --> Version["version
(optional)"]
+ Version --> T["t
(optional)"]
+ T --> Arch["-arch
(optional)"]
+ Arch --> Machine["-machine
(optional)"]
+
+ style Impl fill:#4a90d9,stroke:#2a5f8f,color:#fff
+ style Version fill:#4a9f4a,stroke:#2a6f2a,color:#fff
+ style T fill:#d9904a,stroke:#8f5f2a,color:#fff
+ style Arch fill:#d94a4a,stroke:#8f2a2a,color:#fff
+ style Machine fill:#904ad9,stroke:#5f2a8f,color:#fff
+
+Common examples:
+
+.. list-table::
+ :header-rows: 1
+ :widths: 30 70
+
+ * - Spec
+ - What it matches
+ * - ``3.12``
+ - Any Python 3.12 (CPython, PyPy, etc.)
+ * - ``python3.12``
+ - CPython 3.12 (``python`` means CPython)
+ * - ``pypy3.9``
+ - PyPy 3.9
+ * - ``python3.13t``
+ - Free-threaded (no-GIL) CPython 3.13
+ * - ``python3.12-64``
+ - 64-bit CPython 3.12
+ * - ``python3.12-64-arm64``
+ - 64-bit CPython 3.12 on ARM64 hardware
+ * - ``/usr/bin/python3``
+ - An absolute path, used directly without searching
+ * - ``>=3.11,<3.13``
+ - Any Python in the 3.11--3.12 range (:pep:`440` syntax)
+
+See the :doc:`full spec reference ` for all options.
+
+Parsing a spec
+--------------
+
+You can parse a spec string into its components without searching the system. This is useful for
+inspecting what a spec means or for building tools on top of python-discovery.
+
+.. mermaid::
+
+ flowchart TD
+ Input["cpython3.12t-64-arm64"] --> Parse["PythonSpec.from_string_spec()"]
+ Parse --> Spec["PythonSpec"]
+ Spec --> impl["implementation: cpython"]
+ Spec --> ver["major: 3, minor: 12"]
+ Spec --> ft["free_threaded: True"]
+ Spec --> arch["architecture: 64"]
+ Spec --> mach["machine: arm64"]
+
+ style Input fill:#4a90d9,stroke:#2a5f8f,color:#fff
+ style Spec fill:#4a9f4a,stroke:#2a6f2a,color:#fff
+
+.. code-block:: python
+
+ from python_discovery import PythonSpec
+
+ spec = PythonSpec.from_string_spec("cpython3.12t-64-arm64")
+ spec.implementation # "cpython"
+ spec.major # 3
+ spec.minor # 12
+ spec.free_threaded # True
+ spec.architecture # 64
+ spec.machine # "arm64"
+
+Skipping the cache
+------------------
+
+If you only need to discover once and do not want to write anything to disk, pass ``cache=None``.
+Every call will run a subprocess to query the interpreter, so this is slower for repeated lookups.
+
+.. code-block:: python
+
+ from python_discovery import get_interpreter
+
+ result = get_interpreter("python3.12")
diff --git a/pyproject.toml b/pyproject.toml
index b1c392a..ab8d286 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,76 +1,73 @@
[build-system]
build-backend = "hatchling.build"
requires = [
- "hatch-vcs>=0.3",
- "hatchling>=1.17.1",
+ "hatch-vcs>=0.5",
+ "hatchling>=1.28",
]
[project]
-name = "py-discovery"
-description = "API to interact with the python pyproject.toml based projects"
-readme.content-type = "text/markdown"
-readme.file = "README.md"
+name = "python-discovery"
+description = "Python interpreter discovery"
+readme = "README.md"
keywords = [
- "environments",
- "isolated",
- "testing",
- "virtual",
+ "discovery",
+ "interpreter",
+ "python",
]
-license = "MIT"
+license.file = "LICENSE"
maintainers = [
{ name = "Bernát Gábor", email = "gaborjbernat@gmail.com" },
]
-authors = [
- { name = "Bernát Gábor", email = "gaborjbernat@gmail.com" },
-]
-requires-python = ">=3.7"
+requires-python = ">=3.8"
classifiers = [
"Development Status :: 5 - Production/Stable",
- "Framework :: tox",
"Intended Audience :: Developers",
"License :: OSI Approved :: MIT License",
"Operating System :: MacOS :: MacOS X",
"Operating System :: Microsoft :: Windows",
"Operating System :: POSIX",
"Programming Language :: Python :: 3 :: Only",
- "Programming Language :: Python :: 3.7",
"Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
+ "Programming Language :: Python :: 3.13",
+ "Programming Language :: Python :: 3.14",
+ "Programming Language :: Python :: Implementation :: CPython",
"Topic :: Software Development :: Libraries",
- "Topic :: Software Development :: Testing",
"Topic :: Utilities",
]
dynamic = [
"version",
]
dependencies = [
- "typing-extensions>=4.7.1; python_version<'3.11'",
+ "filelock>=3.15.4",
+ "platformdirs<5,>=4.3.6",
]
optional-dependencies.docs = [
- "furo>=2024.1.29",
- "sphinx<7.2",
- "sphinx-autodoc-typehints>=1.25.3",
+ "furo>=2025.12.19",
+ "sphinx>=9.1",
+ "sphinx-autodoc-typehints>=3.6.3",
+ "sphinxcontrib-mermaid>=2",
]
optional-dependencies.testing = [
"covdefaults>=2.3",
- "pytest>=7.4.4",
- "pytest-cov>=4.1",
- "pytest-mock>=3.11.1",
- "setuptools>=68",
-]
-urls.Homepage = "https://py-discovery.readthedocs.io"
-urls.Source = "https://github.com/tox-dev/py-discovery"
-urls.Tracker = "https://github.com/tox-dev/py-discovery/issues"
+ "coverage>=7.5.4",
+ "pytest>=8.3.5",
+ "pytest-mock>=3.14",
+ "setuptools>=75.1",
+]
+urls.Changelog = "https://github.com/tox-dev/python-discovery/releases"
+urls.Documentation = "https://python-discovery.readthedocs.io"
+urls.Homepage = "https://github.com/tox-dev/python-discovery"
+urls.Source = "https://github.com/tox-dev/python-discovery"
+urls.Tracker = "https://github.com/tox-dev/python-discovery/issues"
[tool.hatch]
-build.hooks.vcs.version-file = "src/py_discovery/_version.py"
version.source = "vcs"
[tool.ruff]
-target-version = "py37"
line-length = 120
format.preview = true
format.docstring-code-line-length = 100
@@ -79,30 +76,46 @@ lint.select = [
"ALL",
]
lint.ignore = [
- "ANN101", # Missing type annotation for `self` in method
- "ANN102", # Missing type annotation for `cls` in classmethod"
- "ANN401", # Dynamically typed expressions
"COM812", # Conflict with formatter
- "CPY", # no copyright
- "D203", # `one-blank-line-before-class` (D203) and `no-blank-line-before-class` (D211) are incompatible
- "D212", # `multi-line-summary-first-line` (D212) and `multi-line-summary-second-line` (D213) are incompatible
- "INP001", # no implicit namespaces here
+ "CPY", # No copyright statements
+ "D203", # `one-blank-line-before-class` and `no-blank-line-before-class` are incompatible
+ "D212", # `multi-line-summary-first-line` and `multi-line-summary-second-line` are incompatible
+ "DOC201", # `return` is not documented in docstring
+ "DOC402", # `yield` is not documented in docstring
+ "DOC501", # `raises` is not documented in docstring
"ISC001", # Conflict with formatter
- "S104", # Possible binding to all interfaces
- "S603", # `subprocess` call: check for execution of untrusted input
+ "S104", # Possible binding to all interface
+]
+lint.per-file-ignores."docs/**/*.py" = [
+ "INP001", # no __init__.py in docs directory
+]
+lint.per-file-ignores."src/python_discovery/_discovery.py" = [
+ "PTH", # shim resolution uses string-based os.path for consistency with env variables
+]
+lint.per-file-ignores."src/python_discovery/_py_info.py" = [
+ "PTH", # must use os.path — file runs as subprocess script with only stdlib
+]
+lint.per-file-ignores."src/python_discovery/_windows/_pep514.py" = [
+ "PTH", # os.path.exists is monkeypatched in tests; pathlib.Path.exists bypasses the mock
]
lint.per-file-ignores."tests/**/*.py" = [
"D", # don't care about documentation in tests
"FBT", # don't care about booleans as positional arguments in tests
"INP001", # no implicit namespace
- "PLC0415", # import at top
- "PLC2701", # Private imports
- "PLR0917", # too many positional arguments
- "PLR2004", # Magic value used in comparison, consider replacing with a constant variable
+ "PLC0415", # imports inside test functions (conditional on mocking)
+ "PLC2701", # private imports needed to test internal APIs
+ "PLR0913", # too many arguments (pytest fixtures)
+ "PLR2004", # Magic value used in comparison
"S101", # asserts allowed in tests
+ "S404", # subprocess import
+ "S603", # `subprocess` call: check for execution of untrusted input
+ "SLF001", # private member access needed to test internals
+]
+lint.per-file-ignores."tests/windows/winreg_mock_values.py" = [
+ "F821", # undefined name (winreg available only on Windows)
]
lint.isort = { known-first-party = [
- "py_discovery",
+ "python_discovery",
], required-imports = [
"from __future__ import annotations",
] }
@@ -110,35 +123,59 @@ lint.preview = true
[tool.codespell]
builtin = "clear,usage,en-GB_to_en-US"
+write-changes = true
count = true
-quiet-level = 3
+
+[tool.pyproject-fmt]
+max_supported_python = "3.14"
+
+[tool.pytest.ini_options]
+markers = [ "graalpy" ]
[tool.coverage]
-report.fail_under = 87
+run.source = [
+ "python_discovery",
+ "tests",
+]
+run.dynamic_context = "test_function"
+run.branch = true
+run.parallel = true
+run.plugins = [
+ "covdefaults",
+]
+report.fail_under = 100
+report.show_missing = true
+report.partial_branches = [
+ "assert any\\(",
+]
+report.omit = [
+ "src/python_discovery/_windows/*",
+ "tests/windows/*",
+]
html.show_contexts = true
html.skip_covered = false
paths.source = [
"src",
".tox*/*/lib/python*/site-packages",
".tox*/pypy*/site-packages",
- ".tox/pypy*/lib/pypy*/site-packages",
".tox*\\*\\Lib\\site-packages",
"*/src",
"*\\src",
]
-report.omit = [
-]
-run.parallel = true
-run.plugins = [
- "covdefaults",
-]
-[tool.mypy]
-python_version = "3.8"
-show_error_codes = true
-strict = true
-overrides = [
- { module = [
- "setuptools.*",
- ], ignore_missing_imports = true },
-]
+[tool.ty]
+src.exclude = [ "tests/windows/winreg_mock_values.py" ]
+
+[[tool.ty.overrides]]
+include = [ "src/python_discovery/_py_info.py", "src/python_discovery/_py_spec.py" ]
+rules.unused-ignore-comment = "ignore"
+rules.invalid-argument-type = "ignore"
+rules.invalid-return-type = "ignore"
+rules.no-matching-overload = "ignore"
+
+[[tool.ty.overrides]]
+include = [ "tests/**/*.py" ]
+rules.unused-ignore-comment = "ignore"
+rules.invalid-argument-type = "ignore"
+rules.no-matching-overload = "ignore"
+rules.unresolved-attribute = "ignore"
diff --git a/src/py_discovery/__init__.py b/src/py_discovery/__init__.py
deleted file mode 100644
index 2f642b9..0000000
--- a/src/py_discovery/__init__.py
+++ /dev/null
@@ -1,22 +0,0 @@
-"""Python discovery."""
-
-from __future__ import annotations
-
-from ._builtin import Builtin, PathPythonInfo, get_interpreter
-from ._discover import Discover
-from ._info import PythonInfo, VersionInfo
-from ._spec import PythonSpec
-from ._version import version
-
-__version__ = version #: version of the package
-
-__all__ = [
- "Builtin",
- "Discover",
- "PathPythonInfo",
- "PythonInfo",
- "PythonSpec",
- "VersionInfo",
- "__version__",
- "get_interpreter",
-]
diff --git a/src/py_discovery/_builtin.py b/src/py_discovery/_builtin.py
deleted file mode 100644
index e930a74..0000000
--- a/src/py_discovery/_builtin.py
+++ /dev/null
@@ -1,194 +0,0 @@
-from __future__ import annotations
-
-import logging
-import os
-import sys
-from typing import TYPE_CHECKING, Iterator, Mapping, MutableMapping
-
-from ._discover import Discover
-from ._info import PythonInfo
-from ._spec import PythonSpec
-
-if TYPE_CHECKING:
- from argparse import ArgumentParser, Namespace
-
-
-class Builtin(Discover):
- def __init__(self, options: Namespace) -> None:
- super().__init__(options)
- self.python_spec = options.python or [sys.executable]
- self.try_first_with = options.try_first_with
-
- @classmethod
- def add_parser_arguments(cls, parser: ArgumentParser) -> None:
- parser.add_argument(
- "-p",
- "--python",
- dest="python",
- metavar="py",
- type=str,
- action="append",
- default=[],
- help="interpreter based on what to create environment (path/identifier) "
- "- by default use the interpreter where the tool is installed - first found wins",
- )
- parser.add_argument(
- "--try-first-with",
- dest="try_first_with",
- metavar="py_exe",
- type=str,
- action="append",
- default=[],
- help="try first these interpreters before starting the discovery",
- )
-
- def run(self) -> PythonInfo | None:
- for python_spec in self.python_spec:
- result = get_interpreter(python_spec, self.try_first_with, self._env)
- if result is not None:
- return result
- return None
-
- def __repr__(self) -> str:
- spec = self.python_spec[0] if len(self.python_spec) == 1 else self.python_spec
- return f"{self.__class__.__name__} discover of python_spec={spec!r}"
-
-
-def get_interpreter(
- key: str,
- try_first_with: list[str],
- env: MutableMapping[str, str] | None = None,
-) -> PythonInfo | None:
- spec = PythonSpec.from_string_spec(key)
- logging.info("find interpreter for spec %r", spec)
- proposed_paths = set()
- env = os.environ if env is None else env
- for interpreter, impl_must_match in propose_interpreters(spec, try_first_with, env):
- if interpreter is None:
- continue
- lookup_key = interpreter.system_executable, impl_must_match
- if lookup_key in proposed_paths:
- continue
- logging.info("proposed %s", interpreter)
- if interpreter.satisfies(spec, impl_must_match):
- logging.debug("accepted %s", interpreter)
- return interpreter
- proposed_paths.add(lookup_key)
- return None
-
-
-def propose_interpreters( # noqa: C901, PLR0912
- spec: PythonSpec,
- try_first_with: list[str],
- env: MutableMapping[str, str] | None = None,
-) -> Iterator[tuple[PythonInfo | None, bool]]:
- # 0. tries with first
- env = os.environ if env is None else env
- for py_exe in try_first_with:
- path = os.path.abspath(py_exe) # noqa: PTH100
- try:
- os.lstat(path) # Windows Store Python does not work with os.path.exists, but does for os.lstat
- except OSError:
- pass
- else:
- yield PythonInfo.from_exe(os.path.abspath(path), env=env), True # noqa: PTH100
-
- # 1. if it's a path and exists
- if spec.path is not None:
- try:
- os.lstat(spec.path) # Windows Store Python does not work with os.path.exists, but does for os.lstat
- except OSError:
- if spec.is_abs:
- raise
- else:
- yield PythonInfo.from_exe(os.path.abspath(spec.path), env=env), True # noqa: PTH100
- if spec.is_abs:
- return
- else:
- # 2. otherwise tries with the current
- yield PythonInfo.current_system(), True
-
- # 3. otherwise fallbacks to platform default logic
- if sys.platform == "win32":
- from ._windows import propose_interpreters # noqa: PLC0415
-
- for interpreter in propose_interpreters(spec, env):
- yield interpreter, True
- # finally, find on the path, the path order matters (as the candidates are less easy to control by end user)
- paths = get_paths(env)
- tested_exes = set()
- for pos, path in enumerate(paths):
- path_str = str(path)
- logging.debug(LazyPathDump(pos, path_str, env))
- for candidate, match in possible_specs(spec):
- found = check_path(candidate, path_str)
- if found is not None:
- exe = os.path.abspath(found) # noqa: PTH100
- if exe not in tested_exes:
- tested_exes.add(exe)
- got = PathPythonInfo.from_exe(exe, raise_on_error=False, env=env)
- if got is not None:
- yield got, match
-
-
-def get_paths(env: Mapping[str, str]) -> list[str]:
- path = env.get("PATH", None)
- if path is None:
- if sys.platform == "win32": # pragma: win32 cover
- path = os.defpath
- else: # pragma: win32 cover
- path = os.confstr("CS_PATH") or os.defpath
- return [] if not path else [p for p in path.split(os.pathsep) if os.path.exists(p)] # noqa: PTH110
-
-
-class LazyPathDump:
- def __init__(self, pos: int, path: str, 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"): # this is the over the board debug
- content += " with =>"
- for file_name in os.listdir(self.path):
- try:
- file_path = os.path.join(self.path, file_name) # noqa: PTH118
- if os.path.isdir(file_path) or not os.access(file_path, os.X_OK): # noqa: PTH112
- continue
- except OSError:
- pass
- content += " "
- content += file_name
- return content
-
-
-def check_path(candidate: str, path: str) -> str | None:
- _, ext = os.path.splitext(candidate) # noqa: PTH122
- if sys.platform == "win32" and ext != ".exe":
- candidate = f"{candidate}.exe"
- if os.path.isfile(candidate): # noqa: PTH113
- return candidate
- candidate = os.path.join(path, candidate) # noqa: PTH118
- if os.path.isfile(candidate): # noqa: PTH113
- return candidate
- return None
-
-
-def possible_specs(spec: PythonSpec) -> Iterator[tuple[str, bool]]:
- # 4. then maybe it's something exact on PATH - if it was a direct lookup implementation no longer counts
- if spec.str_spec is not None:
- yield spec.str_spec, False
- # 5. or from the spec we can deduce a name on path that matches
- yield from spec.generate_names()
-
-
-class PathPythonInfo(PythonInfo):
- """Python info from a path."""
-
-
-__all__ = [
- "Builtin",
- "PathPythonInfo",
- "get_interpreter",
-]
diff --git a/src/py_discovery/_discover.py b/src/py_discovery/_discover.py
deleted file mode 100644
index 3b6fa66..0000000
--- a/src/py_discovery/_discover.py
+++ /dev/null
@@ -1,57 +0,0 @@
-from __future__ import annotations
-
-from abc import ABC, abstractmethod
-from typing import TYPE_CHECKING
-
-if TYPE_CHECKING:
- from argparse import ArgumentParser, Namespace
-
- from py_discovery import PythonInfo
-
-
-class Discover(ABC):
- """Discover and provide the requested Python interpreter."""
-
- @classmethod
- def add_parser_arguments(cls, parser: ArgumentParser) -> None:
- """
- Add CLI arguments for this discovery mechanisms.
-
- :param parser: The CLI parser.
-
- """
- raise NotImplementedError
-
- def __init__(self, options: Namespace) -> None:
- """
- Create a new discovery mechanism.
-
- :param options: The parsed options as defined within the :meth:`add_parser_arguments`.
-
- """
- self._has_run = False
- self._interpreter: PythonInfo | None = None
- self._env = options.env
-
- @abstractmethod
- def run(self) -> PythonInfo | None:
- """
- Discovers an interpreter.
-
- :return: The interpreter ready to use for virtual environment creation
-
- """
- raise NotImplementedError
-
- @property
- def interpreter(self) -> PythonInfo | None:
- """:return: the interpreter as returned by the :meth:`run`, cached"""
- if self._has_run is False:
- self._interpreter = self.run()
- self._has_run = True
- return self._interpreter
-
-
-__all__ = [
- "Discover",
-]
diff --git a/src/py_discovery/_info.py b/src/py_discovery/_info.py
deleted file mode 100644
index 3558153..0000000
--- a/src/py_discovery/_info.py
+++ /dev/null
@@ -1,715 +0,0 @@
-"""
-The PythonInfo contains information about a concrete instance of a Python interpreter.
-
-Note: this file is also used to query target interpreters, so can only use standard library methods
-
-"""
-
-from __future__ import annotations
-
-import copy
-import json
-import logging
-import os
-import platform
-import re
-import sys
-import sysconfig
-import warnings
-from collections import OrderedDict, namedtuple
-from pathlib import Path
-from random import choice
-from shlex import quote
-from string import ascii_lowercase, ascii_uppercase, digits
-from subprocess import PIPE, Popen # noqa: S404
-from tempfile import NamedTemporaryFile
-from typing import TYPE_CHECKING, Any, Iterator, Mapping, MutableMapping
-
-if TYPE_CHECKING:
- from py_discovery import PythonSpec
-
-_FS_CASE_SENSITIVE = None
-
-
-def fs_is_case_sensitive() -> bool:
- global _FS_CASE_SENSITIVE # noqa: PLW0603
-
- if _FS_CASE_SENSITIVE is None:
- with NamedTemporaryFile(prefix="TmP") as tmp_file:
- _FS_CASE_SENSITIVE = not os.path.exists(tmp_file.name.lower()) # noqa: PTH110
- logging.debug("filesystem is %scase-sensitive", "" if _FS_CASE_SENSITIVE else "not ")
- return _FS_CASE_SENSITIVE
-
-
-VersionInfo = namedtuple("VersionInfo", ["major", "minor", "micro", "releaselevel", "serial"]) # noqa: PYI024
-
-
-def _get_path_extensions() -> list[str]:
- return list(OrderedDict.fromkeys(["", *os.environ.get("PATHEXT", "").lower().split(os.pathsep)]))
-
-
-EXTENSIONS = _get_path_extensions()
-_CONF_VAR_RE = re.compile(r"\{\w+\}")
-
-
-class PythonInfo:
- """Contains information for a Python interpreter."""
-
- def __init__(self) -> None:
- def abs_path(v: str | None) -> str | None:
- # unroll relative elements from path (e.g. ..)
- return None if v is None else os.path.abspath(v) # noqa: PTH100
-
- # qualifies the python
- self.platform = sys.platform
- self.implementation = platform.python_implementation()
- self.pypy_version_info = (
- tuple(sys.pypy_version_info) # type: ignore[attr-defined]
- if self.implementation == "PyPy"
- else None
- )
-
- # this is a tuple in earlier, struct later, unify to our own named tuple
- self.version_info = VersionInfo(*sys.version_info)
- self.architecture = 64 if sys.maxsize > 2**32 else 32
-
- # Used to determine some file names - see `CPython3Windows.python_zip()`.
- self.version_nodot = sysconfig.get_config_var("py_version_nodot")
-
- self.version = sys.version
- self.os = os.name
-
- # information about the prefix - determines python home
- self.prefix = abs_path(getattr(sys, "prefix", None)) # prefix we think
- self.base_prefix = abs_path(getattr(sys, "base_prefix", None)) # venv
- self.real_prefix = abs_path(getattr(sys, "real_prefix", None)) # old virtualenv
-
- # information about the exec prefix - dynamic stdlib modules
- self.base_exec_prefix = abs_path(getattr(sys, "base_exec_prefix", None))
- self.exec_prefix = abs_path(getattr(sys, "exec_prefix", None))
-
- self.executable = abs_path(sys.executable) # the executable we were invoked via
- self.original_executable = abs_path(self.executable) # the executable as known by the interpreter
- self.system_executable = self._fast_get_system_executable() # the executable we are based of (if available)
-
- try:
- __import__("venv")
- has = True
- except ImportError:
- has = False
- self.has_venv = has
- self.path = sys.path
- self.file_system_encoding = sys.getfilesystemencoding()
- self.stdout_encoding = getattr(sys.stdout, "encoding", None)
-
- scheme_names = sysconfig.get_scheme_names()
-
- if "venv" in scheme_names:
- 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()
- }
- # we cannot use distutils at all if "venv" exists, distutils don't know it
- self.distutils_install = {}
- # debian / ubuntu python 3.10 without `python3-distutils` will report
- # mangled `local/bin` / etc. names for the default prefix
- # intentionally select `posix_prefix` which is the unaltered posix-like paths
- elif sys.version_info[:2] == (3, 10) and "deb_system" in scheme_names:
- self.sysconfig_scheme: str | None = "posix_prefix"
- self.sysconfig_paths = {
- i: sysconfig.get_path(i, expand=False, scheme=self.sysconfig_scheme) for i in sysconfig.get_path_names()
- }
- # we cannot use distutils at all if "venv" exists, distutils don't know it
- self.distutils_install = {}
- else:
- self.sysconfig_scheme = None # type: ignore[assignment]
- self.sysconfig_paths = {i: sysconfig.get_path(i, expand=False) for i in sysconfig.get_path_names()}
- self.distutils_install = self._distutils_install().copy()
-
- # https://bugs.python.org/issue22199
- makefile = getattr(sysconfig, "get_makefile_filename", getattr(sysconfig, "_get_makefile_filename", None))
- # a list of content to store from sysconfig
- self.sysconfig = {
- k: v for k, v in ([("makefile_filename", makefile())] if makefile is not None else []) if k is not None
- }
-
- config_var_keys: set[str] = 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")
-
- self.sysconfig_vars = {i: sysconfig.get_config_var(i or "") for i in config_var_keys}
-
- confs = {
- k: (self.system_prefix if v is not None 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
-
- def _fast_get_system_executable(self) -> str | None:
- """Try to get the system executable by just looking at properties."""
- # if this is a virtual environment
- if self.real_prefix or (self.base_prefix is not None and self.base_prefix != self.prefix): # noqa: PLR1702
- if self.real_prefix is None:
- # some platforms may set this to help us
- base_executable: str | None = getattr(sys, "_base_executable", None)
- if base_executable is not None: # noqa: SIM102 # use the saved system executable if present
- if sys.executable != base_executable: # we know we're in a virtual environment, cannot be us
- if os.path.exists(base_executable): # noqa: PTH110
- return base_executable
- # Python may return "python" because it was invoked from the POSIX virtual environment; but some
- # installs/distributions do not provide a version-less python binary in the system install
- # location (see PEP 394) so try to fall back to a versioned binary.
- #
- # Gate this to Python 3.11 as `sys._base_executable` path resolution is now relative to
- # the home key from `pyvenv.cfg`, which often points to the system installs location.
- major, minor = self.version_info.major, self.version_info.minor
- if self.os == "posix" and (major, minor) >= (3, 11):
- # search relative to the directory of sys._base_executable
- base_dir = os.path.dirname(base_executable) # noqa: PTH120
- versions = (f"python{major}", f"python{major}.{minor}")
- for base_executable in [os.path.join(base_dir, exe) for exe in versions]: # noqa: PTH118
- if os.path.exists(base_executable): # noqa: PTH110
- return base_executable
- return None # in this case, we just can't tell easily without poking around FS and calling them, bail
- # if we're not in a virtual environment, this is already a system python, so return the original executable
- # note we must choose the original and not the pure executable as shim scripts might throw us off
- return self.original_executable
-
- def install_path(self, key: str) -> str:
- result = self.distutils_install.get(key)
- if result is None: # use sysconfig if sysconfig_scheme is set or distutils is unavailable
- # 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
-
- @staticmethod
- def _distutils_install() -> dict[str, str]:
- # use distutils primarily because that's what pip does
- # https://github.com/pypa/pip/blob/main/src/pip/_internal/locations.py#L95
- # note here we don't import Distribution directly to allow setuptools to patch it
- with warnings.catch_warnings(): # disable warning for PEP-632
- warnings.simplefilter("ignore")
- try:
- from distutils import dist # noqa: PLC0415
- from distutils.command.install import SCHEME_KEYS # noqa: PLC0415
- except ImportError: # if removed or not installed ignore
- return {}
-
- d = dist.Distribution({"script_args": "--no-user-cfg"}) # conf files not parsed so they do not hijack paths
- if hasattr(sys, "_framework"):
- sys._framework = None # disable macOS static paths for framework # noqa: SLF001
-
- with warnings.catch_warnings(): # disable warning for PEP-632
- warnings.simplefilter("ignore")
- i = d.get_command_obj("install", create=True)
- assert i is not None # noqa: S101
-
- # paths generated are relative to prefix that contains the path sep, this makes it relative
- i.prefix = os.sep # type: ignore[attr-defined]
- i.finalize_options()
- return {key: (getattr(i, f"install_{key}")[1:]).lstrip(os.sep) for key in SCHEME_KEYS}
-
- @property
- def version_str(self) -> str:
- return ".".join(str(i) for i in self.version_info[0:3])
-
- @property
- def version_release_str(self) -> str:
- return ".".join(str(i) for i in self.version_info[0:2])
-
- @property
- def python_name(self) -> str:
- version_info = self.version_info
- return f"python{version_info.major}.{version_info.minor}"
-
- @property
- def is_old_virtualenv(self) -> bool:
- return self.real_prefix is not None
-
- @property
- def is_venv(self) -> bool:
- return self.base_prefix is not None
-
- def sysconfig_path(self, key: str, config_var: dict[str, str] | None = None, sep: str = os.sep) -> str:
- pattern = self.sysconfig_paths[key]
- 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_include(self) -> str:
- path = self.sysconfig_path(
- "include",
- {
- k: (self.system_prefix if v is not None and v.startswith(self.prefix) else v)
- for k, v in self.sysconfig_vars.items()
- },
- )
- if not os.path.exists(path) and self.prefix is not None: # noqa: PTH110
- # some broken packaging doesn't respect the sysconfig, fallback to a distutils path
- # the pattern includes the distribution name too at the end, remove that via the parent call
- fallback = os.path.join(self.prefix, os.path.dirname(self.install_path("headers"))) # noqa: PTH118, PTH120
- if os.path.exists(fallback): # noqa: PTH110
- path = fallback
- return path
-
- @property
- def system_prefix(self) -> str:
- res = self.real_prefix or self.base_prefix or self.prefix
- assert res is not None # noqa: S101
- return res
-
- @property
- def system_exec_prefix(self) -> str:
- res = self.real_prefix or self.base_exec_prefix or self.exec_prefix
- assert res is not None # noqa: S101
- return res
-
- def __repr__(self) -> str:
- return "{}({!r})".format(
- self.__class__.__name__,
- {k: v for k, v in self.__dict__.items() if not k.startswith("_")},
- )
-
- def __str__(self) -> str:
- return "{}({})".format(
- self.__class__.__name__,
- ", ".join(
- f"{k}={v}"
- for k, v in (
- ("spec", self.spec),
- (
- "system"
- if self.system_executable is not None and self.system_executable != self.executable
- else None,
- self.system_executable,
- ),
- (
- "original"
- if self.original_executable not in {self.system_executable, self.executable}
- else None,
- self.original_executable,
- ),
- ("exe", self.executable),
- ("platform", self.platform),
- ("version", repr(self.version)),
- ("encoding_fs_io", f"{self.file_system_encoding}-{self.stdout_encoding}"),
- )
- if k is not None
- ),
- )
-
- @property
- def spec(self) -> str:
- return "{}{}-{}".format(self.implementation, ".".join(str(i) for i in self.version_info), self.architecture)
-
- def satisfies(self, spec: PythonSpec, impl_must_match: bool) -> bool: # noqa: C901, FBT001
- """Check if a given specification can be satisfied by the python interpreter instance."""
- if spec.path:
- if self.executable == os.path.abspath(spec.path): # noqa: PTH100
- return True # if the path is a our own executable path we're done
- if not spec.is_abs:
- # if path set, and is not our original executable name, this does not match
- assert self.original_executable is not None # noqa: S101
- basename = os.path.basename(self.original_executable) # noqa: PTH119
- spec_path = spec.path
- if sys.platform == "win32":
- basename, suffix = os.path.splitext(basename) # noqa: PTH122
- if spec_path.endswith(suffix):
- spec_path = spec_path[: -len(suffix)]
- if basename != spec_path:
- return False
-
- if (
- impl_must_match
- and spec.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
-
- for our, req in zip(self.version_info[0:3], (spec.major, spec.minor, spec.micro)):
- if req is not None and our is not None and our != req:
- return False
- return True
-
- _current_system = None
- _current = None
-
- @classmethod
- def current(cls) -> PythonInfo:
- """
- Locate the current host interpreter information.
-
- This might be different than what we run into in case the host python has been upgraded from underneath us.
-
- """
- if cls._current is None:
- cls._current = cls.from_exe(sys.executable, raise_on_error=True, resolve_to_host=False)
- assert cls._current is not None # noqa: S101
- return cls._current
-
- @classmethod
- def current_system(cls) -> PythonInfo:
- """
- Locate the current host interpreter information.
-
- This might be different than what we run into in case the host python has been upgraded from underneath us.
-
- """
- if cls._current_system is None:
- cls._current_system = cls.from_exe(sys.executable, raise_on_error=True, resolve_to_host=True)
- assert cls._current_system is not None # noqa: S101
- return cls._current_system
-
- def _to_json(self) -> str:
- # don't save calculated paths, as these are non-primitive types
- return json.dumps(self._to_dict(), indent=2)
-
- def _to_dict(self) -> dict[str, Any]:
- data = {var: (getattr(self, var) if var != "_creators" else None) for var in vars(self)}
-
- data["version_info"] = data["version_info"]._asdict() # namedtuple to dictionary
- return data
-
- @classmethod
- def from_exe(
- cls,
- exe: str,
- *,
- raise_on_error: bool = True,
- resolve_to_host: bool = True,
- env: MutableMapping[str, str] | None = None,
- ) -> PythonInfo | None:
- """Given a path to an executable, get the python information."""
- # this method is not used by itself, so here and called functions can import stuff locally
-
- env = os.environ if env is None else env
- proposed = cls._from_exe(exe, env=env, raise_on_error=raise_on_error)
-
- if isinstance(proposed, PythonInfo) and resolve_to_host:
- try:
- proposed = proposed._resolve_to_system(proposed) # noqa: SLF001
- except Exception as exception:
- if raise_on_error:
- raise
- logging.info("ignore %s due cannot resolve system due to %r", proposed.original_executable, exception)
- proposed = None
- return proposed
-
- @classmethod
- def _from_exe(
- cls,
- exe: str,
- env: MutableMapping[str, str] | None = None,
- raise_on_error: bool = True, # noqa: FBT001, FBT002
- ) -> PythonInfo | None:
- env = os.environ if env is None else env
- outcome = _run_subprocess(cls, exe, env)
- if isinstance(outcome, Exception):
- if raise_on_error:
- raise outcome
- logging.info("%s", outcome)
- return None
- outcome.executable = exe
- return outcome
-
- @classmethod
- def _from_json(cls, payload: str) -> PythonInfo:
- # the dictionary unroll here is to protect against pypy bug of interpreter crashing
- raw = json.loads(payload)
- return cls._from_dict(raw.copy())
-
- @classmethod
- def _from_dict(cls, data: dict[str, Any]) -> PythonInfo:
- data["version_info"] = VersionInfo(**data["version_info"]) # restore this to a named tuple structure
- result = cls()
- result.__dict__ = data.copy()
- return result
-
- @classmethod
- def _resolve_to_system(cls, target: PythonInfo) -> PythonInfo:
- start_executable = target.executable
- prefixes: OrderedDict[str, PythonInfo] = OrderedDict()
- while target.system_executable is None:
- prefix = target.real_prefix or target.base_prefix or target.prefix
- assert prefix is not None # noqa: S101
- if prefix in prefixes:
- if len(prefixes) == 1:
- # if we're linking back to ourselves, accept ourselves with a WARNING
- logging.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):
- logging.error("%d: prefix=%s, info=%r", at, p, t)
- logging.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] = target
- target = target.discover_exe(prefix=prefix, exact=False)
- if target.executable != target.system_executable and target.system_executable is not None:
- outcome = cls.from_exe(target.system_executable)
- if outcome is None:
- msg = "failed to resolve to system executable"
- raise RuntimeError(msg)
- target = outcome
- target.executable = start_executable
- return target
-
- _cache_exe_discovery: dict[tuple[str, bool], PythonInfo] = {} # noqa: RUF012
-
- def discover_exe(self, prefix: str, exact: bool = True, env: MutableMapping[str, str] | None = None) -> PythonInfo: # noqa: FBT001, FBT002
- key = prefix, exact
- if key in self._cache_exe_discovery and prefix:
- logging.debug("discover exe from cache %s - exact %s: %r", prefix, exact, self._cache_exe_discovery[key])
- return self._cache_exe_discovery[key]
- logging.debug("discover exe for %s in %s", self, prefix)
- # we don't know explicitly here, do some guess work - our executable name should tell
- possible_names = self._find_possible_exe_names()
- possible_folders = self._find_possible_folders(prefix)
- discovered: list[PythonInfo] = []
- not_none_env = os.environ if env is None else env
- for folder in possible_folders:
- for name in possible_names:
- info = self._check_exe(folder, name, exact, discovered, not_none_env)
- if info is not None:
- self._cache_exe_discovery[key] = info
- return info
- if exact is False and discovered:
- info = self._select_most_likely(discovered, self)
- folders = os.pathsep.join(possible_folders)
- self._cache_exe_discovery[key] = info
- logging.debug("no exact match found, chosen most similar of %s within base folders %s", info, folders)
- return info
- msg = "failed to detect {} in {}".format("|".join(possible_names), os.pathsep.join(possible_folders))
- raise RuntimeError(msg)
-
- def _check_exe(
- self,
- folder: str,
- name: str,
- exact: bool, # noqa: FBT001
- discovered: list[PythonInfo],
- env: MutableMapping[str, str],
- ) -> PythonInfo | None:
- exe_path = os.path.join(folder, name) # noqa: PTH118
- if not os.path.exists(exe_path): # noqa: PTH110
- return None
- info = self.from_exe(exe_path, resolve_to_host=False, raise_on_error=False, env=env)
- if info is None: # ignore if for some reason we can't query
- return None
- for item in ["implementation", "architecture", "version_info"]:
- found = getattr(info, item)
- searched = getattr(self, item)
- if found != searched:
- if item == "version_info":
- found, searched = ".".join(str(i) for i in found), ".".join(str(i) for i in searched)
- executable = info.executable
- logging.debug("refused interpreter %s because %s differs %s != %s", executable, item, found, searched)
- if exact is False:
- discovered.append(info)
- break
- else:
- return info
- return None
-
- @staticmethod
- def _select_most_likely(discovered: list[PythonInfo], target: PythonInfo) -> PythonInfo:
- # no exact match found, start relaxing our requirements then to facilitate system package upgrades that
- # could cause this (when using copy strategy of the host python)
- def sort_by(info: PythonInfo) -> int:
- # we need to set up some priority of traits, this is as follows:
- # implementation, major, minor, micro, architecture, tag, serial
- matches = [
- info.implementation == target.implementation,
- info.version_info.major == target.version_info.major,
- info.version_info.minor == target.version_info.minor,
- info.architecture == target.architecture,
- info.version_info.micro == target.version_info.micro,
- info.version_info.releaselevel == target.version_info.releaselevel,
- info.version_info.serial == target.version_info.serial,
- ]
- return sum((1 << pos if match else 0) for pos, match in enumerate(reversed(matches)))
-
- 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[str, None] = OrderedDict()
- executables: OrderedDict[str, None] = OrderedDict()
- assert self.executable is not None # noqa: S101
- executables[os.path.realpath(self.executable)] = None
- executables[self.executable] = None
- assert self.original_executable is not None # noqa: S101
- executables[os.path.realpath(self.original_executable)] = None
- executables[self.original_executable] = None
- for exe in executables:
- base = os.path.dirname(exe) # noqa: PTH120
- # following path pattern of the current
- assert self.prefix is not None # noqa: S101
- 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)] # noqa: PTH110
-
- def _find_possible_exe_names(self) -> list[str]:
- name_candidate: OrderedDict[str, None] = OrderedDict()
- 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 arch in [f"-{self.architecture}", ""]:
- for ext in EXTENSIONS:
- candidate = f"{name}{version}{arch}{ext}"
- name_candidate[candidate] = None
- return list(name_candidate.keys())
-
- def _possible_base(self) -> Iterator[str]:
- possible_base: OrderedDict[str, None] = OrderedDict()
- assert self.executable is not None # noqa: S101
- basename = os.path.splitext(os.path.basename(self.executable))[0].rstrip(digits) # noqa: PTH119, PTH122
- 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
-
- if fs_is_case_sensitive():
- if base != lower:
- yield base
- upper = base.upper()
- if upper != base:
- yield upper
-
-
-_COOKIE_LENGTH: int = 32
-
-
-def _gen_cookie() -> str:
- return "".join(choice(f"{ascii_lowercase}{ascii_uppercase}{digits}") for _ in range(_COOKIE_LENGTH)) # noqa: S311
-
-
-def _run_subprocess( # noqa: PLR0914
- cls: type[PythonInfo], exe: str, env: MutableMapping[str, str]
-) -> PythonInfo | RuntimeError:
- py_info_script = Path(os.path.abspath(__file__)).parent / "_info.py" # noqa: PTH100
- # Cookies allow splitting the serialized stdout output generated by the script collecting the info from the output
- # generated by something else.
- # The right way to deal with it is to create an anonymous pipe and pass its descriptor to the child and output to
- # it.
- # However, AFAIK all of them are either not cross-platform or too big to implement and are not in the stdlib; so the
- # easiest and the shortest way I could mind is just using the cookies.
- # We generate pseudorandom cookies because it is easy to implement and avoids breakage from outputting modules
- # source code, i.e., by debug output libraries.
- # We reverse the cookies to avoid breakages resulting from variable values appearing in debug output.
-
- start_cookie = _gen_cookie()
- end_cookie = _gen_cookie()
- cmd = [exe, str(py_info_script), start_cookie, end_cookie]
- # prevent sys.prefix from leaking into the child process - see https://bugs.python.org/issue22490
- env = copy.copy(env)
- env.pop("__PYVENV_LAUNCHER__", None)
- logging.debug("get interpreter info via cmd: %s", LogCmd(cmd))
- try:
- process = Popen(
- cmd,
- universal_newlines=True,
- stdin=PIPE,
- stderr=PIPE,
- stdout=PIPE,
- env=env,
- encoding="utf-8",
- )
- out, err = process.communicate()
- code = process.returncode
- except OSError as os_error:
- out, err, code = "", os_error.strerror, os_error.errno
- if code == 0:
- out_starts = out.find(start_cookie[::-1])
-
- if out_starts > -1:
- pre_cookie = out[:out_starts]
-
- if pre_cookie:
- sys.stdout.write(pre_cookie)
-
- out = out[out_starts + _COOKIE_LENGTH :]
-
- out_ends = out.find(end_cookie[::-1])
-
- if out_ends > -1:
- post_cookie = out[out_ends + _COOKIE_LENGTH :]
-
- if post_cookie:
- sys.stdout.write(post_cookie)
-
- out = out[:out_ends]
-
- result = cls._from_json(out)
- result.executable = exe # keep the original executable as this may contain initialization code
- return result
-
- err_str = f" err: {err!r}" if err else ""
- out_str = f" out: {out!r}" if out else ""
- msg = f"{exe} with code {code}{out_str}{err_str}"
- 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 __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
-
-
-__all__ = [
- "EXTENSIONS",
- "PythonInfo",
- "VersionInfo",
- "fs_is_case_sensitive",
-]
-
-
-def _run() -> None:
- """Dump a JSON representation of the current python."""
- argv = sys.argv[1:]
- if len(argv) >= 1:
- start_cookie = argv[0]
- argv = argv[1:]
- else:
- start_cookie = ""
- if len(argv) >= 1:
- end_cookie = argv[0]
- argv = argv[1:]
- else:
- end_cookie = ""
- sys.argv = sys.argv[:1] + argv
- info = PythonInfo()._to_json() # noqa: SLF001
- sys.stdout.write("".join((start_cookie[::-1], info, end_cookie[::-1])))
-
-
-if __name__ == "__main__":
- _run()
diff --git a/src/py_discovery/_spec.py b/src/py_discovery/_spec.py
deleted file mode 100644
index ce89cb5..0000000
--- a/src/py_discovery/_spec.py
+++ /dev/null
@@ -1,132 +0,0 @@
-"""A Python specification is an abstract requirement definition of an interpreter."""
-
-from __future__ import annotations
-
-import os
-import re
-from collections import OrderedDict
-from typing import Iterator, Tuple, cast
-
-from py_discovery._info import fs_is_case_sensitive
-
-PATTERN = re.compile(r"^(?P[a-zA-Z]+)?(?P[0-9.]+)?(?:-(?P32|64))?$")
-
-
-class PythonSpec:
- """Contains specification about a Python Interpreter."""
-
- def __init__( # noqa: PLR0913, PLR0917
- self,
- str_spec: str | None,
- implementation: str | None,
- major: int | None,
- minor: int | None,
- micro: int | None,
- architecture: int | None,
- path: str | None,
- ) -> None:
- self.str_spec = str_spec
- self.implementation = implementation
- self.major = major
- self.minor = minor
- self.micro = micro
- self.architecture = architecture
- self.path = path
-
- @classmethod
- def from_string_spec(cls, string_spec: str) -> PythonSpec: # noqa: C901, PLR0912
- impl, major, minor, micro, arch, path = None, None, None, None, None, None
- if os.path.isabs(string_spec): # noqa: PTH117, PLR1702
- path = string_spec
- else:
- ok = False
- match = re.match(PATTERN, string_spec)
- if match:
-
- def _int_or_none(val: str | None) -> int | None:
- return None if val is None else int(val)
-
- try:
- groups = match.groupdict()
- version = groups["version"]
- if version is not None:
- versions = tuple(int(i) for i in version.split(".") if i)
- if len(versions) > 3: # noqa: PLR2004
- raise ValueError # noqa: TRY301
- if len(versions) == 3: # noqa: PLR2004
- major, minor, micro = versions
- elif len(versions) == 2: # noqa: PLR2004
- major, minor = versions
- elif len(versions) == 1:
- version_data = versions[0]
- major = int(str(version_data)[0]) # first digit major
- if version_data > 9: # noqa: PLR2004
- minor = int(str(version_data)[1:])
- ok = True
- except ValueError:
- pass
- else:
- impl = groups["impl"]
- if impl in {"py", "python"}:
- impl = None
- arch = _int_or_none(groups["arch"])
-
- if not ok:
- path = string_spec
-
- return cls(string_spec, impl, major, minor, micro, arch, path)
-
- def generate_names(self) -> Iterator[tuple[str, bool]]:
- impls = OrderedDict()
- if self.implementation:
- # first, consider implementation as it is
- impls[self.implementation] = False
- if fs_is_case_sensitive():
- # for case-sensitive file systems, consider lower and upper case versions too
- # trivia: MacBooks and all pre-2018 Windows-es were case-insensitive by default
- impls[self.implementation.lower()] = False
- impls[self.implementation.upper()] = False
- impls["python"] = True # finally, consider python as alias; implementation must match now
- version = self.major, self.minor, self.micro
- try:
- not_none_version: tuple[int, ...] = version[: version.index(None)] # type: ignore[assignment]
- except ValueError:
- not_none_version = cast(Tuple[int, ...], version)
-
- for impl, match in impls.items():
- for at in range(len(not_none_version), -1, -1):
- cur_ver = not_none_version[0:at]
- spec = f"{impl}{'.'.join(str(i) for i in cur_ver)}"
- yield spec, match
-
- @property
- def is_abs(self) -> bool:
- return self.path is not None and os.path.isabs(self.path) # noqa: PTH117
-
- def satisfies(self, spec: PythonSpec) -> bool:
- """Call when there's a candidate metadata spec to see if compatible - e.g., PEP-514 on Windows."""
- 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
-
- for our, req in zip((self.major, self.minor, self.micro), (spec.major, spec.minor, spec.micro)):
- if req is not None and our is not None and our != req:
- return False
- return True
-
- def __repr__(self) -> str:
- name = type(self).__name__
- params = "implementation", "major", "minor", "micro", "architecture", "path"
- return f"{name}({', '.join(f'{k}={getattr(self, k)}' for k in params if getattr(self, k) is not None)})"
-
-
-__all__ = [
- "PythonSpec",
-]
diff --git a/src/py_discovery/_windows/__init__.py b/src/py_discovery/_windows/__init__.py
deleted file mode 100644
index 5bf5968..0000000
--- a/src/py_discovery/_windows/__init__.py
+++ /dev/null
@@ -1,49 +0,0 @@
-from __future__ import annotations
-
-from typing import Iterator, MutableMapping
-
-from py_discovery._info import PythonInfo
-from py_discovery._spec import PythonSpec
-
-from .pep514 import discover_pythons
-
-# Map of well-known organizations (as per PEP 514 Company Windows Registry key part) versus Python implementation
-_IMPLEMENTATION_BY_ORG = {
- "ContinuumAnalytics": "CPython",
- "PythonCore": "CPython",
-}
-
-
-class Pep514PythonInfo(PythonInfo):
- """A Python information acquired from PEP-514."""
-
-
-def propose_interpreters(spec: PythonSpec, env: MutableMapping[str, str]) -> Iterator[PythonInfo]:
- # see if PEP-514 entries are good
-
- # start with higher python versions in an effort to use the latest version available
- # and prefer PythonCore over conda pythons (as virtualenv is mostly used by non conda tools)
- 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),
- reverse=True,
- )
-
- for name, major, minor, arch, exe, _ in existing:
- # Map the well-known/most common organizations to a Python implementation, use the org name as a fallback for
- # backwards compatibility.
- implementation = _IMPLEMENTATION_BY_ORG.get(name, name)
-
- # Pre-filtering based on Windows Registry metadata, for CPython only
- skip_pre_filter = implementation.lower() != "cpython"
- registry_spec = PythonSpec(None, implementation, major, minor, None, arch, exe)
- if skip_pre_filter or registry_spec.satisfies(spec):
- interpreter = Pep514PythonInfo.from_exe(exe, raise_on_error=False, resolve_to_host=True, env=env)
- if interpreter is not None and interpreter.satisfies(spec, impl_must_match=True):
- yield interpreter # Final filtering/matching using interpreter metadata
-
-
-__all__ = [
- "Pep514PythonInfo",
- "propose_interpreters",
-]
diff --git a/src/py_discovery/_windows/pep514.py b/src/py_discovery/_windows/pep514.py
deleted file mode 100644
index c209e71..0000000
--- a/src/py_discovery/_windows/pep514.py
+++ /dev/null
@@ -1,242 +0,0 @@
-"""Implement https://www.python.org/dev/peps/pep-0514/ to discover interpreters - Windows only."""
-
-from __future__ import annotations
-
-import os
-import re
-import sys
-from logging import basicConfig, getLogger
-from typing import TYPE_CHECKING, Any, Iterator, Union, cast
-
-if TYPE_CHECKING:
- from types import TracebackType
-
-if sys.platform == "win32": # pragma: win32 cover
- from winreg import (
- HKEY_CURRENT_USER,
- HKEY_LOCAL_MACHINE,
- KEY_READ,
- KEY_WOW64_32KEY,
- KEY_WOW64_64KEY,
- EnumKey,
- HKEYType,
- OpenKeyEx,
- QueryValueEx,
- )
-
-
-else: # pragma: win32 no cover
- HKEY_CURRENT_USER = 0
- HKEY_LOCAL_MACHINE = 1
- KEY_READ = 131097
- KEY_WOW64_32KEY = 512
- KEY_WOW64_64KEY = 256
-
- class HKEYType:
- def __bool__(self) -> bool:
- return True
-
- def __int__(self) -> int:
- return 1
-
- def __enter__(self) -> HKEYType: # noqa: PYI034
- return HKEYType()
-
- def __exit__(
- self,
- exc_type: type[BaseException] | None,
- exc_value: BaseException | None,
- traceback: TracebackType | None,
- ) -> bool | None: ...
-
- def EnumKey(__key: _KeyType, __index: int) -> str: # noqa: N802
- return ""
-
- def OpenKeyEx( # noqa: N802
- key: _KeyType, # noqa: ARG001
- sub_key: str, # noqa: ARG001
- reserved: int = 0, # noqa: ARG001
- access: int = 131097, # noqa: ARG001
- ) -> HKEYType:
- return HKEYType()
-
- def QueryValueEx(__key: HKEYType, __name: str) -> tuple[Any, int]: # noqa: N802
- return "", 0
-
-
-_KeyType = Union[HKEYType, int]
-LOGGER = getLogger(__name__)
-
-
-def enum_keys(key: _KeyType) -> Iterator[str]:
- at = 0
- while True:
- try:
- yield EnumKey(key, at)
- except OSError:
- break
- at += 1
-
-
-def get_value(key: HKEYType, value_name: str) -> str | None:
- try:
- return cast(str, QueryValueEx(key, value_name)[0])
- except OSError:
- return None
-
-
-def discover_pythons() -> Iterator[tuple[str, int, int | None, int, str, str | None]]:
- for hive, hive_name, key, flags, default_arch in [
- (HKEY_CURRENT_USER, "HKEY_CURRENT_USER", r"Software\Python", 0, 64),
- (HKEY_LOCAL_MACHINE, "HKEY_LOCAL_MACHINE", r"Software\Python", KEY_WOW64_64KEY, 64),
- (HKEY_LOCAL_MACHINE, "HKEY_LOCAL_MACHINE", r"Software\Python", KEY_WOW64_32KEY, 32),
- ]:
- yield from process_set(hive, hive_name, key, flags, default_arch)
-
-
-def process_set(
- hive: int,
- hive_name: str,
- key: str,
- flags: int,
- default_arch: int,
-) -> Iterator[tuple[str, int, int | None, int, str, str | None]]:
- try:
- with OpenKeyEx(hive, key, 0, KEY_READ | flags) as root_key:
- for company in enum_keys(root_key):
- if company == "PyLauncher": # reserved
- continue
- yield from process_company(hive_name, company, root_key, default_arch)
- except OSError:
- pass
-
-
-def process_company(
- hive_name: str,
- company: str,
- root_key: _KeyType,
- default_arch: int,
-) -> Iterator[tuple[str, int, int | None, int, str, str | None]]:
- with OpenKeyEx(root_key, company) as company_key:
- for tag in enum_keys(company_key):
- spec = process_tag(hive_name, company, company_key, tag, default_arch)
- if spec is not None:
- yield spec
-
-
-def process_tag(
- hive_name: str,
- company: str,
- company_key: HKEYType,
- tag: str,
- default_arch: int,
-) -> tuple[str, int, int | None, int, str, str | None] | None:
- with OpenKeyEx(company_key, tag) as tag_key:
- 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
- return company, major, minor, arch, exe, args
- return None
- return None
- return None
-
-
-def load_exe(hive_name: str, company: str, company_key: HKEYType, tag: str) -> tuple[str, str | None] | None:
- key_path = f"{hive_name}/{company}/{tag}"
- try:
- with OpenKeyEx(company_key, rf"{tag}\InstallPath") as ip_key, ip_key:
- exe = get_value(ip_key, "ExecutablePath")
- if exe is None:
- ip = get_value(ip_key, "")
- if ip is None:
- msg(key_path, "no ExecutablePath or default for it")
-
- else:
- ip = ip.rstrip("\\")
- exe = f"{ip}\\python.exe"
- if exe is not None and os.path.exists(exe): # noqa: PTH110
- args = get_value(ip_key, "ExecutableArguments")
- return exe, args
- msg(key_path, f"could not load exe with value {exe}")
- except OSError:
- msg(f"{key_path}/InstallPath", "missing")
- return None
-
-
-def load_arch_data(hive_name: str, company: str, tag: str, tag_key: HKEYType, default_arch: int) -> int:
- 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: str) -> int:
- if isinstance(arch_str, str):
- match = re.match(r"^(\d+)bit$", arch_str)
- if match:
- return int(next(iter(match.groups())))
- error = f"invalid format {arch_str}"
- else:
- error = f"arch is not string: {arch_str!r}"
- raise ValueError(error)
-
-
-def load_version_data(
- hive_name: str,
- company: str,
- tag: str,
- tag_key: HKEYType,
-) -> tuple[int, 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
-
-
-def parse_version(version_str: str) -> tuple[int, int | None, int | None]:
- if isinstance(version_str, str):
- match = re.match(r"^(\d+)(?:\.(\d+))?(?:\.(\d+))?$", version_str)
- if match:
- return tuple(int(i) if i is not None else None for i in match.groups()) # type: ignore[return-value]
- error = f"invalid format {version_str}"
- else:
- error = f"version is not string: {version_str!r}"
- raise ValueError(error)
-
-
-def msg(path: str, what: str | ValueError) -> 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()]
- print("\n".join(sorted(interpreters))) # noqa: T201
-
-
-__all__ = [
- "HKEY_CURRENT_USER",
- "HKEY_LOCAL_MACHINE",
- "KEY_READ",
- "KEY_WOW64_32KEY",
- "KEY_WOW64_64KEY",
- "discover_pythons",
-]
-
-if __name__ == "__main__":
- _run()
diff --git a/src/python_discovery/__init__.py b/src/python_discovery/__init__.py
new file mode 100644
index 0000000..74c7bca
--- /dev/null
+++ b/src/python_discovery/__init__.py
@@ -0,0 +1,22 @@
+"""Self-contained Python interpreter discovery."""
+
+from __future__ import annotations
+
+from importlib.metadata import version
+
+from ._cache import ContentStore, DiskCache, PyInfoCache
+from ._discovery import get_interpreter
+from ._py_info import PythonInfo
+from ._py_spec import PythonSpec
+
+__version__ = version("python-discovery")
+
+__all__ = [
+ "ContentStore",
+ "DiskCache",
+ "PyInfoCache",
+ "PythonInfo",
+ "PythonSpec",
+ "__version__",
+ "get_interpreter",
+]
diff --git a/src/python_discovery/_cache.py b/src/python_discovery/_cache.py
new file mode 100644
index 0000000..f1cd189
--- /dev/null
+++ b/src/python_discovery/_cache.py
@@ -0,0 +1,153 @@
+"""Cache Protocol and built-in implementations for Python interpreter discovery."""
+
+from __future__ import annotations
+
+import json
+import logging
+from contextlib import contextmanager, suppress
+from hashlib import sha256
+from typing import TYPE_CHECKING, Final, Protocol, runtime_checkable
+
+if TYPE_CHECKING:
+ from collections.abc import Generator
+ from pathlib import Path
+
+_LOGGER: Final[logging.Logger] = logging.getLogger(__name__)
+
+
+@runtime_checkable
+class ContentStore(Protocol):
+ """A store for reading and writing cached content."""
+
+ def exists(self) -> bool: ...
+
+ def read(self) -> dict | None: ...
+
+ def write(self, content: dict) -> None: ...
+
+ def remove(self) -> None: ...
+
+ @contextmanager
+ def locked(self) -> Generator[None]: ...
+
+
+@runtime_checkable
+class PyInfoCache(Protocol):
+ """Cache interface for Python interpreter information."""
+
+ def py_info(self, path: Path) -> ContentStore: ...
+
+ def py_info_clear(self) -> None: ...
+
+
+class DiskContentStore:
+ """JSON file-based content store with file locking."""
+
+ def __init__(self, folder: Path, key: str) -> None:
+ self._folder = folder
+ self._key = key
+
+ @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:
+ data = json.loads(self._file.read_text(encoding="utf-8"))
+ except ValueError:
+ bad_format = True
+ except OSError:
+ _LOGGER.debug("failed to read %s", self._file, exc_info=True)
+ else:
+ _LOGGER.debug("got python info from %s", self._file)
+ return data
+ if bad_format:
+ with suppress(OSError):
+ self.remove()
+ return None
+
+ def write(self, content: dict) -> None:
+ self._folder.mkdir(parents=True, exist_ok=True)
+ self._file.write_text(json.dumps(content, sort_keys=True, indent=2), encoding="utf-8")
+ _LOGGER.debug("wrote python info at %s", self._file)
+
+ 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 # noqa: PLC0415
+
+ lock_path = self._folder / f"{self._key}.lock"
+ lock_path.parent.mkdir(parents=True, exist_ok=True)
+ with FileLock(str(lock_path)):
+ yield
+
+
+class DiskCache:
+ """File-system based Python interpreter info cache (``/py_info/4/.json``)."""
+
+ def __init__(self, root: Path) -> None:
+ self._root = root
+
+ @property
+ def _py_info_dir(self) -> Path:
+ return self._root / "py_info" / "4"
+
+ def py_info(self, path: Path) -> DiskContentStore:
+ key = sha256(str(path).encode("utf-8")).hexdigest()
+ return DiskContentStore(self._py_info_dir, key)
+
+ def py_info_clear(self) -> None:
+ folder = self._py_info_dir
+ if folder.exists():
+ 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."""
+
+ def exists(self) -> bool: # noqa: PLR6301
+ return False
+
+ def read(self) -> dict | None: # noqa: PLR6301
+ return None
+
+ def write(self, content: dict) -> None:
+ pass
+
+ def remove(self) -> None:
+ pass
+
+ @contextmanager
+ def locked(self) -> Generator[None]: # noqa: PLR6301
+ yield
+
+
+class NoOpCache(PyInfoCache):
+ """Cache that does nothing -- implements PyInfoCache protocol."""
+
+ def py_info(self, path: Path) -> NoOpContentStore: # noqa: ARG002, PLR6301
+ return NoOpContentStore()
+
+ def py_info_clear(self) -> None:
+ pass
+
+
+__all__ = [
+ "ContentStore",
+ "DiskCache",
+ "DiskContentStore",
+ "NoOpCache",
+ "NoOpContentStore",
+ "PyInfoCache",
+]
diff --git a/src/python_discovery/_cached_py_info.py b/src/python_discovery/_cached_py_info.py
new file mode 100644
index 0000000..1750abd
--- /dev/null
+++ b/src/python_discovery/_cached_py_info.py
@@ -0,0 +1,259 @@
+"""Acquire Python information via subprocess interrogation with multi-level caching."""
+
+from __future__ import annotations
+
+import hashlib
+import json
+import logging
+import os
+import pkgutil
+import secrets
+import subprocess # noqa: S404
+import sys
+import tempfile
+from collections import OrderedDict
+from contextlib import contextmanager
+from pathlib import Path
+from shlex import quote
+from subprocess import Popen # noqa: S404
+from typing import TYPE_CHECKING, Final
+
+from ._cache import NoOpCache
+from ._py_info import PythonInfo
+
+if TYPE_CHECKING:
+ from collections.abc import Generator, Mapping
+
+ from ._cache import ContentStore, PyInfoCache
+
+
+_CACHE: OrderedDict[Path, PythonInfo | Exception] = OrderedDict()
+_CACHE[Path(sys.executable)] = PythonInfo()
+_LOGGER: Final[logging.Logger] = logging.getLogger(__name__)
+
+
+def from_exe( # noqa: PLR0913
+ cls: type[PythonInfo],
+ cache: PyInfoCache | None,
+ exe: str,
+ env: Mapping[str, str] | None = None,
+ *,
+ raise_on_error: bool = True,
+ ignore_cache: bool = False,
+) -> PythonInfo | None:
+ env = os.environ if env is None else env
+ result = _get_from_cache(cls, cache, exe, env, ignore_cache=ignore_cache)
+ if isinstance(result, Exception):
+ if raise_on_error:
+ raise result
+ _LOGGER.info("%s", result)
+ result = None
+ return result
+
+
+def _get_from_cache(
+ cls: type[PythonInfo],
+ cache: PyInfoCache | None,
+ exe: str,
+ env: Mapping[str, str],
+ *,
+ ignore_cache: bool = True,
+) -> PythonInfo | Exception:
+ exe_path = Path(exe)
+ if not ignore_cache and exe_path in _CACHE:
+ result = _CACHE[exe_path]
+ else:
+ py_info = _get_via_file_cache(cls, cache, exe_path, exe, env)
+ result = _CACHE[exe_path] = py_info
+ if isinstance(result, PythonInfo):
+ result.executable = exe
+ return result
+
+
+def _get_via_file_cache(
+ cls: type[PythonInfo],
+ cache: PyInfoCache | None,
+ path: Path,
+ exe: str,
+ env: Mapping[str, str],
+) -> PythonInfo | Exception:
+ path_text = str(path)
+ try:
+ path_modified = path.stat().st_mtime
+ except OSError:
+ path_modified = -1
+ py_info_script = Path(Path(__file__).resolve()).parent / "_py_info.py"
+ try:
+ py_info_hash: str | None = hashlib.sha256(py_info_script.read_bytes()).hexdigest()
+ except OSError:
+ py_info_hash = None
+
+ resolved_cache = cache if cache is not None else NoOpCache()
+ py_info: PythonInfo | None = None
+ py_info_store = resolved_cache.py_info(path)
+ with py_info_store.locked():
+ if py_info_store.exists() and (data := py_info_store.read()) is not None:
+ of_path, of_st_mtime = data.get("path"), data.get("st_mtime")
+ of_content, of_hash = data.get("content"), data.get("hash")
+ if (
+ of_path == path_text
+ and of_st_mtime == path_modified
+ and of_hash == py_info_hash
+ and isinstance(of_content, dict)
+ ):
+ py_info = _load_cached_py_info(cls, py_info_store, of_content)
+ else:
+ py_info_store.remove()
+ if py_info is None:
+ failure, py_info = _run_subprocess(cls, exe, env)
+ if failure is not None:
+ _LOGGER.debug("first subprocess attempt failed for %s (%s), retrying", exe, failure)
+ failure, py_info = _run_subprocess(cls, exe, env)
+ if failure is not None:
+ return failure
+ if py_info is not None:
+ py_info_store.write({
+ "st_mtime": path_modified,
+ "path": path_text,
+ "content": py_info.to_dict(),
+ "hash": py_info_hash,
+ })
+ if py_info is None:
+ msg = f"{exe} failed to produce interpreter info"
+ return RuntimeError(msg)
+ return py_info
+
+
+def _load_cached_py_info(
+ cls: type[PythonInfo],
+ py_info_store: ContentStore,
+ content: dict,
+) -> PythonInfo | None:
+ try:
+ py_info = cls.from_dict(content.copy())
+ except (KeyError, TypeError):
+ py_info_store.remove()
+ return None
+ if (sys_exe := py_info.system_executable) is not None and not Path(sys_exe).exists():
+ py_info_store.remove()
+ return None
+ return py_info
+
+
+COOKIE_LENGTH: Final[int] = 32
+
+
+def gen_cookie() -> str:
+ return secrets.token_hex(COOKIE_LENGTH // 2)
+
+
+@contextmanager
+def _resolve_py_info_script() -> Generator[Path]:
+ py_info_script = Path(Path(__file__).resolve()).parent / "_py_info.py"
+ if py_info_script.is_file():
+ yield py_info_script
+ else:
+ data = pkgutil.get_data(__package__ or __name__, "_py_info.py")
+ if data is None:
+ msg = "cannot locate _py_info.py for subprocess interrogation"
+ raise FileNotFoundError(msg)
+ fd, tmp = tempfile.mkstemp(suffix=".py")
+ try:
+ os.write(fd, data)
+ os.close(fd)
+ yield Path(tmp)
+ finally:
+ Path(tmp).unlink()
+
+
+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,
+ env: Mapping[str, str],
+) -> tuple[Exception | None, PythonInfo | None]:
+ start_cookie = gen_cookie()
+ end_cookie = gen_cookie()
+ with _resolve_py_info_script() as py_info_script:
+ cmd = [exe, str(py_info_script), start_cookie, end_cookie]
+ env = dict(env)
+ env.pop("__PYVENV_LAUNCHER__", None)
+ env["PYTHONUTF8"] = "1"
+ _LOGGER.debug("get interpreter info via cmd: %s", LogCmd(cmd))
+ try:
+ process = Popen( # noqa: S603
+ cmd,
+ universal_newlines=True,
+ stdin=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ stdout=subprocess.PIPE,
+ env=env,
+ encoding="utf-8",
+ errors="backslashreplace",
+ )
+ out, err = process.communicate()
+ code = process.returncode
+ except OSError as os_error:
+ out, err, code = "", os_error.strerror, os_error.errno
+ if code != 0:
+ msg = f"{exe} with code {code}{f' out: {out!r}' if out else ''}{f' err: {err!r}' if err else ''}"
+ return RuntimeError(f"failed to query {msg}"), None
+ out, raw_out, out_starts, out_ends = _extract_between_cookies(out, start_cookie, end_cookie)
+ try:
+ result = cls.from_json(out)
+ result.executable = exe
+ except json.JSONDecodeError as exc:
+ _LOGGER.warning(
+ "subprocess %s returned invalid JSON; raw stdout %d chars, start cookie %s, end cookie %s, "
+ "parsed output %d chars: %r",
+ exe,
+ len(raw_out),
+ "found" if out_starts > -1 else "missing",
+ "found" if out_ends > -1 else "missing",
+ len(out),
+ out[:200] if out else "",
+ )
+ msg = f"{exe} returned invalid JSON (exit code {code}){f', stderr: {err!r}' if err else ''}"
+ failure = RuntimeError(msg)
+ failure.__cause__ = exc
+ return failure, None
+ return None, result
+
+
+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 clear(cache: PyInfoCache) -> None:
+ cache.py_info_clear()
+ _CACHE.clear()
+
+
+__all__ = [
+ "LogCmd",
+ "clear",
+ "from_exe",
+]
diff --git a/src/python_discovery/_compat.py b/src/python_discovery/_compat.py
new file mode 100644
index 0000000..f55b089
--- /dev/null
+++ b/src/python_discovery/_compat.py
@@ -0,0 +1,29 @@
+"""Platform compatibility utilities for Python discovery."""
+
+from __future__ import annotations
+
+import functools
+import logging
+import pathlib
+import tempfile
+from typing import Final
+
+_LOGGER: Final[logging.Logger] = logging.getLogger(__name__)
+
+
+@functools.lru_cache(maxsize=1)
+def fs_is_case_sensitive() -> bool:
+ with tempfile.NamedTemporaryFile(prefix="TmP") as tmp_file:
+ result = not pathlib.Path(tmp_file.name.lower()).exists()
+ _LOGGER.debug("filesystem is %scase-sensitive", "" if result else "not ")
+ 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
new file mode 100644
index 0000000..4e1228d
--- /dev/null
+++ b/src/python_discovery/_discovery.py
@@ -0,0 +1,308 @@
+from __future__ import annotations
+
+import logging
+import os
+import sys
+from contextlib import suppress
+from pathlib import Path
+from typing import TYPE_CHECKING, Final
+
+from platformdirs import user_data_path
+
+from ._compat import fs_path_id
+from ._py_info import PythonInfo
+from ._py_spec import PythonSpec
+
+if TYPE_CHECKING:
+ from collections.abc import Callable, Generator, Iterable, Mapping, Sequence
+
+ from ._cache import PyInfoCache
+
+_LOGGER: Final[logging.Logger] = logging.getLogger(__name__)
+IS_WIN: Final[bool] = sys.platform == "win32"
+
+
+def get_interpreter(
+ key: str | Sequence[str],
+ try_first_with: Iterable[str] | None = None,
+ cache: PyInfoCache | None = None,
+ env: Mapping[str, str] | None = None,
+) -> PythonInfo | None:
+ specs = [key] if isinstance(key, str) else key
+ for spec_str in specs:
+ if result := _find_interpreter(spec_str, try_first_with or (), cache, env):
+ return result
+ return None
+
+
+def _find_interpreter(
+ key: str,
+ try_first_with: Iterable[str],
+ cache: PyInfoCache | None = None,
+ env: Mapping[str, str] | None = None,
+) -> PythonInfo | None:
+ spec = PythonSpec.from_string_spec(key)
+ _LOGGER.info("find interpreter for spec %r", spec)
+ 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
+ continue
+ proposed_key = interpreter.system_executable, impl_must_match
+ if proposed_key in proposed_paths:
+ continue
+ _LOGGER.info("proposed %s", interpreter)
+ if interpreter.satisfies(spec, impl_must_match=impl_must_match):
+ _LOGGER.debug("accepted %s", interpreter)
+ return interpreter
+ proposed_paths.add(proposed_key)
+ 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)
+ return str(Path(path).absolute())
+
+
+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],
+ cache: PyInfoCache | None = None,
+ env: Mapping[str, str] | None = None,
+) -> Generator[tuple[PythonInfo | None, bool], None, None]:
+ env = os.environ if env is None else env
+ tested_exes: set[str] = set()
+ if spec.is_abs and spec.path is not None:
+ if exe_raw := _check_exe(spec.path, tested_exes): # pragma: no branch # first exe always new
+ yield PythonInfo.from_exe(exe_raw, cache, env=env), True
+ return
+
+ yield from _propose_explicit(spec, try_first_with, cache, env, tested_exes)
+ if spec.path is not None and spec.is_abs: # pragma: no cover # relative spec.path is never abs
+ return
+ yield from _propose_from_path(spec, cache, env, tested_exes)
+ yield from _propose_from_uv(cache, env)
+
+
+def _propose_explicit(
+ spec: PythonSpec,
+ try_first_with: Iterable[str],
+ cache: PyInfoCache | None,
+ env: Mapping[str, str],
+ tested_exes: set[str],
+) -> Generator[tuple[PythonInfo | None, bool], None, None]:
+ for py_exe in try_first_with:
+ if exe_raw := _check_exe(str(Path(py_exe).resolve()), tested_exes):
+ yield PythonInfo.from_exe(exe_raw, cache, env=env), True
+
+ if spec.path is not None:
+ if exe_raw := _check_exe(spec.path, tested_exes): # pragma: no branch
+ yield PythonInfo.from_exe(exe_raw, cache, env=env), True
+ else:
+ yield from _propose_current_and_windows(spec, cache, env, tested_exes)
+
+
+def _propose_current_and_windows(
+ spec: PythonSpec,
+ cache: PyInfoCache | None,
+ env: Mapping[str, str],
+ tested_exes: set[str],
+) -> Generator[tuple[PythonInfo | None, bool], None, None]:
+ current_python = PythonInfo.current_system(cache)
+ if _is_new_exe(str(current_python.executable), tested_exes):
+ yield current_python, True
+
+ if IS_WIN: # pragma: win32 cover
+ from ._windows import propose_interpreters as win_propose # noqa: PLC0415
+
+ for interpreter in win_propose(spec, cache, env):
+ if _is_new_exe(str(interpreter.executable), tested_exes):
+ yield interpreter, True
+
+
+def _propose_from_path(
+ spec: PythonSpec,
+ cache: PyInfoCache | None,
+ env: Mapping[str, str],
+ tested_exes: set[str],
+) -> Generator[tuple[PythonInfo | None, bool], None, None]:
+ find_candidates = path_exe_finder(spec)
+ for pos, path in enumerate(get_paths(env)):
+ _LOGGER.debug(LazyPathDump(pos, path, env))
+ for exe, impl_must_match in find_candidates(path):
+ exe_raw = str(exe)
+ if resolved := _resolve_shim(exe_raw, env):
+ _LOGGER.debug("resolved shim %s to %s", exe_raw, resolved)
+ 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:
+ yield interpreter, impl_must_match
+
+
+def _propose_from_uv(
+ cache: PyInfoCache | None,
+ env: Mapping[str, str],
+) -> Generator[tuple[PythonInfo | None, bool], None, None]:
+ if uv_python_dir := os.getenv("UV_PYTHON_INSTALL_DIR"):
+ uv_python_path = Path(uv_python_dir).expanduser()
+ elif xdg_data_home := os.getenv("XDG_DATA_HOME"):
+ uv_python_path = Path(xdg_data_home).expanduser() / "uv" / "python"
+ else:
+ uv_python_path = user_data_path("uv") / "python"
+
+ for exe_path in uv_python_path.glob("*/bin/python"): # pragma: no branch
+ interpreter = PathPythonInfo.from_exe(str(exe_path), cache, raise_on_error=False, env=env)
+ if interpreter is not None: # pragma: no branch
+ yield interpreter, True
+
+
+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:
+ for entry in map(Path, path.split(os.pathsep)):
+ with suppress(OSError):
+ if 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 file_path.is_dir():
+ continue
+ if IS_WIN: # pragma: win32 cover
+ pathext = self.env.get("PATHEXT", ".COM;.EXE;.BAT;.CMD").split(";")
+ if not any(file_path.name.upper().endswith(ext) for ext in pathext):
+ continue
+ elif not (file_path.stat().st_mode & os.X_OK):
+ continue
+ except OSError:
+ pass
+ content += " "
+ content += file_path.name
+ return content
+
+
+def path_exe_finder(spec: PythonSpec) -> 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")
+ direct = spec.str_spec
+ if sys.platform == "win32": # pragma: win32 cover
+ direct = f"{direct}.exe"
+
+ def path_exes(path: Path) -> Generator[tuple[Path, bool], None, None]:
+ direct_path = path / direct
+ if direct_path.exists():
+ yield direct_path, False
+
+ for exe in path.iterdir():
+ match = pat.fullmatch(exe.name)
+ if match:
+ yield exe.absolute(), match["impl"] == "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 _resolve_shim_to_binary(exe_name: str, versions_dir: str, env: Mapping[str, str]) -> str | None:
+ for version in _active_versions(env):
+ 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:
+ candidate = os.path.join(current, ".python-version") if Path(current).is_dir() else current
+ if Path(candidate).is_file():
+ with Path(candidate).open(encoding="utf-8") as fh:
+ if versions := [v for line in fh if (v := line.strip()) and not v.startswith("#")]:
+ return versions
+ if not search_parents:
+ return None
+ parent = Path(current).parent
+ if parent == current:
+ return None
+ current = parent
+
+
+class PathPythonInfo(PythonInfo):
+ """python info from path."""
+
+
+__all__ = [
+ "LazyPathDump",
+ "PathPythonInfo",
+ "get_interpreter",
+ "get_paths",
+ "propose_interpreters",
+]
diff --git a/src/python_discovery/_py_info.py b/src/python_discovery/_py_info.py
new file mode 100644
index 0000000..178d740
--- /dev/null
+++ b/src/python_discovery/_py_info.py
@@ -0,0 +1,726 @@
+"""Concrete Python interpreter information, also used as subprocess interrogation script (stdlib only)."""
+
+from __future__ import annotations
+
+import json
+import logging
+import os
+import platform
+import re
+import struct
+import sys
+import sysconfig
+import warnings
+from collections import OrderedDict
+from string import digits
+from typing import TYPE_CHECKING, ClassVar, Final, NamedTuple
+
+if TYPE_CHECKING:
+ from collections.abc import Generator, Mapping
+
+ from ._cache import PyInfoCache
+ from ._py_spec import PythonSpec
+
+
+class VersionInfo(NamedTuple):
+ major: int
+ minor: int
+ micro: int
+ releaselevel: str
+ serial: int
+
+
+_LOGGER: Final[logging.Logger] = logging.getLogger(__name__)
+
+
+def _get_path_extensions() -> list[str]:
+ return list(OrderedDict.fromkeys(["", *os.environ.get("PATHEXT", "").lower().split(os.pathsep)]))
+
+
+EXTENSIONS: Final[list[str]] = _get_path_extensions()
+_32BIT_POINTER_SIZE: Final[int] = 4
+_CONF_VAR_RE: Final[re.Pattern[str]] = re.compile(
+ r"""
+ \{ \w+ } # sysconfig variable placeholder like {base}
+ """,
+ re.VERBOSE,
+)
+
+
+class PythonInfo: # noqa: PLR0904
+ """Contains information for a Python interpreter."""
+
+ def __init__(self) -> None:
+ self._init_identity()
+ self._init_prefixes()
+ self._init_schemes()
+ self._init_sysconfig()
+
+ def _init_identity(self) -> None:
+ self.platform = sys.platform
+ self.implementation = platform.python_implementation()
+ if self.implementation == "PyPy":
+ self.pypy_version_info = tuple(sys.pypy_version_info) # ty: ignore[unresolved-attribute] # pypy only
+
+ 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.sysconfig_platform = sysconfig.get_platform()
+ self.version_nodot = sysconfig.get_config_var("py_version_nodot")
+ self.version = sys.version
+ self.os = os.name
+ self.free_threaded = sysconfig.get_config_var("Py_GIL_DISABLED") == 1
+
+ def _init_prefixes(self) -> None:
+ def abs_path(value: str | None) -> str | None:
+ return None if value is None else os.path.abspath(value)
+
+ self.prefix = abs_path(getattr(sys, "prefix", None))
+ self.base_prefix = abs_path(getattr(sys, "base_prefix", None))
+ self.real_prefix = abs_path(getattr(sys, "real_prefix", None))
+ self.base_exec_prefix = abs_path(getattr(sys, "base_exec_prefix", None))
+ self.exec_prefix = abs_path(getattr(sys, "exec_prefix", None))
+
+ self.executable = abs_path(sys.executable)
+ self.original_executable = abs_path(self.executable)
+ self.system_executable = self._fast_get_system_executable()
+
+ try:
+ __import__("venv")
+ has = True
+ except ImportError: # pragma: no cover # venv is always available in standard CPython
+ has = False
+ self.has_venv = has
+ self.path = sys.path
+ self.file_system_encoding = sys.getfilesystemencoding()
+ self.stdout_encoding = getattr(sys.stdout, "encoding", None)
+
+ def _init_schemes(self) -> None:
+ 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) -> None:
+ 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
+
+ confs = {
+ k: (self.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() -> tuple[
+ str | None,
+ str | None,
+ ]: # 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 # noqa: PLC0415
+ except ImportError:
+ pass
+ else:
+ try:
+ tcl = tk.Tcl()
+ tcl_lib = tcl.eval("info library")
+
+ # Try to get TK library path directly first
+ try:
+ tk_lib = tcl.eval("set tk_library")
+ if tk_lib and os.path.isdir(tk_lib):
+ pass # We found it directly
+ else:
+ tk_lib = None # Reset if invalid
+ except tk.TclError:
+ tk_lib = None
+
+ # If direct query failed, try constructing the path
+ if tk_lib is None:
+ tk_version = tcl.eval("package require Tk")
+ tcl_parent = os.path.dirname(tcl_lib)
+
+ # Try different version formats
+ version_variants = [
+ tk_version, # Full version like "8.6.12"
+ ".".join(tk_version.split(".")[:2]), # Major.minor like "8.6"
+ tk_version.split(".")[0], # Just major like "8"
+ ]
+
+ for version in version_variants:
+ tk_lib_path = os.path.join(tcl_parent, f"tk{version}")
+ if not os.path.isdir(tk_lib_path):
+ continue
+ if os.path.exists(os.path.join(tk_lib_path, "tk.tcl")):
+ tk_lib = tk_lib_path
+ break
+
+ except tk.TclError:
+ pass
+
+ return tcl_lib, tk_lib
+
+ def _fast_get_system_executable(self) -> str | None:
+ """Try to get the system executable by just looking at properties."""
+ # if we're not in a virtual environment, this is already a system python, so return the original executable
+ # note we must choose the original and not the pure executable as shim scripts might throw us off
+ if not (self.real_prefix or (self.base_prefix is not None and self.base_prefix != self.prefix)):
+ return self.original_executable
+
+ # if this is NOT a virtual environment, can't determine easily, bail out
+ if self.real_prefix is not None:
+ return None
+
+ base_executable = getattr(sys, "_base_executable", None) # some platforms may set this to help us
+ if base_executable is None: # use the saved system executable if present
+ return None
+
+ # we know we're in a virtual environment, can not be us
+ if sys.executable == base_executable:
+ return None
+
+ # We're not in a venv and base_executable exists; use it directly
+ if os.path.exists(base_executable): # pragma: >=3.11 cover
+ return base_executable
+
+ # Try fallback for POSIX virtual environments
+ return self._try_posix_fallback_executable(base_executable) # pragma: >=3.11 cover
+
+ def _try_posix_fallback_executable(self, base_executable: str) -> str | None:
+ """Find a versioned Python binary as fallback for POSIX virtual environments."""
+ major, minor = self.version_info.major, self.version_info.minor
+ if self.os != "posix" or (major, minor) < (3, 11):
+ return None
+
+ # search relative to the directory of sys._base_executable
+ base_dir = os.path.dirname(base_executable)
+ candidates = [f"python{major}", f"python{major}.{minor}"]
+ if self.implementation == "PyPy":
+ candidates.extend(["pypy", "pypy3", f"pypy{major}", f"pypy{major}.{minor}"])
+
+ for candidate in candidates:
+ full_path = os.path.join(base_dir, candidate)
+ if os.path.exists(full_path):
+ return full_path
+
+ return None # in this case we just can't tell easily without poking around FS and calling them, bail
+
+ def install_path(self, key: str) -> str:
+ """Return the relative installation path for a given installation scheme *key*."""
+ 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
+
+ @staticmethod
+ def _distutils_install() -> dict[str, str]:
+ # use distutils primarily because that's what pip does
+ # https://github.com/pypa/pip/blob/main/src/pip/_internal/locations.py#L95
+ # note here we don't import Distribution directly to allow setuptools to patch it
+ with warnings.catch_warnings(): # disable warning for PEP-632
+ warnings.simplefilter("ignore")
+ try:
+ from distutils import dist # noqa: PLC0415 # ty: ignore[unresolved-import]
+ from distutils.command.install import SCHEME_KEYS # noqa: PLC0415 # ty: ignore[unresolved-import]
+ except ImportError: # pragma: no cover # if removed or not installed ignore
+ return {}
+
+ distribution = dist.Distribution({
+ "script_args": "--no-user-cfg",
+ }) # conf files not parsed so they do not hijack paths
+ if hasattr(sys, "_framework"): # pragma: no cover # macOS framework builds only
+ sys._framework = None # noqa: SLF001 # disable macOS static paths for framework
+
+ with warnings.catch_warnings(): # disable warning for PEP-632
+ warnings.simplefilter("ignore")
+ install = distribution.get_command_obj("install", create=True)
+
+ install.prefix = os.sep # paths generated are relative to prefix that contains the path sep
+ install.finalize_options()
+ return {key: (getattr(install, f"install_{key}")[1:]).lstrip(os.sep) for key in SCHEME_KEYS}
+
+ @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])
+
+ @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
+
+ def sysconfig_path(self, key: str, config_var: dict[str, str] | None = None, sep: str = os.sep) -> str:
+ """Return the sysconfig install path for a scheme *key*, optionally substituting config variables."""
+ pattern = self.sysconfig_paths.get(key)
+ if pattern is None:
+ return ""
+ 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_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()
+ },
+ )
+ if not os.path.exists(path): # pragma: no cover # broken packaging fallback
+ fallback = os.path.join(self.prefix, os.path.dirname(self.install_path("headers")))
+ if os.path.exists(fallback):
+ path = fallback
+ return path
+
+ @property
+ def system_prefix(self) -> str:
+ """The prefix of the system Python this interpreter is based on."""
+ return self.real_prefix or self.base_prefix or self.prefix
+
+ @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
+
+ def __repr__(self) -> str:
+ return "{}({!r})".format(
+ self.__class__.__name__,
+ {k: v for k, v in self.__dict__.items() if not k.startswith("_")},
+ )
+
+ def __str__(self) -> str:
+ return "{}({})".format(
+ self.__class__.__name__,
+ ", ".join(
+ f"{k}={v}"
+ for k, v in (
+ ("spec", self.spec),
+ (
+ "system"
+ if self.system_executable is not None and self.system_executable != self.executable
+ else None,
+ self.system_executable,
+ ),
+ (
+ "original"
+ if self.original_executable not in {self.system_executable, self.executable}
+ else None,
+ self.original_executable,
+ ),
+ ("exe", self.executable),
+ ("platform", self.platform),
+ ("version", repr(self.version)),
+ ("encoding_fs_io", f"{self.file_system_encoding}-{self.stdout_encoding}"),
+ )
+ if k is not None
+ ),
+ )
+
+ @property
+ def machine(self) -> str:
+ """Return the instruction set architecture (ISA) derived from :func:`sysconfig.get_platform`."""
+ plat = self.sysconfig_platform
+ if plat is None:
+ return "unknown"
+ if plat == "win32":
+ return "x86"
+ isa = plat.rsplit("-", 1)[-1]
+ if isa == "universal2":
+ isa = platform.machine().lower()
+ return normalize_isa(isa)
+
+ @property
+ def spec(self) -> str:
+ """A specification string identifying this interpreter (e.g. ``CPython3.13.2-64-arm64``)."""
+ return "{}{}{}-{}-{}".format(
+ self.implementation,
+ ".".join(str(i) for i in self.version_info),
+ "t" if self.free_threaded else "",
+ self.architecture,
+ self.machine,
+ )
+
+ @classmethod
+ def clear_cache(cls, cache: PyInfoCache) -> None:
+ """Clear all cached interpreter information from *cache*."""
+ from ._cached_py_info import clear # noqa: PLC0415
+
+ clear(cache)
+ cls._cache_exe_discovery.clear()
+
+ def satisfies(self, spec: PythonSpec, *, impl_must_match: bool) -> bool: # noqa: PLR0911
+ """Check if a given specification can be satisfied by this python interpreter instance."""
+ if spec.path and not self._satisfies_path(spec):
+ return False
+ if impl_must_match and not self._satisfies_implementation(spec):
+ return False
+ if spec.architecture is not None and spec.architecture != self.architecture:
+ return False
+ if spec.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._satisfies_version_specifier(spec):
+ return False
+ return all(
+ req is None or our is None or our == req
+ for our, req in zip(self.version_info[0:3], (spec.major, spec.minor, spec.micro))
+ )
+
+ def _satisfies_path(self, spec: PythonSpec) -> bool:
+ if self.executable == os.path.abspath(spec.path):
+ return True
+ if spec.is_abs:
+ return True
+ basename = os.path.basename(self.original_executable)
+ spec_path = spec.path
+ if sys.platform == "win32":
+ basename, suffix = os.path.splitext(basename)
+ spec_path = spec_path[: -len(suffix)] if suffix and spec_path.endswith(suffix) else spec_path
+ return basename == spec_path
+
+ 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
+ return True
+ version_info = self.version_info
+ release = f"{version_info.major}.{version_info.minor}.{version_info.micro}"
+ if version_info.releaselevel != "final":
+ suffix = {"alpha": "a", "beta": "b", "candidate": "rc"}.get(version_info.releaselevel)
+ if suffix is not None: # pragma: no branch # releaselevel is always alpha/beta/candidate here
+ release = f"{release}{suffix}{version_info.serial}"
+ return spec.version_specifier.contains(release)
+
+ _current_system = None
+ _current = None
+
+ @classmethod
+ def current(cls, cache: PyInfoCache | None = None) -> PythonInfo:
+ """Locate the current host interpreter information."""
+ if cls._current is None:
+ result = cls.from_exe(sys.executable, cache, raise_on_error=True, resolve_to_host=False)
+ if result is None:
+ msg = "failed to query current Python interpreter"
+ raise RuntimeError(msg)
+ cls._current = result
+ return cls._current
+
+ @classmethod
+ def current_system(cls, cache: PyInfoCache | None = None) -> PythonInfo:
+ """Locate the current system interpreter information, resolving through any virtualenv layers."""
+ 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:
+ return json.dumps(self.to_dict(), indent=2)
+
+ def to_dict(self) -> dict[str, object]:
+ data = {var: (getattr(self, var) if var != "_creators" else None) for var in vars(self)}
+ 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( # noqa: PLR0913
+ 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."""
+ from ._cached_py_info import from_exe # noqa: PLC0415
+
+ 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)
+
+ if isinstance(proposed, PythonInfo) and resolve_to_host:
+ try:
+ proposed = proposed.resolve_to_system(cache, proposed)
+ 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:
+ raw = json.loads(payload)
+ return cls.from_dict(raw.copy())
+
+ @classmethod
+ def from_dict(cls, data: dict[str, object]) -> PythonInfo:
+ data["version_info"] = VersionInfo(**data["version_info"]) # restore this to a named tuple structure
+ result = cls()
+ result.__dict__ = data.copy()
+ return result
+
+ @classmethod
+ def resolve_to_system(cls, cache: PyInfoCache | None, target: PythonInfo) -> PythonInfo:
+ start_executable = target.executable
+ prefixes = OrderedDict()
+ while target.system_executable is None:
+ prefix = target.real_prefix or target.base_prefix or target.prefix
+ if prefix 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] = target
+ target = target.discover_exe(cache, prefix=prefix, exact=False)
+ if target.executable != target.system_executable:
+ resolved = cls.from_exe(target.system_executable, cache)
+ if resolved is not None:
+ target = resolved
+ target.executable = start_executable
+ return target
+
+ _cache_exe_discovery: ClassVar[dict[tuple[str, bool], PythonInfo]] = {}
+
+ def discover_exe(
+ self,
+ cache: PyInfoCache,
+ prefix: str,
+ *,
+ exact: bool = True,
+ env: Mapping[str, str] | None = None,
+ ) -> PythonInfo:
+ """Discover a matching Python executable under a given *prefix* directory."""
+ key = prefix, exact
+ if key in self._cache_exe_discovery and prefix:
+ _LOGGER.debug("discover exe from cache %s - exact %s: %r", prefix, exact, self._cache_exe_discovery[key])
+ return self._cache_exe_discovery[key]
+ _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
+ for folder in possible_folders:
+ for name in possible_names:
+ info = self._check_exe(cache, folder, name, discovered, env, exact=exact)
+ if info is not None:
+ self._cache_exe_discovery[key] = info
+ return info
+ if exact is False and discovered:
+ info = self._select_most_likely(discovered, self)
+ folders = os.pathsep.join(possible_folders)
+ self._cache_exe_discovery[key] = info
+ _LOGGER.debug("no exact match found, chosen most similar of %s within base folders %s", info, folders)
+ return info
+ msg = "failed to detect {} in {}".format("|".join(possible_names), os.pathsep.join(possible_folders))
+ raise RuntimeError(msg)
+
+ def _check_exe( # noqa: PLR0913
+ self,
+ cache: PyInfoCache | None,
+ folder: str,
+ name: str,
+ discovered: list[PythonInfo],
+ env: Mapping[str, str],
+ *,
+ exact: bool,
+ ) -> PythonInfo | None:
+ exe_path = os.path.join(folder, name)
+ if not os.path.exists(exe_path):
+ return None
+ info = self.from_exe(exe_path, cache, resolve_to_host=False, raise_on_error=False, env=env)
+ if info is None: # ignore if for some reason we can't query
+ return None
+ for item in ["implementation", "architecture", "machine", "version_info"]:
+ found = getattr(info, item)
+ searched = getattr(self, item)
+ if found != searched:
+ if item == "version_info":
+ found, searched = ".".join(str(i) for i in found), ".".join(str(i) for i in searched)
+ executable = info.executable
+ _LOGGER.debug("refused interpreter %s because %s differs %s != %s", executable, item, found, searched)
+ if exact is False:
+ discovered.append(info)
+ break
+ else:
+ return info
+ return None
+
+ @staticmethod
+ def _select_most_likely(discovered: list[PythonInfo], target: PythonInfo) -> PythonInfo:
+ def sort_by(info: PythonInfo) -> int:
+ # we need to setup some priority of traits, this is as follows:
+ # implementation, major, minor, architecture, machine, micro, tag, serial
+ matches = [
+ info.implementation == target.implementation,
+ info.version_info.major == target.version_info.major,
+ info.version_info.minor == target.version_info.minor,
+ info.architecture == target.architecture,
+ info.machine == target.machine,
+ info.version_info.micro == target.version_info.micro,
+ info.version_info.releaselevel == target.version_info.releaselevel,
+ info.version_info.serial == target.version_info.serial,
+ ]
+ return sum((1 << pos if match else 0) for pos, match in enumerate(reversed(matches)))
+
+ 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()
+ for name in self._possible_base():
+ for at in (3, 2, 1, 0):
+ version = ".".join(str(i) for i in self.version_info[:at])
+ mods = [""]
+ if self.free_threaded:
+ mods.append("t")
+ for mod in mods:
+ for arch in [f"-{self.architecture}", ""]:
+ for ext in EXTENSIONS:
+ candidate = f"{name}{version}{mod}{arch}{ext}"
+ name_candidate[candidate] = 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 # noqa: PLC0415
+
+ if fs_is_case_sensitive(): # pragma: no branch
+ if base != lower:
+ yield base
+ upper = base.upper()
+ if upper != base:
+ yield upper
+
+
+def normalize_isa(isa: str) -> str:
+ low = isa.lower()
+ return {"amd64": "x86_64", "aarch64": "arm64"}.get(low, low)
+
+
+def _main() -> None: # pragma: no cover
+ argv = sys.argv[1:]
+
+ if len(argv) >= 1:
+ start_cookie = argv[0]
+ argv = argv[1:]
+ else:
+ start_cookie = ""
+
+ if len(argv) >= 1:
+ end_cookie = argv[0]
+ argv = argv[1:]
+ else:
+ end_cookie = ""
+
+ sys.argv = sys.argv[:1] + argv
+
+ result = PythonInfo().to_json()
+ sys.stdout.write("".join((start_cookie[::-1], result, end_cookie[::-1])))
+ sys.stdout.flush()
+
+
+if __name__ == "__main__":
+ _main()
diff --git a/src/python_discovery/_py_spec.py b/src/python_discovery/_py_spec.py
new file mode 100644
index 0000000..ae48335
--- /dev/null
+++ b/src/python_discovery/_py_spec.py
@@ -0,0 +1,235 @@
+"""A Python specification is an abstract requirement definition of an interpreter."""
+
+from __future__ import annotations
+
+import contextlib
+import pathlib
+import re
+from typing import Final
+
+from ._py_info import normalize_isa
+from ._specifier import SimpleSpecifier, SimpleSpecifierSet, SimpleVersion
+
+PATTERN = re.compile(
+ r"""
+ ^
+ (?P[a-zA-Z]+)? # implementation (e.g. cpython, pypy)
+ (?P[0-9.]+)? # version (e.g. 3.12, 3.12.1)
+ (?Pt)? # free-threaded flag
+ (?:-(?P32|64))? # architecture bitness
+ (?:-(?P[a-zA-Z0-9_]+))? # ISA (e.g. arm64, x86_64)
+ $
+ """,
+ re.VERBOSE,
+)
+SPECIFIER_PATTERN = re.compile(
+ r"""
+ ^
+ (?:(?P[A-Za-z]+)\s*)? # optional implementation prefix
+ (?P(?:===|==|~=|!=|<=|>=|<|>).+) # PEP 440 version specifier
+ $
+ """,
+ re.VERBOSE,
+)
+
+_MAX_VERSION_PARTS: Final[int] = 3
+_SINGLE_DIGIT_MAX: Final[int] = 9
+
+SpecifierSet = SimpleSpecifierSet
+Version = SimpleVersion
+InvalidSpecifier = ValueError
+InvalidVersion = ValueError
+
+
+def _int_or_none(val: str | None) -> int | None:
+ return None if val is None else int(val)
+
+
+def _parse_version_parts(version: str) -> tuple[int | None, int | None, int | None]:
+ versions = tuple(int(i) for i in version.split(".") if i)
+ 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: # noqa: PLR2004
+ return versions[0], versions[1], None
+ version_data = versions[0]
+ major = int(str(version_data)[0])
+ minor = int(str(version_data)[1:]) if version_data > _SINGLE_DIGIT_MAX else None
+ return major, minor, None
+
+
+def _parse_spec_pattern(string_spec: str) -> PythonSpec | None:
+ if not (match := re.match(PATTERN, string_spec)):
+ return None
+ groups = match.groupdict()
+ version = groups["version"]
+ major, minor, micro, threaded = None, None, None, None
+ if version is not None:
+ try:
+ major, minor, micro = _parse_version_parts(version)
+ except ValueError:
+ return None
+ threaded = bool(groups["threaded"])
+ impl = groups["impl"]
+ if impl in {"py", "python"}:
+ impl = None
+ arch = _int_or_none(groups["arch"])
+ machine = groups.get("machine")
+ if machine is not None:
+ machine = normalize_isa(machine)
+ return PythonSpec(string_spec, impl, major, minor, micro, arch, None, free_threaded=threaded, machine=machine)
+
+
+def _parse_specifier(string_spec: str) -> PythonSpec | None:
+ if not (specifier_match := SPECIFIER_PATTERN.match(string_spec.strip())):
+ return None
+ if SpecifierSet is None: # pragma: no cover
+ return None
+ impl = specifier_match.group("impl")
+ spec_text = specifier_match.group("spec").strip()
+ try:
+ version_specifier = SpecifierSet.from_string(spec_text)
+ except InvalidSpecifier: # pragma: no cover
+ return None
+ if impl in {"py", "python"}:
+ impl = None
+ return PythonSpec(string_spec, impl, None, None, None, None, None, version_specifier=version_specifier)
+
+
+class PythonSpec:
+ """Contains specification about a Python Interpreter."""
+
+ def __init__( # noqa: PLR0913, PLR0917
+ self,
+ str_spec: str,
+ implementation: str | None,
+ major: int | None,
+ minor: int | None,
+ micro: int | None,
+ architecture: int | None,
+ path: str | None,
+ *,
+ free_threaded: bool | None = None,
+ machine: str | None = None,
+ version_specifier: SpecifierSet | None = None,
+ ) -> None:
+ self.str_spec = str_spec
+ self.implementation = implementation
+ self.major = major
+ self.minor = minor
+ self.micro = micro
+ self.free_threaded = free_threaded
+ self.architecture = architecture
+ self.machine = machine
+ self.path = path
+ self.version_specifier = version_specifier
+
+ @classmethod
+ def from_string_spec(cls, string_spec: str) -> PythonSpec:
+ """Parse a string specification into a PythonSpec."""
+ if pathlib.Path(string_spec).is_absolute():
+ return cls(string_spec, None, None, None, None, None, string_spec)
+ if result := _parse_spec_pattern(string_spec):
+ return result
+ if result := _parse_specifier(string_spec):
+ return result
+ return cls(string_spec, None, None, None, None, None, string_spec)
+
+ def generate_re(self, *, windows: bool) -> re.Pattern:
+ """Generate a regular expression for matching against a filename."""
+ version = r"{}(\.{}(\.{})?)?".format(
+ *(r"\d+" if v is None else v for v in (self.major, self.minor, self.micro)),
+ )
+ impl = "python" if self.implementation is None else f"python|{re.escape(self.implementation)}"
+ mod = "t?" if self.free_threaded else ""
+ suffix = r"\.exe" if windows else ""
+ version_conditional = "?" if windows or self.major is None else ""
+ return re.compile(
+ rf"(?P{impl})(?P{version}{mod}){version_conditional}{suffix}$",
+ flags=re.IGNORECASE,
+ )
+
+ @property
+ def is_abs(self) -> bool:
+ 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."""
+ components: list[int] = []
+ for part in (self.major, self.minor, self.micro):
+ if part is None:
+ break
+ components.append(part)
+ if not components:
+ return True
+
+ version_str = ".".join(str(part) for part in components)
+ if spec.version_specifier is None:
+ return True
+ with contextlib.suppress(InvalidVersion):
+ Version.from_string(version_str)
+ for item in spec.version_specifier:
+ required_precision = self._get_required_precision(item)
+ if required_precision is None or len(components) < required_precision:
+ continue
+ if not item.contains(version_str):
+ return False
+ return True
+
+ @staticmethod
+ def _get_required_precision(item: SimpleSpecifier) -> int | None:
+ """Get the required precision for a specifier item."""
+ if item.version is None:
+ return None
+ with contextlib.suppress(AttributeError, ValueError):
+ return len(item.version.release)
+ return None
+
+ def satisfies(self, spec: PythonSpec) -> bool: # noqa: PLR0911
+ """Check if this spec is compatible with the given *spec* (e.g. PEP-514 on Windows)."""
+ 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",
+ "version_specifier",
+ )
+ return f"{name}({', '.join(f'{k}={getattr(self, k)}' for k in params if getattr(self, k) is not None)})"
+
+
+__all__ = [
+ "InvalidSpecifier",
+ "InvalidVersion",
+ "PythonSpec",
+ "SpecifierSet",
+ "Version",
+]
diff --git a/src/python_discovery/_specifier.py b/src/python_discovery/_specifier.py
new file mode 100644
index 0000000..73853b3
--- /dev/null
+++ b/src/python_discovery/_specifier.py
@@ -0,0 +1,264 @@
+"""Version specifier support using only standard library (PEP 440 compatible)."""
+
+from __future__ import annotations
+
+import contextlib
+import operator
+import re
+import sys
+from dataclasses import dataclass
+from typing import TYPE_CHECKING, Final
+
+_DC_KW = {"frozen": True, "kw_only": True, "slots": True} if sys.version_info >= (3, 10) else {"frozen": True}
+
+if TYPE_CHECKING:
+ from collections.abc import Iterator
+
+_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
+ $
+ """,
+ re.VERBOSE,
+)
+_SPECIFIER_RE: Final[re.Pattern[str]] = re.compile(
+ r"""
+ ^
+ (===|==|~=|!=|<=|>=|<|>) # operator
+ \s*
+ (.+) # version string
+ $
+ """,
+ re.VERBOSE,
+)
+_PRE_ORDER: Final[dict[str, int]] = {"a": 1, "b": 2, "rc": 3}
+
+
+@dataclass(**_DC_KW)
+class SimpleVersion:
+ """Simple PEP 440-like version parser using only standard library."""
+
+ version_str: str
+ major: int
+ minor: int
+ micro: int
+ pre_type: str | None
+ pre_num: int | None
+ release: tuple[int, int, int]
+
+ @classmethod
+ def from_string(cls, version_str: str) -> SimpleVersion:
+ 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
+ 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,
+ release=(major, minor, micro),
+ )
+
+ def __eq__(self, other: object) -> bool:
+ if not isinstance(other, SimpleVersion):
+ return NotImplemented
+ return self.release == other.release and self.pre_type == other.pre_type and self.pre_num == other.pre_num
+
+ def __hash__(self) -> int:
+ return hash((self.release, self.pre_type, self.pre_num))
+
+ def __lt__(self, other: object) -> bool: # noqa: PLR0911
+ if not isinstance(other, SimpleVersion):
+ return NotImplemented
+ if self.release != other.release:
+ return self.release < other.release
+ if self.pre_type is None and other.pre_type is None:
+ return False
+ if self.pre_type is None:
+ 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]
+ return (self.pre_num or 0) < (other.pre_num or 0)
+
+ def __le__(self, other: object) -> bool:
+ return self == other or self < other
+
+ def __gt__(self, other: object) -> bool:
+ if not isinstance(other, SimpleVersion):
+ return NotImplemented
+ return not self <= other
+
+ def __ge__(self, other: object) -> bool:
+ return not self < other
+
+ def __str__(self) -> str:
+ return self.version_str
+
+ def __repr__(self) -> str:
+ return f"SimpleVersion('{self.version_str}')"
+
+
+@dataclass(**_DC_KW)
+class SimpleSpecifier:
+ """Simple PEP 440-like version specifier using only standard library."""
+
+ spec_str: str
+ operator: str
+ version_str: str
+ is_wildcard: bool
+ wildcard_precision: int | None
+ version: SimpleVersion | None
+
+ @classmethod
+ def from_string(cls, spec_str: str) -> SimpleSpecifier:
+ 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()
+ is_wildcard = version_str.endswith(".*")
+ wildcard_precision: int | None = None
+ if is_wildcard:
+ version_str = version_str[:-2]
+ wildcard_precision = len(version_str.split("."))
+ try:
+ version = SimpleVersion.from_string(version_str)
+ except ValueError:
+ version = None
+ return cls(
+ spec_str=stripped,
+ operator=op,
+ version_str=version_str,
+ is_wildcard=is_wildcard,
+ wildcard_precision=wildcard_precision,
+ version=version,
+ )
+
+ def contains(self, version_str: str) -> bool:
+ """Check if a version string satisfies this specifier."""
+ try:
+ candidate = SimpleVersion.from_string(version_str) if isinstance(version_str, str) else version_str
+ except ValueError:
+ return False
+ if self.version is None:
+ return False
+ if self.is_wildcard:
+ return self._check_wildcard(candidate)
+ 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.operator == "==":
+ return candidate.release[: self.wildcard_precision] == self.version.release[: self.wildcard_precision]
+ if self.operator == "!=":
+ return candidate.release[: self.wildcard_precision] != self.version.release[: self.wildcard_precision]
+ return False
+
+ def _check_standard(self, candidate: SimpleVersion) -> bool:
+ if self.version is None: # pragma: no branch
+ return False # pragma: no cover
+ if self.operator == "===":
+ return str(candidate) == str(self.version)
+ if self.operator == "~=":
+ return self._check_compatible_release(candidate)
+ cmp_ops = {
+ "==": operator.eq,
+ "!=": operator.ne,
+ "<": operator.lt,
+ "<=": operator.le,
+ ">": operator.gt,
+ ">=": operator.ge,
+ }
+ if self.operator in cmp_ops:
+ return cmp_ops[self.operator](candidate, self.version)
+ return False
+
+ def _check_compatible_release(self, candidate: SimpleVersion) -> bool:
+ if self.version is None:
+ return False
+ if candidate < self.version:
+ return False
+ if len(self.version.release) >= 2: # noqa: PLR2004 # pragma: no branch # SimpleVersion always has 3-part release
+ upper_parts = list(self.version.release[:-1])
+ upper_parts[-1] += 1
+ upper = SimpleVersion.from_string(".".join(str(p) for p in upper_parts))
+ return candidate < upper
+ return True # pragma: no cover
+
+ 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:
+ """Simple PEP 440-like specifier set using only standard library."""
+
+ specifiers_str: str
+ specifiers: tuple[SimpleSpecifier, ...]
+
+ @classmethod
+ def from_string(cls, specifiers_str: str = "") -> SimpleSpecifierSet:
+ stripped = specifiers_str.strip()
+ specs: list[SimpleSpecifier] = []
+ if stripped:
+ for spec_item in stripped.split(","):
+ item = spec_item.strip()
+ if item:
+ 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."""
+ 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)
+
+ def __eq__(self, other: object) -> bool:
+ if not isinstance(other, SimpleSpecifierSet):
+ return NotImplemented
+ return self.specifiers_str == other.specifiers_str
+
+ def __hash__(self) -> int:
+ return hash(self.specifiers_str)
+
+ def __str__(self) -> str:
+ return self.specifiers_str
+
+ def __repr__(self) -> str:
+ return f"SimpleSpecifierSet('{self.specifiers_str}')"
+
+
+__all__ = [
+ "SimpleSpecifier",
+ "SimpleSpecifierSet",
+ "SimpleVersion",
+]
diff --git a/src/python_discovery/_windows/__init__.py b/src/python_discovery/_windows/__init__.py
new file mode 100644
index 0000000..5d06b8a
--- /dev/null
+++ b/src/python_discovery/_windows/__init__.py
@@ -0,0 +1,13 @@
+"""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/_pep514.py b/src/python_discovery/_windows/_pep514.py
new file mode 100644
index 0000000..f9f7e40
--- /dev/null
+++ b/src/python_discovery/_windows/_pep514.py
@@ -0,0 +1,222 @@
+"""Implement https://www.python.org/dev/peps/pep-0514/ to discover interpreters - Windows only."""
+
+from __future__ import annotations
+
+import logging
+import os
+import re
+import sys
+import winreg
+from logging import basicConfig, getLogger
+from typing import TYPE_CHECKING, Any, Final
+
+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"""
+ ^
+ (\d+) # bitness number
+ bit # literal suffix
+ $
+ """,
+ re.VERBOSE,
+)
+_VERSION_RE: Final[re.Pattern[str]] = re.compile(
+ r"""
+ ^
+ (\d+) # major
+ (?:\.(\d+))? # optional minor
+ (?:\.(\d+))? # optional micro
+ $
+ """,
+ re.VERBOSE,
+)
+_THREADED_TAG_RE: Final[re.Pattern[str]] = re.compile(
+ r"""
+ ^
+ \d+ # major
+ (\.\d+){0,2} # optional minor/micro
+ t # free-threaded flag
+ $
+ """,
+ re.VERBOSE | re.IGNORECASE,
+)
+
+
+def enum_keys(key: Any) -> Generator[str, None, None]: # noqa: ANN401
+ at = 0
+ while True:
+ try:
+ yield winreg.EnumKey(key, at) # ty: ignore[unresolved-attribute]
+ except OSError:
+ break
+ at += 1
+
+
+def get_value(key: Any, value_name: str | None) -> Any: # noqa: ANN401
+ try:
+ return winreg.QueryValueEx(key, value_name)[0] # ty: ignore[unresolved-attribute]
+ except OSError:
+ return None
+
+
+def discover_pythons() -> Generator[_RegistrySpec, None, None]:
+ for hive, hive_name, key, flags, default_arch in [
+ (winreg.HKEY_CURRENT_USER, "HKEY_CURRENT_USER", r"Software\Python", 0, 64), # ty: ignore[unresolved-attribute]
+ (winreg.HKEY_LOCAL_MACHINE, "HKEY_LOCAL_MACHINE", r"Software\Python", winreg.KEY_WOW64_64KEY, 64), # ty: ignore[unresolved-attribute]
+ (winreg.HKEY_LOCAL_MACHINE, "HKEY_LOCAL_MACHINE", r"Software\Python", winreg.KEY_WOW64_32KEY, 32), # ty: ignore[unresolved-attribute]
+ ]:
+ yield from process_set(hive, hive_name, key, flags, default_arch)
+
+
+def process_set(
+ hive: int,
+ hive_name: str,
+ key: str,
+ flags: int,
+ 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):
+ if company == "PyLauncher": # reserved
+ continue
+ yield from process_company(hive_name, company, root_key, default_arch)
+ except OSError:
+ pass
+
+
+def process_company(
+ hive_name: str,
+ company: str,
+ root_key: Any, # noqa: ANN401
+ 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:
+ yield spec
+
+
+def process_tag(hive_name: str, company: str, company_key: Any, tag: str, default_arch: int) -> _RegistrySpec | None: # noqa: ANN401
+ 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
+ return None
+ return None
+
+
+def load_exe(hive_name: str, company: str, company_key: Any, tag: str) -> tuple[str, str | None] | None: # noqa: ANN401
+ 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]
+ exe = get_value(ip_key, "ExecutablePath")
+ if exe is None:
+ ip = get_value(ip_key, None)
+ if ip is None:
+ msg(key_path, "no ExecutablePath or default for it")
+
+ else:
+ exe = os.path.join(ip, "python.exe")
+ if exe is not None and os.path.exists(exe):
+ args = get_value(ip_key, "ExecutableArguments")
+ return exe, args
+ msg(key_path, f"could not load exe with value {exe}")
+ except OSError:
+ msg(f"{key_path}/InstallPath", "missing")
+ return None
+
+
+def load_arch_data(hive_name: str, company: str, tag: str, tag_key: Any, default_arch: int) -> int | None: # noqa: ANN401
+ 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: # noqa: ANN401
+ if isinstance(arch_str, str):
+ if match := _ARCH_RE.match(arch_str):
+ return int(next(iter(match.groups())))
+ error = f"invalid format {arch_str}"
+ else:
+ error = f"arch is not string: {arch_str!r}"
+ raise ValueError(error)
+
+
+def load_version_data(
+ hive_name: str,
+ company: str,
+ tag: str,
+ tag_key: Any, # noqa: ANN401
+) -> 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
+
+
+def parse_version(version_str: Any) -> tuple[int | None, int | None, int | None]: # noqa: ANN401
+ 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,
+ )
+ error = f"invalid format {version_str}"
+ else:
+ error = f"version is not string: {version_str!r}"
+ raise ValueError(error)
+
+
+def load_threaded(hive_name: str, company: str, tag: str, tag_key: Any) -> bool: # noqa: ANN401
+ 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:
+ _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")
+
+
+if __name__ == "__main__":
+ _run()
diff --git a/src/python_discovery/_windows/_propose.py b/src/python_discovery/_windows/_propose.py
new file mode 100644
index 0000000..6b9ff33
--- /dev/null
+++ b/src/python_discovery/_windows/_propose.py
@@ -0,0 +1,53 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+from python_discovery._py_info import PythonInfo
+from python_discovery._py_spec import PythonSpec
+
+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.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,
+ ),
+ reverse=True,
+ )
+
+ for name, major, minor, arch, threaded, exe, _ in existing:
+ 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(exe, cache, env=env, raise_on_error=False)
+ if interpreter is not None and interpreter.satisfies(spec, impl_must_match=True):
+ yield interpreter
+
+
+__all__ = [
+ "Pep514PythonInfo",
+ "propose_interpreters",
+]
diff --git a/src/py_discovery/py.typed b/src/python_discovery/py.typed
similarity index 100%
rename from src/py_discovery/py.typed
rename to src/python_discovery/py.typed
diff --git a/tests/conftest.py b/tests/conftest.py
index 0016cfa..4e7bad1 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -1,26 +1,29 @@
from __future__ import annotations
-import os
-import sys
-from tempfile import NamedTemporaryFile
+from typing import TYPE_CHECKING
import pytest
+from python_discovery import DiskCache, PythonInfo
+
+if TYPE_CHECKING:
+ from collections.abc import Generator
+
@pytest.fixture(scope="session")
-def _fs_supports_symlink() -> None:
- can = False
- if hasattr(os, "symlink"): # pragma: no branch
- if sys.platform == "win32": # pragma: win32 cover
- with NamedTemporaryFile(prefix="TmP") as tmp_file:
- temp_dir = os.path.dirname(tmp_file.name) # noqa: PTH120
- dest = os.path.join(temp_dir, f"{tmp_file.name}-{'b'}") # noqa: PTH118
- try:
- os.symlink(tmp_file.name, dest)
- can = True # pragma: no cover
- except (OSError, NotImplementedError): # pragma: no cover
- pass # pragma: no cover
- else: # pragma: win32 no cover
- can = True
- if not can: # pragma: no branch
- pytest.skip("No symlink support") # pragma: no cover
+def session_cache(tmp_path_factory: pytest.TempPathFactory) -> DiskCache:
+ return DiskCache(tmp_path_factory.mktemp("python-discovery-cache"))
+
+
+@pytest.fixture(autouse=True)
+def _ensure_py_info_cache_empty(session_cache: DiskCache) -> Generator[None]:
+ PythonInfo.clear_cache(session_cache)
+ yield
+ PythonInfo.clear_cache(session_cache)
+
+
+@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
+ pytest.skip("test not valid if run under system")
diff --git a/tests/py_info/test_py_info.py b/tests/py_info/test_py_info.py
new file mode 100644
index 0000000..2232d9e
--- /dev/null
+++ b/tests/py_info/test_py_info.py
@@ -0,0 +1,455 @@
+from __future__ import annotations
+
+import copy
+import itertools
+import json
+import logging
+import os
+import sys
+import sysconfig
+from pathlib import Path
+from textwrap import dedent
+from typing import TYPE_CHECKING, NamedTuple
+
+import pytest
+from setuptools.dist import Distribution
+
+from python_discovery import DiskCache, PythonInfo, PythonSpec
+from python_discovery import _cached_py_info as cached_py_info
+from python_discovery._py_info import VersionInfo
+
+if TYPE_CHECKING:
+ from pytest_mock import MockerFixture
+
+IS_PYPY = PythonInfo.current_system().implementation == "PyPy"
+
+CURRENT = PythonInfo.current_system()
+
+
+@pytest.mark.graalpy
+def test_current_as_json() -> None:
+ result = CURRENT.to_json()
+ parsed = json.loads(result)
+ major, minor, micro, releaselevel, serial = sys.version_info
+ free_threaded = sysconfig.get_config_var("Py_GIL_DISABLED") == 1
+ assert parsed["version_info"] == {
+ "major": major,
+ "minor": minor,
+ "micro": micro,
+ "releaselevel": releaselevel,
+ "serial": serial,
+ }
+ assert parsed["free_threaded"] is free_threaded
+
+
+def test_bad_exe_py_info_raise(tmp_path: Path, session_cache: DiskCache) -> None:
+ exe = str(tmp_path)
+ with pytest.raises(RuntimeError) as context:
+ PythonInfo.from_exe(exe, session_cache)
+ msg = str(context.value)
+ assert "code" in msg
+ assert exe in msg
+
+
+def test_bad_exe_py_info_no_raise(
+ tmp_path: Path,
+ caplog: pytest.LogCaptureFixture,
+ capsys: pytest.CaptureFixture[str],
+ session_cache: DiskCache,
+) -> None:
+ caplog.set_level(logging.NOTSET)
+ exe = str(tmp_path)
+ result = PythonInfo.from_exe(exe, session_cache, raise_on_error=False)
+ assert result is None
+ out, _ = capsys.readouterr()
+ assert not out
+ messages = [r.message for r in caplog.records if r.name != "filelock"]
+ assert len(messages) == 4
+ assert "get interpreter info via cmd: " in messages[0]
+ assert "retrying" in messages[1]
+ assert "get interpreter info via cmd: " in messages[2]
+ assert str(exe) in messages[3]
+ assert "code" in messages[3]
+
+
+@pytest.mark.parametrize(
+ "spec",
+ itertools.chain(
+ [sys.executable],
+ [
+ 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 []
+ )
+ ),
+ [sys.version_info[0 : i + 1] for i in range(3)],
+ ["", 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)
+ 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}",
+ )
+ 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'}",
+ )
+ matches = CURRENT.satisfies(parsed_spec, impl_must_match=True)
+ assert matches is False
+
+
+def _generate_not_match_current_interpreter_version() -> list[str]:
+ result: list[str] = []
+ for depth in range(3):
+ ver: list[int] = [int(part) for part in sys.version_info[0 : depth + 1]]
+ for idx in range(len(ver)):
+ for offset in [-1, 1]:
+ temp = ver.copy()
+ temp[idx] += offset
+ result.append(".".join(str(part) for part in temp))
+ return result
+
+
+_NON_MATCH_VER = _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)
+ assert matches is False
+
+
+def test_py_info_cached_error(mocker: MockerFixture, tmp_path: Path, session_cache: DiskCache) -> None:
+ spy = mocker.spy(cached_py_info, "_run_subprocess")
+ with pytest.raises(RuntimeError):
+ PythonInfo.from_exe(str(tmp_path), session_cache)
+ with pytest.raises(RuntimeError):
+ PythonInfo.from_exe(str(tmp_path), session_cache)
+ assert spy.call_count == 2
+
+
+def test_py_info_cache_clear(mocker: MockerFixture, session_cache: DiskCache) -> None:
+ result = PythonInfo.from_exe(sys.executable, session_cache)
+ assert result is not None
+
+ PythonInfo.clear_cache(session_cache)
+ assert not cached_py_info._CACHE
+
+ spy = mocker.spy(cached_py_info, "_run_subprocess")
+ info = PythonInfo.from_exe(sys.executable, session_cache)
+ assert info is not None
+
+ native_difference = 1 if info.system_executable == info.executable else 0
+ assert spy.call_count + native_difference >= 1
+
+
+class PyInfoMock(NamedTuple):
+ implementation: str
+ architecture: int
+ version_info: VersionInfo
+
+
+@pytest.mark.parametrize(
+ ("target", "position", "discovered"),
+ [
+ (
+ 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)),
+ ],
+ ),
+ (
+ 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)),
+ ],
+ ),
+ (
+ 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)),
+ ],
+ ),
+ ],
+)
+def test_system_executable_no_exact_match(
+ target: PyInfoMock,
+ discovered: list[PyInfoMock],
+ position: int,
+ *,
+ tmp_path: Path,
+ mocker: MockerFixture,
+ caplog: pytest.LogCaptureFixture,
+ session_cache: DiskCache,
+) -> None:
+ caplog.set_level(logging.DEBUG)
+
+ def _make_py_info(of: PyInfoMock) -> PythonInfo:
+ base = copy.deepcopy(CURRENT)
+ base.implementation = of.implementation
+ base.version_info = of.version_info
+ base.architecture = of.architecture
+ return base
+
+ discovered_with_path: dict[str, PythonInfo] = {}
+ names: list[str] = []
+ selected = None
+ for pos, i in enumerate(discovered):
+ path = tmp_path / str(pos)
+ path.write_text("", encoding="utf-8")
+ py_info = _make_py_info(i)
+ py_info.system_executable = CURRENT.system_executable
+ py_info.executable = CURRENT.system_executable
+ py_info.base_executable = str(path) # ty: ignore[unresolved-attribute]
+ if pos == position:
+ selected = py_info
+ 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)])
+
+ def func(exe_path: str, _cache: object = None, **_kwargs: object) -> PythonInfo:
+ return discovered_with_path[exe_path]
+
+ mocker.patch.object(target_py_info, "from_exe", side_effect=func)
+ 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
+ found = discovered_with_path[mapped.base_executable]
+ assert found is selected
+
+ assert caplog.records[0].msg == "discover exe for %s in %s"
+ for record in caplog.records[1:-1]:
+ assert record.message.startswith("refused interpreter ")
+ assert record.levelno == logging.DEBUG
+
+ warn_similar = caplog.records[-1]
+ assert warn_similar.levelno == logging.DEBUG
+ assert warn_similar.msg.startswith("no exact match found, chosen most similar")
+
+
+def test_py_info_ignores_distutils_config(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
+ raw = f"""
+ [install]
+ prefix={tmp_path}{os.sep}prefix
+ install_purelib={tmp_path}{os.sep}purelib
+ install_platlib={tmp_path}{os.sep}platlib
+ install_headers={tmp_path}{os.sep}headers
+ install_scripts={tmp_path}{os.sep}scripts
+ install_data={tmp_path}{os.sep}data
+ """
+ (tmp_path / "setup.cfg").write_text(dedent(raw), encoding="utf-8")
+ monkeypatch.chdir(tmp_path)
+ py_info = PythonInfo.from_exe(sys.executable)
+ assert py_info is not None
+ distutils = py_info.distutils_install
+ for key, value in distutils.items(): # pragma: no cover # distutils_install is empty with "venv" scheme
+ assert not value.startswith(str(tmp_path)), f"{key}={value}"
+
+
+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.original_executable).suffix
+ spec = PythonSpec.from_string_spec(suffixed_name)
+ mocker.patch.object(CURRENT, "original_executable", str(Path(CURRENT.executable).parent / suffixed_name))
+ 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
+ spec = PythonSpec.from_string_spec(suffixed_name)
+ mocker.patch.object(
+ CURRENT,
+ "original_executable",
+ str(Path(CURRENT.executable).parent / f"e{suffixed_name}"),
+ )
+ assert CURRENT.satisfies(spec, impl_must_match=True) is False
+
+
+@pytest.mark.skipif(IS_PYPY, reason="setuptools distutils patching does not work")
+def test_py_info_setuptools() -> None:
+ assert Distribution
+ PythonInfo()
+
+
+@pytest.mark.usefixtures("_skip_if_test_in_system")
+def test_py_info_to_system_raises( # pragma: no cover # skipped in venv environments
+ session_cache: DiskCache,
+ mocker: MockerFixture,
+ caplog: pytest.LogCaptureFixture,
+) -> None:
+ caplog.set_level(logging.DEBUG)
+ mocker.patch.object(PythonInfo, "_find_possible_folders", return_value=[])
+ result = PythonInfo.from_exe(sys.executable, cache=session_cache, raise_on_error=False)
+ assert result is None
+ log = caplog.records[-1]
+ assert log.levelno == logging.INFO
+ expected = f"ignore {sys.executable} due cannot resolve system due to RuntimeError('failed to detect "
+ assert expected in log.message
+
+
+def test_sysconfig_vars_include_shared_lib_keys() -> None:
+ for key in ("Py_ENABLE_SHARED", "INSTSONAME", "LIBDIR"):
+ 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
+
+
+def test_py_info_machine_property() -> None:
+ machine = CURRENT.machine
+ assert machine is not None
+ assert isinstance(machine, str)
+ assert len(machine) > 0
+ known_isas = {"arm64", "x86_64", "x86", "ppc64le", "ppc64", "s390x", "riscv64"}
+ assert machine in known_isas, f"unexpected machine value: {machine}"
+
+
+def test_py_info_machine_in_spec() -> None:
+ 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()
+
+
+@pytest.mark.parametrize(
+ ("platform", "expected"),
+ [
+ pytest.param("win32", "x86", id="win32"),
+ pytest.param("win-amd64", "x86_64", id="win-amd64"),
+ pytest.param("win-arm64", "arm64", id="win-arm64"),
+ pytest.param("linux-x86_64", "x86_64", id="linux-x86_64"),
+ pytest.param("linux-aarch64", "arm64", id="linux-aarch64"),
+ pytest.param("linux-riscv64", "riscv64", id="linux-riscv64"),
+ pytest.param("linux-ppc64le", "ppc64le", id="linux-ppc64le"),
+ pytest.param("linux-s390x", "s390x", id="linux-s390x"),
+ pytest.param("macosx-14.0-arm64", "arm64", id="macos-arm64"),
+ pytest.param("macosx-14.0-x86_64", "x86_64", id="macos-x86_64"),
+ ],
+)
+def test_py_info_machine_derivation(platform: str, expected: str) -> None:
+ 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.sysconfig_platform = "macosx-11.0-universal2"
+ mocker.patch("python_discovery._py_info.platform.machine", return_value=runtime_isa)
+ assert info.machine == runtime_isa
+
+
+def test_py_info_satisfies_with_machine() -> None:
+ threaded = "t" if CURRENT.free_threaded else ""
+ spec_str = (
+ 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
+
+
+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
+
+
+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}"
+ 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
+
+
+@pytest.mark.parametrize(
+ ("platform", "spec_machine"),
+ [
+ pytest.param("linux-x86_64", "amd64", id="amd64-matches-x86_64"),
+ pytest.param("macosx-14.0-arm64", "aarch64", id="aarch64-matches-arm64"),
+ ],
+)
+def test_py_info_satisfies_machine_cross_os_normalization(platform: str, spec_machine: str) -> None:
+ 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
+
+
+def test_py_info_to_dict_includes_sysconfig_platform() -> None:
+ data = CURRENT.to_dict()
+ assert "sysconfig_platform" in data
+ assert data["sysconfig_platform"] == sysconfig.get_platform()
+
+
+def test_py_info_json_round_trip() -> None:
+ 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
+
+
+@pytest.mark.parametrize(
+ ("target_platform", "discovered_platforms", "expected_idx"),
+ [
+ pytest.param("linux-x86_64", ["linux-aarch64", "linux-x86_64"], 1, id="x86_64-over-aarch64"),
+ pytest.param("macosx-14.0-arm64", ["macosx-14.0-x86_64", "macosx-14.0-arm64"], 1, id="arm64-over-x86_64"),
+ ],
+)
+def test_select_most_likely_prefers_machine_match(
+ target_platform: str,
+ discovered_platforms: list[str],
+ expected_idx: int,
+) -> None:
+ 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
+ result = PythonInfo._select_most_likely(discovered, target)
+ assert result.sysconfig_platform == discovered_platforms[expected_idx]
diff --git a/tests/py_info/test_py_info_exe_based_of.py b/tests/py_info/test_py_info_exe_based_of.py
new file mode 100644
index 0000000..21e19fc
--- /dev/null
+++ b/tests/py_info/test_py_info_exe_based_of.py
@@ -0,0 +1,82 @@
+from __future__ import annotations
+
+import logging
+from pathlib import Path
+
+import pytest
+
+from python_discovery import DiskCache, PythonInfo
+from python_discovery._compat import fs_is_case_sensitive
+from python_discovery._discovery import IS_WIN
+from python_discovery._py_info import EXTENSIONS
+
+CURRENT = PythonInfo.current()
+
+
+def _fs_supports_symlink() -> bool:
+ return not IS_WIN
+
+
+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))
+
+
+def _discover_base_folders() -> tuple[str, ...]:
+ exe_dir = str(Path(CURRENT.executable).parent)
+ folders: dict[str, None] = {}
+ if exe_dir.startswith(CURRENT.prefix): # pragma: no branch
+ relative = exe_dir[len(CURRENT.prefix) :].lstrip("/\\")
+ if relative: # pragma: no branch
+ folders[relative] = None
+ folders["."] = None
+ return tuple(folders)
+
+
+BASE = _discover_base_folders()
+
+
+@pytest.mark.skipif(not _fs_supports_symlink(), reason="symlink is not supported")
+@pytest.mark.parametrize("suffix", sorted({".exe", ""} & set(EXTENSIONS) if IS_WIN else [""]))
+@pytest.mark.parametrize("into", BASE)
+@pytest.mark.parametrize("arch", [CURRENT.architecture, ""])
+@pytest.mark.parametrize("version", [".".join(str(i) for i in CURRENT.version_info[0:i]) for i in range(3, 0, -1)])
+@pytest.mark.parametrize("impl", [CURRENT.implementation, "python"])
+def test_discover_ok(
+ tmp_path: Path,
+ suffix: str,
+ impl: str,
+ version: str,
+ *,
+ arch: int | str,
+ into: str,
+ caplog: pytest.LogCaptureFixture,
+ session_cache: DiskCache,
+) -> None:
+ 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
+ (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
+ 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 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()
+ with pytest.raises(RuntimeError):
+ CURRENT.discover_exe(session_cache, inside_folder)
diff --git a/tests/test_cache.py b/tests/test_cache.py
new file mode 100644
index 0000000..f868b11
--- /dev/null
+++ b/tests/test_cache.py
@@ -0,0 +1,115 @@
+from __future__ import annotations
+
+from pathlib import Path
+
+from python_discovery._cache import DiskCache, DiskContentStore, NoOpCache, NoOpContentStore
+
+
+def test_disk_content_store_read_valid_json(tmp_path: Path) -> None:
+ store = DiskContentStore(tmp_path, "test")
+ data = {"key": "value"}
+ store.write(data)
+ assert store.read() == data
+
+
+def test_disk_content_store_read_invalid_json(tmp_path: Path) -> None:
+ store = DiskContentStore(tmp_path, "test")
+ tmp_path.mkdir(parents=True, exist_ok=True)
+ (tmp_path / "test.json").write_text("not json", encoding="utf-8")
+ assert store.read() is None
+ assert not (tmp_path / "test.json").exists()
+
+
+def test_disk_content_store_read_missing_file(tmp_path: Path) -> None:
+ store = DiskContentStore(tmp_path, "test")
+ assert store.read() is None
+
+
+def test_disk_content_store_remove(tmp_path: Path) -> None:
+ store = DiskContentStore(tmp_path, "test")
+ store.write({"key": "value"})
+ assert store.exists()
+ store.remove()
+ assert not store.exists()
+
+
+def test_disk_content_store_remove_missing_file(tmp_path: Path) -> None:
+ store = DiskContentStore(tmp_path, "test")
+ store.remove()
+
+
+def test_disk_content_store_locked(tmp_path: Path) -> None:
+ store = DiskContentStore(tmp_path, "test")
+ with store.locked():
+ store.write({"key": "value"})
+ assert store.read() == {"key": "value"}
+
+
+def test_disk_content_store_exists(tmp_path: Path) -> None:
+ store = DiskContentStore(tmp_path, "test")
+ assert store.exists() is False
+ store.write({"key": "value"})
+ assert store.exists() is True
+
+
+def test_disk_cache_py_info(tmp_path: Path) -> None:
+ cache = DiskCache(tmp_path)
+ store = cache.py_info(Path("/some/path"))
+ assert isinstance(store, DiskContentStore)
+
+
+def test_disk_cache_py_info_clear(tmp_path: Path) -> None:
+ cache = DiskCache(tmp_path)
+ store = cache.py_info(Path("/some/path"))
+ store.write({"key": "value"})
+ assert store.exists()
+ cache.py_info_clear()
+ assert not store.exists()
+
+
+def test_disk_cache_py_info_clear_empty(tmp_path: Path) -> None:
+ cache = DiskCache(tmp_path)
+ cache.py_info_clear()
+
+
+def test_disk_cache_py_info_clear_skips_non_json(tmp_path: Path) -> None:
+ cache = DiskCache(tmp_path)
+ py_info_dir = tmp_path / "py_info" / "4"
+ py_info_dir.mkdir(parents=True)
+ (py_info_dir / "test.json").write_text("{}", encoding="utf-8")
+ (py_info_dir / "notjson.txt").touch()
+ cache.py_info_clear()
+ remaining = [entry.name for entry in py_info_dir.iterdir()]
+ assert "notjson.txt" in remaining
+ assert "test.json" not in remaining
+
+
+def test_noop_content_store_exists() -> None:
+ assert NoOpContentStore().exists() is False
+
+
+def test_noop_content_store_read() -> None:
+ assert NoOpContentStore().read() is None
+
+
+def test_noop_content_store_write() -> None:
+ NoOpContentStore().write({"key": "value"})
+
+
+def test_noop_content_store_remove() -> None:
+ NoOpContentStore().remove()
+
+
+def test_noop_content_store_locked() -> None:
+ with NoOpContentStore().locked():
+ pass
+
+
+def test_noop_cache_py_info() -> None:
+ cache = NoOpCache()
+ store = cache.py_info(Path("/some/path"))
+ assert isinstance(store, NoOpContentStore)
+
+
+def test_noop_cache_py_info_clear() -> None:
+ NoOpCache().py_info_clear()
diff --git a/tests/test_cached_py_info.py b/tests/test_cached_py_info.py
new file mode 100644
index 0000000..91b2d7e
--- /dev/null
+++ b/tests/test_cached_py_info.py
@@ -0,0 +1,216 @@
+from __future__ import annotations
+
+import json
+import logging
+import os
+import sys
+from pathlib import Path
+from typing import TYPE_CHECKING
+from unittest.mock import MagicMock, patch
+
+import pytest
+
+from python_discovery import DiskCache, PythonInfo
+from python_discovery._cached_py_info import (
+ LogCmd,
+ _get_via_file_cache,
+ _load_cached_py_info,
+ _resolve_py_info_script,
+ _run_subprocess,
+ gen_cookie,
+)
+
+if TYPE_CHECKING:
+ from pytest_mock import MockerFixture
+
+
+def test_gen_cookie_length() -> None:
+ cookie = gen_cookie()
+ assert len(cookie) == 32
+
+
+def test_log_cmd_repr() -> None:
+ cmd = LogCmd(["python", "-c", "print('hello')"])
+ assert "python" in repr(cmd)
+ assert cmd.env is None
+
+
+def test_log_cmd_repr_with_env() -> None:
+ cmd = LogCmd(["python", "-c", "print('hello')"], env={"FOO": "bar"})
+ result = repr(cmd)
+ assert "python" in result
+ assert "env of" in result
+ assert "FOO" in result
+
+
+def test_resolve_py_info_script_file_exists() -> None:
+ with _resolve_py_info_script() as script:
+ assert script.exists()
+ assert script.name == "_py_info.py"
+
+
+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")
+ with _resolve_py_info_script() as script:
+ assert script.exists()
+ content = script.read_text(encoding="utf-8")
+ assert content == "# mock script"
+ assert not script.exists()
+
+
+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)
+ with pytest.raises(FileNotFoundError, match="cannot locate"), _resolve_py_info_script():
+ pass # pragma: no cover
+
+
+def test_run_subprocess_success() -> None:
+ failure, result = _run_subprocess(PythonInfo, sys.executable, dict(os.environ))
+ assert failure is None
+ assert result is not None
+ assert isinstance(result, PythonInfo)
+
+
+def test_run_subprocess_bad_exe() -> None:
+ failure, result = _run_subprocess(PythonInfo, "/nonexistent/python", dict(os.environ))
+ assert failure is not None
+ assert result is None
+ assert isinstance(failure, RuntimeError)
+
+
+def test_run_subprocess_invalid_json(mocker: MockerFixture) -> None:
+ mock_process = MagicMock()
+ mock_process.communicate.return_value = ("not json", "")
+ mock_process.returncode = 0
+ mocker.patch("python_discovery._cached_py_info.Popen", return_value=mock_process)
+ failure, result = _run_subprocess(PythonInfo, sys.executable, dict(os.environ))
+ assert failure is not None
+ assert result is None
+
+
+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"
+
+ mock_process = MagicMock()
+ mock_process.communicate.return_value = (out, "")
+ mock_process.returncode = 0
+ mocker.patch("python_discovery._cached_py_info.Popen", return_value=mock_process)
+ mocker.patch("python_discovery._cached_py_info.gen_cookie", side_effect=[start_cookie, end_cookie])
+
+ with patch("sys.stdout") as mock_stdout:
+ failure, result = _run_subprocess(PythonInfo, sys.executable, dict(os.environ))
+
+ assert failure is None
+ assert result is not None
+ assert mock_stdout.write.call_count == 2
+
+
+def test_run_subprocess_nonzero_exit(mocker: MockerFixture) -> None:
+ mock_process = MagicMock()
+ mock_process.communicate.return_value = ("some output", "some error")
+ mock_process.returncode = 1
+ mocker.patch("python_discovery._cached_py_info.Popen", return_value=mock_process)
+ failure, result = _run_subprocess(PythonInfo, sys.executable, dict(os.environ))
+ assert failure is not None
+ assert "failed to query" in str(failure)
+ assert result is None
+
+
+def test_load_cached_py_info_valid() -> None:
+ store = MagicMock()
+ content = PythonInfo().to_dict()
+ result = _load_cached_py_info(PythonInfo, store, content)
+ assert result is not None
+ assert isinstance(result, PythonInfo)
+
+
+def test_load_cached_py_info_bad_data() -> None:
+ store = MagicMock()
+ 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()
+ content = PythonInfo().to_dict()
+ content["system_executable"] = "/nonexistent/python"
+ mocker.patch("os.path.exists", return_value=False)
+ result = _load_cached_py_info(PythonInfo, store, content)
+ assert result is None
+ store.remove.assert_called_once()
+
+
+def test_get_via_file_cache_uses_cached(tmp_path: Path) -> None:
+ cache = DiskCache(tmp_path)
+ path = Path(sys.executable)
+ env = dict(os.environ)
+
+ result1 = _get_via_file_cache(PythonInfo, cache, path, sys.executable, env)
+ assert isinstance(result1, PythonInfo)
+
+ result2 = _get_via_file_cache(PythonInfo, cache, path, sys.executable, env)
+ assert isinstance(result2, PythonInfo)
+
+
+def test_get_via_file_cache_stale_hash(tmp_path: Path) -> None:
+ cache = DiskCache(tmp_path)
+ path = Path(sys.executable)
+ env = dict(os.environ)
+
+ result1 = _get_via_file_cache(PythonInfo, cache, path, sys.executable, env)
+ assert isinstance(result1, PythonInfo)
+
+ store = cache.py_info(path)
+ data = store.read()
+ assert data is not None
+ data["hash"] = "stale_hash"
+ store.write(data)
+
+ result2 = _get_via_file_cache(PythonInfo, cache, path, sys.executable, env)
+ assert isinstance(result2, PythonInfo)
+
+
+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)
+ assert isinstance(result, (PythonInfo, Exception))
+
+
+def test_from_exe_retry_on_first_failure(
+ tmp_path: Path, mocker: MockerFixture, caplog: pytest.LogCaptureFixture
+) -> None:
+ caplog.set_level(logging.DEBUG)
+ cache = DiskCache(tmp_path)
+ error = RuntimeError("fail")
+ mocker.patch(
+ "python_discovery._cached_py_info._run_subprocess",
+ side_effect=[(error, None), (None, PythonInfo())],
+ )
+ result = _get_via_file_cache(PythonInfo, cache, Path("/fake"), "/fake", dict(os.environ))
+ assert isinstance(result, PythonInfo)
+ assert any("retrying" in r.message for r in caplog.records)
+
+
+def test_get_via_file_cache_hash_oserror(tmp_path: Path, mocker: MockerFixture) -> None:
+ cache = DiskCache(tmp_path)
+ mocker.patch("python_discovery._cached_py_info.Path.read_bytes", side_effect=OSError("permission denied"))
+ result = _get_via_file_cache(PythonInfo, cache, Path(sys.executable), sys.executable, dict(os.environ))
+ assert isinstance(result, PythonInfo)
+
+
+def test_get_via_file_cache_py_info_none(tmp_path: Path, mocker: MockerFixture) -> None:
+ cache = DiskCache(tmp_path)
+ mocker.patch(
+ "python_discovery._cached_py_info._run_subprocess",
+ return_value=(None, None),
+ )
+ result = _get_via_file_cache(PythonInfo, cache, Path("/fake"), "/fake", dict(os.environ))
+ assert isinstance(result, RuntimeError)
diff --git a/tests/test_discovery.py b/tests/test_discovery.py
index 4170579..e44076e 100644
--- a/tests/test_discovery.py
+++ b/tests/test_discovery.py
@@ -2,45 +2,67 @@
import logging
import os
+import stat
+import subprocess
import sys
-from argparse import Namespace
from pathlib import Path
+from typing import TYPE_CHECKING
+from unittest.mock import patch
from uuid import uuid4
import pytest
-from py_discovery import Builtin, PythonInfo, get_interpreter
+from python_discovery import DiskCache, PythonInfo, get_interpreter
+from python_discovery._discovery import IS_WIN, LazyPathDump, get_paths
+if TYPE_CHECKING:
+ from pytest_mock import MockerFixture
-@pytest.mark.usefixtures("_fs_supports_symlink")
+
+@pytest.mark.graalpy
+@pytest.mark.skipif(not Path(sys.executable).is_symlink() and not Path(sys.executable).is_file(), reason="no symlink")
@pytest.mark.parametrize("case", ["mixed", "lower", "upper"])
+@pytest.mark.parametrize("specificity", ["more", "less", "none"])
def test_discovery_via_path(
monkeypatch: pytest.MonkeyPatch,
case: str,
+ specificity: str,
+ *,
tmp_path: Path,
caplog: pytest.LogCaptureFixture,
+ session_cache: DiskCache,
) -> None:
- caplog.set_level(logging.DEBUG) # pragma: no cover
- current = PythonInfo.current_system() # pragma: no cover
- core = f"somethingVeryCryptic{'.'.join(str(i) for i in current.version_info[0:3])}" # pragma: no cover
- name = "somethingVeryCryptic" # pragma: no cover
- if case == "lower": # pragma: no cover
- name = name.lower() # pragma: no cover
- elif case == "upper": # pragma: no cover
- name = name.upper() # pragma: no cover
- exe_name = f"{name}{current.version_info.major}{'.exe' if sys.platform == 'win32' else ''}" # pragma: no cover
- target = tmp_path / current.install_path("scripts") # pragma: no cover
- target.mkdir(parents=True) # pragma: no cover
- executable = target / exe_name # pragma: no cover
- os.symlink(sys.executable, str(executable)) # pragma: no cover
- pyvenv_cfg = Path(sys.executable).parents[1] / "pyvenv.cfg" # pragma: no cover
- if pyvenv_cfg.exists(): # pragma: no cover
- (target / pyvenv_cfg.name).write_bytes(pyvenv_cfg.read_bytes()) # pragma: no cover
- new_path = os.pathsep.join([str(target), *os.environ.get("PATH", "").split(os.pathsep)]) # pragma: no cover
- monkeypatch.setenv("PATH", new_path) # pragma: no cover
- interpreter = get_interpreter(core, []) # pragma: no cover
-
- assert interpreter is not None # pragma: no cover
+ 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 ''}"
+ target = tmp_path / current.install_path("scripts")
+ target.mkdir(parents=True)
+ executable = target / exe_name
+ Path(str(executable)).symlink_to(sys.executable)
+ pyvenv_cfg = Path(sys.executable).parents[1] / "pyvenv.cfg"
+ if pyvenv_cfg.exists(): # pragma: no branch
+ (target / pyvenv_cfg.name).write_bytes(pyvenv_cfg.read_bytes())
+ new_path = os.pathsep.join([str(target), *os.environ.get("PATH", "").split(os.pathsep)])
+ monkeypatch.setenv("PATH", new_path)
+ interpreter = get_interpreter(core, [])
+
+ assert interpreter is not None
def test_discovery_via_path_not_found(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
@@ -49,33 +71,341 @@ def test_discovery_via_path_not_found(tmp_path: Path, monkeypatch: pytest.Monkey
assert interpreter is None
-def test_relative_path(monkeypatch: pytest.MonkeyPatch) -> None:
- sys_executable_str = PythonInfo.current_system().system_executable
- assert sys_executable_str is not None
- sys_executable = Path(sys_executable_str)
+def test_discovery_via_path_in_nonbrowseable_directory(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
+ bad_perm = tmp_path / "bad_perm"
+ bad_perm.mkdir(mode=0o000)
+ monkeypatch.setenv("PATH", str(bad_perm))
+ interpreter = get_interpreter(uuid4().hex, [])
+ assert interpreter is None
+ monkeypatch.setenv("PATH", str(bad_perm / "bin"))
+ interpreter = get_interpreter(uuid4().hex, [])
+ assert interpreter is None
+
+
+def test_relative_path(session_cache: DiskCache, monkeypatch: pytest.MonkeyPatch) -> None:
+ 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, [])
+ result = get_interpreter(relative, [], session_cache)
assert result is not None
-def test_discovery_fallback_fail(caplog: pytest.LogCaptureFixture) -> None:
- caplog.set_level(logging.DEBUG)
- builtin = Builtin(Namespace(try_first_with=[], python=["magic-one", "magic-two"], env=os.environ))
+def test_uv_python(
+ monkeypatch: pytest.MonkeyPatch, tmp_path_factory: pytest.TempPathFactory, mocker: MockerFixture
+) -> None:
+ monkeypatch.delenv("UV_PYTHON_INSTALL_DIR", raising=False)
+ monkeypatch.delenv("XDG_DATA_HOME", raising=False)
+ monkeypatch.setenv("PATH", "")
+ mocker.patch.object(PythonInfo, "satisfies", return_value=False)
- result = builtin.run()
- assert result is None
+ uv_python_install_dir = tmp_path_factory.mktemp("uv_python_install_dir")
+ with patch("python_discovery._discovery.PathPythonInfo.from_exe") as mock_from_exe, monkeypatch.context() as m:
+ m.setenv("UV_PYTHON_INSTALL_DIR", str(uv_python_install_dir))
- assert "accepted" not in caplog.text
+ get_interpreter("python", [])
+ mock_from_exe.assert_not_called()
+
+ bin_path = uv_python_install_dir.joinpath("some-py-impl", "bin")
+ bin_path.mkdir(parents=True)
+ bin_path.joinpath("python").touch()
+ get_interpreter("python", [])
+ mock_from_exe.assert_called_once()
+ assert mock_from_exe.call_args[0][0] == str(bin_path / "python")
+
+ mock_from_exe.reset_mock()
+ python_exe = "python.exe" if IS_WIN else "python"
+ dir_in_path = tmp_path_factory.mktemp("path_bin_dir")
+ dir_in_path.joinpath(python_exe).touch()
+ m.setenv("PATH", str(dir_in_path))
+ get_interpreter("python", [])
+ mock_from_exe.assert_called_once()
+ assert mock_from_exe.call_args[0][0] == str(dir_in_path / python_exe)
+
+ xdg_data_home = tmp_path_factory.mktemp("xdg_data_home")
+ with patch("python_discovery._discovery.PathPythonInfo.from_exe") as mock_from_exe, monkeypatch.context() as m:
+ m.setenv("XDG_DATA_HOME", str(xdg_data_home))
+
+ get_interpreter("python", [])
+ mock_from_exe.assert_not_called()
+
+ bin_path = xdg_data_home.joinpath("uv", "python", "some-py-impl", "bin")
+ bin_path.mkdir(parents=True)
+ bin_path.joinpath("python").touch()
+ get_interpreter("python", [])
+ mock_from_exe.assert_called_once()
+ assert mock_from_exe.call_args[0][0] == str(bin_path / "python")
+ user_data_path = tmp_path_factory.mktemp("user_data_path")
+ with patch("python_discovery._discovery.PathPythonInfo.from_exe") as mock_from_exe, monkeypatch.context() as m:
+ m.setattr("python_discovery._discovery.user_data_path", lambda x: user_data_path / x)
-def test_discovery_fallback_ok(caplog: pytest.LogCaptureFixture) -> None:
+ get_interpreter("python", [])
+ mock_from_exe.assert_not_called()
+
+ bin_path = user_data_path.joinpath("uv", "python", "some-py-impl", "bin")
+ bin_path.mkdir(parents=True)
+ bin_path.joinpath("python").touch()
+ get_interpreter("python", [])
+ mock_from_exe.assert_called_once()
+ assert mock_from_exe.call_args[0][0] == str(bin_path / "python")
+
+
+def test_discovery_fallback_fail(session_cache: DiskCache, caplog: pytest.LogCaptureFixture) -> None:
caplog.set_level(logging.DEBUG)
- builtin = Builtin(Namespace(try_first_with=[], python=["magic-one", sys.executable], env=os.environ))
+ result = get_interpreter(["magic-one", "magic-two"], cache=session_cache)
+ assert result is None
+ assert "accepted" not in caplog.text
- result = builtin.run()
+
+def test_discovery_fallback_ok(session_cache: DiskCache, caplog: pytest.LogCaptureFixture) -> None:
+ caplog.set_level(logging.DEBUG)
+ result = get_interpreter(["magic-one", sys.executable], cache=session_cache)
assert result is not None, caplog.text
assert result.executable == sys.executable, caplog.text
-
assert "accepted" in caplog.text
+
+
+@pytest.fixture
+def mock_find_interpreter(mocker: MockerFixture) -> None:
+ mocker.patch(
+ "python_discovery._discovery._find_interpreter",
+ lambda key, *_args, **_kwargs: getattr(mocker.sentinel, key),
+ )
+
+
+@pytest.mark.usefixtures("mock_find_interpreter")
+def test_returns_first_python_specified(mocker: MockerFixture) -> None:
+ result = get_interpreter(["python_from_cli"])
+ assert result == mocker.sentinel.python_from_cli
+
+
+def test_discovery_absolute_path_with_try_first(
+ tmp_path: Path,
+ session_cache: DiskCache,
+) -> None:
+ good_env = tmp_path / "good"
+ bad_env = tmp_path / "bad"
+
+ subprocess.check_call([sys.executable, "-m", "venv", str(good_env)])
+ subprocess.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"
+ good_exe = good_env / scripts_dir / exe_name
+ bad_exe = bad_env / scripts_dir / exe_name
+
+ interpreter = get_interpreter(
+ str(good_exe),
+ try_first_with=[str(bad_exe)],
+ cache=session_cache,
+ )
+
+ assert interpreter is not None
+ assert Path(interpreter.executable) == good_exe
+
+
+def test_discovery_via_path_with_file(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
+ a_file = tmp_path / "a_file"
+ a_file.touch()
+ monkeypatch.setenv("PATH", str(a_file))
+ interpreter = get_interpreter(uuid4().hex, [])
+ assert interpreter is None
+
+
+def test_get_paths_no_path_env(monkeypatch: pytest.MonkeyPatch) -> None:
+ monkeypatch.delenv("PATH", raising=False)
+ paths = list(get_paths({}))
+ assert paths
+
+
+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"
+ (a_dir / executable_file).touch(mode=0o755)
+ (a_dir / "b_file").touch(mode=0o644)
+ dumper = LazyPathDump(0, a_dir, os.environ)
+ output = repr(dumper)
+ assert executable_file in output
+ assert "b_file" not in output
+
+
+def test_discovery_via_version_specifier(session_cache: DiskCache) -> None:
+ current = PythonInfo.current_system(session_cache)
+ major, minor = current.version_info.major, current.version_info.minor
+
+ spec = f">={major}.{minor}"
+ interpreter = get_interpreter(spec, [], session_cache)
+ assert interpreter is not None
+ assert interpreter.version_info.major == major
+ assert interpreter.version_info.minor >= minor
+
+ spec = f">={major}.{minor},<{major}.{minor + 10}"
+ interpreter = get_interpreter(spec, [], session_cache)
+ assert interpreter is not 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"
+
+
+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"),
+ [
+ pytest.param("PYENV_ROOT", ("versions",), id="pyenv"),
+ pytest.param("MISE_DATA_DIR", ("installs", "python"), id="mise"),
+ pytest.param("ASDF_DATA_DIR", ("installs", "python"), id="asdf"),
+ ],
+)
+def test_shim_resolved_to_real_binary(
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+ env_var: str,
+ versions_path: tuple[str, ...],
+) -> 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)
+
+ 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
+
+ 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 test_shim_not_resolved_without_version_manager_env(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
+ shims_dir = tmp_path / "shims"
+ shims_dir.mkdir()
+ shim = shims_dir / ("python2.7.exe" if IS_WIN else "python2.7")
+ shim.touch(mode=0o755)
+
+ monkeypatch.setenv("PATH", str(shims_dir))
+ monkeypatch.delenv("PYENV_ROOT", raising=False)
+ 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)
+
+
+def test_shim_falls_through_when_binary_missing(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
+ root = _create_version_manager(tmp_path, "PYENV_ROOT")
+ shim = root / "shims" / ("python2.7.exe" if IS_WIN else "python2.7")
+ shim.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)
+
+
+def test_shim_uses_python_version_file(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
+ root = _create_version_manager(tmp_path, "PYENV_ROOT")
+ real_binary = _create_versioned_binary(root, ("versions",), "2.7.18", "python2.7")
+ shim = root / "shims" / ("python2.7.exe" if IS_WIN else "python2.7")
+ shim.touch(mode=0o755)
+ (tmp_path / ".python-version").write_text(encoding="utf-8", data="2.7.18\n")
+
+ monkeypatch.setenv("PATH", str(root / "shims"))
+ monkeypatch.setenv("PYENV_ROOT", str(root))
+ 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)
+
+
+def test_shim_pyenv_version_env_takes_priority_over_file(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> 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)
+ (tmp_path / ".python-version").write_text(encoding="utf-8", data="2.7.18\n")
+
+ monkeypatch.setenv("PATH", str(root / "shims"))
+ monkeypatch.setenv("PYENV_ROOT", str(root))
+ 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)
+
+
+def test_shim_uses_global_version_file(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
+ root = _create_version_manager(tmp_path, "PYENV_ROOT")
+ real_binary = _create_versioned_binary(root, ("versions",), "2.7.18", "python2.7")
+ shim = root / "shims" / ("python2.7.exe" if IS_WIN else "python2.7")
+ shim.touch(mode=0o755)
+ (root / "version").write_text(encoding="utf-8", data="2.7.18\n")
+ workdir = tmp_path / "workdir"
+ workdir.mkdir()
+
+ monkeypatch.setenv("PATH", str(root / "shims"))
+ monkeypatch.setenv("PYENV_ROOT", str(root))
+ 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)
+
+
+def test_shim_colon_separated_pyenv_version_picks_first_match(
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+) -> 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)
+
+ 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)
diff --git a/tests/test_discovery_extra.py b/tests/test_discovery_extra.py
new file mode 100644
index 0000000..2c17fd3
--- /dev/null
+++ b/tests/test_discovery_extra.py
@@ -0,0 +1,241 @@
+from __future__ import annotations
+
+import os
+import sys
+from pathlib import Path
+from typing import TYPE_CHECKING
+
+import pytest
+
+from python_discovery import DiskCache, get_interpreter
+from python_discovery._discovery import (
+ IS_WIN,
+ LazyPathDump,
+ _active_versions,
+ _read_python_version_file,
+ _resolve_shim,
+ propose_interpreters,
+)
+from python_discovery._py_spec import PythonSpec
+
+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, []))
+ 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))
+ 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])
+ results = []
+ for result in gen: # pragma: no branch
+ results.append(result)
+ break
+ assert len(results) >= 0
+
+
+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)
+
+
+def test_propose_interpreters_relative_path(
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+ session_cache: DiskCache,
+) -> None:
+ Path(sys.executable)
+ link = tmp_path / ("python.exe" if IS_WIN else "python")
+ Path(str(link)).symlink_to(sys.executable)
+ spec = PythonSpec.from_string_spec(link.name)
+ spec.path = link.name
+ monkeypatch.setenv("PATH", str(tmp_path))
+ results = list(propose_interpreters(spec, [], session_cache))
+ assert len(results) >= 0
+
+
+def test_lazy_path_dump_basic(tmp_path: Path) -> None:
+ dumper = LazyPathDump(0, tmp_path, {})
+ result = repr(dumper)
+ 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)
+ 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)
+ 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)
+
+
+def test_active_versions_pyenv_version(monkeypatch: pytest.MonkeyPatch) -> None:
+ monkeypatch.setenv("PYENV_VERSION", "3.12.0:3.11.0")
+ versions = list(_active_versions(dict(os.environ)))
+ assert versions == ["3.12.0", "3.11.0"]
+
+
+def test_active_versions_python_version_file(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
+ monkeypatch.delenv("PYENV_VERSION", raising=False)
+ (tmp_path / ".python-version").write_text("3.12.0\n", encoding="utf-8")
+ monkeypatch.chdir(tmp_path)
+ versions = list(_active_versions(dict(os.environ)))
+ assert versions == ["3.12.0"]
+
+
+def test_active_versions_global_version_file(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
+ monkeypatch.delenv("PYENV_VERSION", raising=False)
+ monkeypatch.setenv("PYENV_ROOT", str(tmp_path))
+ workdir = tmp_path / "workdir"
+ workdir.mkdir()
+ monkeypatch.chdir(workdir)
+ (tmp_path / "version").write_text("3.11.0\n", encoding="utf-8")
+ versions = list(_active_versions(dict(os.environ)))
+ assert versions == ["3.11.0"]
+
+
+def test_active_versions_no_source(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
+ monkeypatch.delenv("PYENV_VERSION", raising=False)
+ monkeypatch.delenv("PYENV_ROOT", raising=False)
+ monkeypatch.chdir(tmp_path)
+ versions = list(_active_versions(dict(os.environ)))
+ assert versions == []
+
+
+def test_read_python_version_file_found(tmp_path: Path) -> None:
+ (tmp_path / ".python-version").write_text("3.12.0\n# comment\n", encoding="utf-8")
+ result = _read_python_version_file(str(tmp_path))
+ assert result == ["3.12.0"]
+
+
+def test_read_python_version_file_not_found(tmp_path: Path) -> None:
+ result = _read_python_version_file(str(tmp_path), search_parents=False)
+ assert result is None
+
+
+def test_read_python_version_file_search_parents(tmp_path: Path) -> None:
+ (tmp_path / ".python-version").write_text("3.11.0\n", encoding="utf-8")
+ child = tmp_path / "child"
+ child.mkdir()
+ result = _read_python_version_file(str(child))
+ assert result == ["3.11.0"]
+
+
+def test_read_python_version_file_direct_path(tmp_path: Path) -> None:
+ version_file = tmp_path / "version"
+ version_file.write_text("3.12.0\n", encoding="utf-8")
+ result = _read_python_version_file(str(version_file), search_parents=False)
+ assert result == ["3.12.0"]
+
+
+def test_resolve_shim_no_match() -> None:
+ result = _resolve_shim("/some/random/path", dict(os.environ))
+ assert result is 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)
+ assert callable(finder)
+ results = list(finder(tmp_path))
+ assert results == []
+
+
+def test_get_paths_no_path_env() -> None:
+ from python_discovery._discovery import get_paths
+
+ paths = list(get_paths({}))
+ assert isinstance(paths, list)
+
+
+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))
+ 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")
+ Path(str(link)).symlink_to(sys.executable)
+ spec = PythonSpec.from_string_spec(str(link))
+ spec.path = str(link)
+ results = list(propose_interpreters(spec, [], session_cache))
+ assert len(results) >= 1
+
+
+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()
+ monkeypatch.setenv("PYENV_ROOT", str(tmp_path))
+ exe_path = str(shims / "python3")
+ result = _resolve_shim(exe_path, dict(os.environ))
+ assert result is None
+
+
+def test_resolve_shim_dir_no_match(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
+ monkeypatch.setenv("PYENV_ROOT", str(tmp_path))
+ result = _resolve_shim("/other/dir/python3", dict(os.environ))
+ assert result is None
+
+
+def test_read_python_version_file_reaches_root() -> None:
+ result = _read_python_version_file("/nonexistent/deep/path/that/does/not/exist")
+ assert result is None
+
+
+def test_read_python_version_file_empty_versions(tmp_path: Path) -> None:
+ (tmp_path / ".python-version").write_text("# only comments\n\n", encoding="utf-8")
+ result = _read_python_version_file(str(tmp_path), search_parents=False)
+ assert result is None
+
+
+def test_get_interpreter_multi_spec_all_fail(session_cache: DiskCache) -> None:
+ result = get_interpreter(["magic-one", "magic-two"], cache=session_cache)
+ assert result is None
+
+
+def test_get_interpreter_multi_spec_fallback(session_cache: DiskCache) -> None:
+ result = get_interpreter(["magic-one", sys.executable], cache=session_cache)
+ assert result is not None
+ assert result.executable == sys.executable
diff --git a/tests/test_py_info.py b/tests/test_py_info.py
deleted file mode 100644
index e9cebc2..0000000
--- a/tests/test_py_info.py
+++ /dev/null
@@ -1,443 +0,0 @@
-from __future__ import annotations
-
-import copy
-import functools
-import itertools
-import json
-import logging
-import os
-import sys
-import sysconfig
-from pathlib import Path
-from platform import python_implementation
-from textwrap import dedent
-from typing import TYPE_CHECKING, Mapping, NamedTuple, Tuple, cast
-
-import pytest
-
-from py_discovery import PythonInfo, PythonSpec, VersionInfo
-
-if TYPE_CHECKING:
- from pytest_mock import MockerFixture
-
-CURRENT = PythonInfo.current_system()
-
-
-def test_current_as_json() -> None:
- result = CURRENT._to_json() # noqa: SLF001
- parsed = json.loads(result)
- a, b, c, d, e = sys.version_info
- assert parsed["version_info"] == {"major": a, "minor": b, "micro": c, "releaselevel": d, "serial": e}
-
-
-def test_bad_exe_py_info_raise(tmp_path: Path) -> None:
- exe = str(tmp_path)
- with pytest.raises(RuntimeError) as context:
- PythonInfo.from_exe(exe)
- msg = str(context.value)
- assert "code" in msg
- assert exe in msg
-
-
-def test_bad_exe_py_info_no_raise(
- tmp_path: Path,
- caplog: pytest.LogCaptureFixture,
- capsys: pytest.CaptureFixture[str],
-) -> None:
- caplog.set_level(logging.NOTSET)
- exe = str(tmp_path)
- result = PythonInfo.from_exe(exe, raise_on_error=False)
- assert result is None
- out, _ = capsys.readouterr()
- assert not out
- messages = [r.message for r in caplog.records if r.name != "filelock"]
- assert len(messages) == 2
- msg = messages[0]
- assert "get interpreter info via cmd: " in msg
- msg = messages[1]
- assert str(exe) in msg
- assert "code" in msg
-
-
-@pytest.mark.parametrize(
- "spec",
- itertools.chain(
- [sys.executable],
- [
- f"{impl}{'.'.join(str(i) for i in ver)}{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 []
- )
- ),
- [sys.version_info[0 : i + 1] for i in range(3)],
- ["", f"-{CURRENT.architecture}"],
- )
- ],
- ),
-)
-def test_satisfy_py_info(spec: str) -> None:
- parsed_spec = PythonSpec.from_string_spec(spec)
- matches = CURRENT.satisfies(parsed_spec, 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}",
- )
- matches = CURRENT.satisfies(parsed_spec, True)
- assert matches is False
-
-
-def _generate_not_match_current_interpreter_version() -> list[str]:
- result: list[str] = []
- for i in range(3):
- ver = cast(Tuple[int, ...], sys.version_info[0 : i + 1])
- for a in range(len(ver)):
- for o in [-1, 1]:
- temp = list(ver)
- temp[a] += o
- result.append(".".join(str(i) for i in temp))
- return result
-
-
-_NON_MATCH_VER = _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, True)
- assert matches is False
-
-
-class PyInfoMock(NamedTuple):
- implementation: str
- architecture: int
- version_info: VersionInfo
-
-
-@pytest.mark.parametrize(
- ("target", "position", "discovered"),
- [
- (
- 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)),
- ],
- ),
- (
- 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)),
- ],
- ),
- (
- 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)),
- ],
- ),
- ],
-)
-def test_system_executable_no_exact_match( # noqa: PLR0913
- target: PyInfoMock,
- position: int,
- discovered: list[PyInfoMock],
- tmp_path: Path,
- mocker: MockerFixture,
- caplog: pytest.LogCaptureFixture,
-) -> None:
- """Here we should fallback to other compatible."""
- caplog.set_level(logging.DEBUG)
-
- def _make_py_info(of: PyInfoMock) -> PythonInfo:
- base = copy.deepcopy(CURRENT)
- base.implementation = of.implementation
- base.version_info = of.version_info
- base.architecture = of.architecture
- base.system_executable = CURRENT.system_executable
- base.executable = CURRENT.system_executable
- base.base_executable = str(path) # type: ignore[attr-defined] # we mock it on for the test
- return base
-
- discovered_with_path = {}
- names = []
- selected = None
- for pos, i in enumerate(discovered):
- path = tmp_path / str(pos)
- path.write_text("", encoding="utf-8")
- py_info = _make_py_info(i)
- if pos == position:
- selected = py_info
- 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)])
-
- def func(k: str, resolve_to_host: bool, raise_on_error: bool, env: Mapping[str, str]) -> PythonInfo: # noqa: ARG001
- return discovered_with_path[k]
-
- mocker.patch.object(target_py_info, "from_exe", side_effect=func)
- 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(target_py_info) # noqa: SLF001
- assert mapped.system_executable == CURRENT.system_executable
- found = discovered_with_path[mapped.base_executable] # type: ignore[attr-defined] # we set it a few lines above
- assert found is selected
-
- assert caplog.records[0].msg == "discover exe for %s in %s"
- for record in caplog.records[1:-1]:
- assert record.message.startswith("refused interpreter ")
- assert record.levelno == logging.DEBUG
-
- warn_similar = caplog.records[-1]
- assert warn_similar.levelno == logging.DEBUG
- assert warn_similar.msg.startswith("no exact match found, chosen most similar")
-
-
-def test_py_info_ignores_distutils_config(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
- raw = f"""
- [install]
- prefix={tmp_path}{os.sep}prefix
- install_purelib={tmp_path}{os.sep}purelib
- install_platlib={tmp_path}{os.sep}platlib
- install_headers={tmp_path}{os.sep}headers
- install_scripts={tmp_path}{os.sep}scripts
- install_data={tmp_path}{os.sep}data
- """
- (tmp_path / "setup.cfg").write_text(dedent(raw), encoding="utf-8")
- monkeypatch.chdir(tmp_path)
- py_info = PythonInfo.from_exe(sys.executable)
- assert py_info is not None
- distutils = py_info.distutils_install
- # on newer pythons this is just empty
- for key, value in distutils.items(): # pragma: no branch
- assert not value.startswith(str(tmp_path)), f"{key}={value}" # pragma: no cover
-
-
-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
- assert CURRENT.original_executable is not None
- suffixed_name += Path(CURRENT.original_executable).suffix
- spec = PythonSpec.from_string_spec(suffixed_name)
- assert CURRENT.executable is not None
- mocker.patch.object(CURRENT, "original_executable", str(Path(CURRENT.executable).parent / suffixed_name))
- 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
- assert CURRENT.original_executable is not None
- suffixed_name += Path(CURRENT.original_executable).suffix
- spec = PythonSpec.from_string_spec(suffixed_name)
- assert CURRENT.executable is not None
- mocker.patch.object(
- CURRENT,
- "original_executable",
- str(Path(CURRENT.executable).parent / f"e{suffixed_name}"),
- )
- assert CURRENT.satisfies(spec, impl_must_match=True) is False
-
-
-if python_implementation() != "PyPy": # pragma: pypy no cover
-
- def test_py_info_setuptools() -> None:
- from setuptools.dist import Distribution
-
- assert Distribution
- PythonInfo()
-
-
-if CURRENT.system_executable is None: # pragma: no branch
-
- def test_py_info_to_system_raises( # pragma: no cover
- mocker: MockerFixture, # pragma: no cover
- caplog: pytest.LogCaptureFixture, # pragma: no cover
- ) -> None: # pragma: no cover
- caplog.set_level(logging.DEBUG) # pragma: no cover
- mocker.patch.object(PythonInfo, "_find_possible_folders", return_value=[]) # pragma: no cover
- result = PythonInfo.from_exe(sys.executable, raise_on_error=False) # pragma: no cover
- assert result is None # pragma: no cover
- log = caplog.records[-1] # pragma: no cover
- assert log.levelno == logging.INFO # pragma: no cover
- exe = sys.executable # pragma: no cover
- expected = f"ignore {exe} due cannot resolve system due to RuntimeError('failed to detect " # pragma: no cover
- assert expected in log.message # pragma: no cover
-
-
-def test_custom_venv_install_scheme_is_prefered(mocker: MockerFixture) -> None:
- # The paths in this test are Fedora paths, but we set them for nt as well, so the test also works on Windows,
- # despite the actual values are nonsense there.
- # Values were simplified to be compatible with all the supported Python versions.
- default_scheme = {
- "stdlib": "{base}/lib/python{py_version_short}",
- "platstdlib": "{platbase}/lib/python{py_version_short}",
- "purelib": "{base}/local/lib/python{py_version_short}/site-packages",
- "platlib": "{platbase}/local/lib/python{py_version_short}/site-packages",
- "include": "{base}/include/python{py_version_short}",
- "platinclude": "{platbase}/include/python{py_version_short}",
- "scripts": "{base}/local/bin",
- "data": "{base}/local",
- }
- venv_scheme = {key: path.replace("local", "") for key, path in default_scheme.items()}
- sysconfig_install_schemes = {
- "posix_prefix": default_scheme,
- "nt": default_scheme,
- "pypy": default_scheme,
- "pypy_nt": default_scheme,
- "venv": venv_scheme,
- }
- if getattr(sysconfig, "get_preferred_scheme", None): # pragma: no branch
- # define the prefix as sysconfig.get_preferred_scheme did before 3.11
- sysconfig_install_schemes["nt" if os.name == "nt" else "posix_prefix"] = default_scheme # pragma: no cover
-
- # On Python < 3.10, the distutils schemes are not derived from sysconfig schemes, so we mock them as well to assert
- # the custom "venv" install scheme has priority
- distutils_scheme = {
- "purelib": "$base/local/lib/python$py_version_short/site-packages",
- "platlib": "$platbase/local/lib/python$py_version_short/site-packages",
- "headers": "$base/include/python$py_version_short/$dist_name",
- "scripts": "$base/local/bin",
- "data": "$base/local",
- }
- distutils_schemes = {
- "unix_prefix": distutils_scheme,
- "nt": distutils_scheme,
- }
-
- # We need to mock distutils first, so they don't see the mocked sysconfig,
- # if imported for the first time.
- # That can happen if the actual interpreter has the "venv" INSTALL_SCHEME
- # and hence this is the first time we are touching distutils in this process.
- # If distutils saw our mocked sysconfig INSTALL_SCHEMES, we would need
- # to define all install schemes.
- mocker.patch("distutils.command.install.INSTALL_SCHEMES", distutils_schemes)
- mocker.patch("sysconfig._INSTALL_SCHEMES", sysconfig_install_schemes)
-
- pyinfo = PythonInfo()
- pyver = f"{pyinfo.version_info.major}.{pyinfo.version_info.minor}"
- assert pyinfo.install_path("scripts") == "bin"
- assert pyinfo.install_path("purelib").replace(os.sep, "/") == f"lib/python{pyver}/site-packages"
-
-
-if sys.version_info[:2] >= (3, 11): # pragma: >=3.11 cover # pytest.skip doesn't support covdefaults
- if sys.platform != "win32": # pragma: win32 no cover # pytest.skip doesn't support covdefaults
-
- def test_fallback_existent_system_executable(mocker: MockerFixture) -> None:
- current = PythonInfo()
- # Posix may execute a "python" out of a venv but try to set the base_executable
- # to "python" out of the system installation path. PEP 394 informs distributions
- # that "python" is not required and the standard `make install` does not provide one
-
- # Falsify some data to look like we're in a venv
- current.prefix = current.exec_prefix = "/tmp/tmp.izZNCyINRj/venv" # noqa: S108
- exe = os.path.join(current.prefix, "bin/python") # noqa: PTH118
- current.executable = current.original_executable = exe
-
- # Since we don't know if the distribution we're on provides python, use a binary that should not exist
- assert current.system_executable is not None
- mocker.patch.object(
- sys,
- "_base_executable",
- os.path.join(os.path.dirname(current.system_executable), "idontexist"), # noqa: PTH118,PTH120
- )
- mocker.patch.object(sys, "executable", current.executable)
-
- # ensure it falls back to an alternate binary name that exists
- current._fast_get_system_executable() # noqa: SLF001
- assert current.system_executable is not None
- assert os.path.basename(current.system_executable) in [ # noqa: PTH119
- f"python{v}"
- for v in (current.version_info.major, f"{current.version_info.major}.{current.version_info.minor}")
- ]
- assert current.system_executable is not None
- assert os.path.exists(current.system_executable) # noqa: PTH110
-
-
-@pytest.mark.skipif(sys.version_info[:2] != (3, 10), reason="Only runs on Python 3.10") # pragma: ==3.10 cover
-def test_uses_posix_prefix_on_debian_3_10_without_venv(mocker: MockerFixture) -> None:
- # this is taken from ubuntu 22.04 /usr/lib/python3.10/sysconfig.py
- sysconfig_install_schemes = {
- "posix_prefix": {
- "stdlib": "{installed_base}/{platlibdir}/python{py_version_short}",
- "platstdlib": "{platbase}/{platlibdir}/python{py_version_short}",
- "purelib": "{base}/lib/python{py_version_short}/site-packages",
- "platlib": "{platbase}/{platlibdir}/python{py_version_short}/site-packages",
- "include": "{installed_base}/include/python{py_version_short}{abiflags}",
- "platinclude": "{installed_platbase}/include/python{py_version_short}{abiflags}",
- "scripts": "{base}/bin",
- "data": "{base}",
- },
- "posix_home": {
- "stdlib": "{installed_base}/lib/python",
- "platstdlib": "{base}/lib/python",
- "purelib": "{base}/lib/python",
- "platlib": "{base}/lib/python",
- "include": "{installed_base}/include/python",
- "platinclude": "{installed_base}/include/python",
- "scripts": "{base}/bin",
- "data": "{base}",
- },
- "nt": {
- "stdlib": "{installed_base}/Lib",
- "platstdlib": "{base}/Lib",
- "purelib": "{base}/Lib/site-packages",
- "platlib": "{base}/Lib/site-packages",
- "include": "{installed_base}/Include",
- "platinclude": "{installed_base}/Include",
- "scripts": "{base}/Scripts",
- "data": "{base}",
- },
- "deb_system": {
- "stdlib": "{installed_base}/{platlibdir}/python{py_version_short}",
- "platstdlib": "{platbase}/{platlibdir}/python{py_version_short}",
- "purelib": "{base}/lib/python3/dist-packages",
- "platlib": "{platbase}/{platlibdir}/python3/dist-packages",
- "include": "{installed_base}/include/python{py_version_short}{abiflags}",
- "platinclude": "{installed_platbase}/include/python{py_version_short}{abiflags}",
- "scripts": "{base}/bin",
- "data": "{base}",
- },
- "posix_local": {
- "stdlib": "{installed_base}/{platlibdir}/python{py_version_short}",
- "platstdlib": "{platbase}/{platlibdir}/python{py_version_short}",
- "purelib": "{base}/local/lib/python{py_version_short}/dist-packages",
- "platlib": "{platbase}/local/lib/python{py_version_short}/dist-packages",
- "include": "{installed_base}/local/include/python{py_version_short}{abiflags}",
- "platinclude": "{installed_platbase}/local/include/python{py_version_short}{abiflags}",
- "scripts": "{base}/local/bin",
- "data": "{base}",
- },
- }
- # reset the default in case we're on a system which doesn't have this problem
- sysconfig_get_path = functools.partial(sysconfig.get_path, scheme="posix_local")
-
- # make it look like python3-distutils is not available
- mocker.patch.dict(sys.modules, {"distutils.command": None})
- mocker.patch("sysconfig._INSTALL_SCHEMES", sysconfig_install_schemes)
- mocker.patch("sysconfig.get_path", sysconfig_get_path)
- mocker.patch("sysconfig.get_default_scheme", return_value="posix_local")
-
- pyinfo = PythonInfo()
- pyver = f"{pyinfo.version_info.major}.{pyinfo.version_info.minor}"
- assert pyinfo.install_path("scripts") == "bin"
- assert pyinfo.install_path("purelib").replace(os.sep, "/") == f"lib/python{pyver}/site-packages"
diff --git a/tests/test_py_info_exe_based_of.py b/tests/test_py_info_exe_based_of.py
deleted file mode 100644
index 85a9a8e..0000000
--- a/tests/test_py_info_exe_based_of.py
+++ /dev/null
@@ -1,67 +0,0 @@
-from __future__ import annotations
-
-import logging
-import os
-import sys
-from pathlib import Path
-
-import pytest
-
-from py_discovery import PythonInfo
-from py_discovery._info import EXTENSIONS, fs_is_case_sensitive
-
-CURRENT = PythonInfo.current()
-
-
-def test_discover_empty_folder(tmp_path: Path) -> None:
- with pytest.raises(RuntimeError):
- CURRENT.discover_exe(prefix=str(tmp_path))
-
-
-BASE = (CURRENT.install_path("scripts"), ".")
-
-
-@pytest.mark.usefixtures("_fs_supports_symlink")
-@pytest.mark.parametrize("suffix", sorted({".exe", ".cmd", ""} & set(EXTENSIONS) if sys.platform == "win32" 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"])
-def test_discover_ok( # noqa: PLR0913
- tmp_path: Path,
- suffix: str,
- impl: str,
- version: str,
- arch: str,
- into: str,
- caplog: pytest.LogCaptureFixture,
-) -> None:
- caplog.set_level(logging.DEBUG) # pragma: no cover
- folder = tmp_path / into # pragma: no cover
- folder.mkdir(parents=True, exist_ok=True) # pragma: no cover
- name = f"{impl}{version}" # pragma: no cover
- if arch: # pragma: no cover
- name += f"-{arch}" # pragma: no cover
- name += suffix # pragma: no cover
- dest = folder / name # pragma: no cover
- assert CURRENT.executable is not None # pragma: no cover
- os.symlink(CURRENT.executable, str(dest)) # pragma: no cover
- pyvenv = Path(CURRENT.executable).parents[1] / "pyvenv.cfg" # pragma: no cover
- if pyvenv.exists(): # pragma: no cover
- (folder / pyvenv.name).write_text(pyvenv.read_text(encoding="utf-8"), encoding="utf-8") # pragma: no cover
- inside_folder = str(tmp_path) # pragma: no cover
- base = CURRENT.discover_exe(inside_folder) # pragma: no cover
- found = base.executable # pragma: no cover
- assert found is not None # pragma: no cover
- dest_str = str(dest) # pragma: no cover
- if not fs_is_case_sensitive(): # pragma: no cover
- found = found.lower() # pragma: no cover
- dest_str = dest_str.lower() # pragma: no cover
- assert found == dest_str # pragma: no cover
- assert len(caplog.messages) >= 1, caplog.text # pragma: no cover
- assert "get interpreter info via cmd: " in caplog.text # pragma: no cover
-
- dest.rename(dest.parent / (dest.name + "-1")) # pragma: no cover
- CURRENT._cache_exe_discovery.clear() # noqa: SLF001 # pragma: no cover
- with pytest.raises(RuntimeError): # pragma: no cover
- CURRENT.discover_exe(inside_folder) # pragma: no cover
diff --git a/tests/test_py_info_extra.py b/tests/test_py_info_extra.py
new file mode 100644
index 0000000..5c38afb
--- /dev/null
+++ b/tests/test_py_info_extra.py
@@ -0,0 +1,512 @@
+from __future__ import annotations
+
+import copy
+import logging
+import os
+import sys
+from pathlib import Path
+from typing import TYPE_CHECKING
+from unittest.mock import MagicMock
+
+import pytest
+
+from python_discovery import DiskCache, PythonInfo, PythonSpec
+from python_discovery._py_info import VersionInfo
+
+try:
+ import tkinter as tk # pragma: no cover
+except ImportError: # pragma: no cover
+ tk = None # type: ignore[assignment]
+
+if TYPE_CHECKING:
+ from pytest_mock import MockerFixture
+
+CURRENT = PythonInfo.current_system()
+
+
+def test_py_info_pypy_version(mocker: MockerFixture) -> None:
+ mocker.patch("platform.python_implementation", return_value="PyPy")
+ mocker.patch.object(sys, "pypy_version_info", (7, 3, 11, "final", 0), create=True)
+ info = PythonInfo()
+ assert info.implementation == "PyPy"
+ assert info.pypy_version_info == (7, 3, 11, "final", 0)
+
+
+def test_has_venv_attribute() -> None:
+ info = PythonInfo()
+ assert isinstance(info.has_venv, bool)
+
+
+def test_tcl_tk_libs_with_env(mocker: MockerFixture) -> None:
+ mocker.patch.dict(os.environ, {"TCL_LIBRARY": "/some/path"})
+ mocker.patch.object(PythonInfo, "_get_tcl_tk_libs", return_value=("/tcl", "/tk"))
+ info = PythonInfo()
+ assert info.tcl_lib == "/tcl"
+ assert info.tk_lib == "/tk"
+
+
+def test_get_tcl_tk_libs_returns_tuple() -> None:
+ tcl_path, tk_path = PythonInfo._get_tcl_tk_libs()
+ assert tcl_path is None or isinstance(tcl_path, str)
+ assert tk_path is None or isinstance(tk_path, str)
+
+
+@pytest.mark.skipif(tk is None, reason="tkinter not available")
+def test_get_tcl_tk_libs_tcl_error(mocker: MockerFixture) -> None: # pragma: no cover
+ mock_tcl = MagicMock()
+ mock_tcl.eval.side_effect = tk.TclError("fail")
+ mocker.patch("tkinter.Tcl", return_value=mock_tcl)
+
+ tcl, _tk = PythonInfo._get_tcl_tk_libs()
+ assert tcl is None
+
+
+def test_fast_get_system_executable_not_venv() -> None:
+ info = PythonInfo()
+ info.real_prefix = None
+ info.base_prefix = info.prefix
+ assert info._fast_get_system_executable() == info.original_executable
+
+
+def test_fast_get_system_executable_real_prefix() -> None:
+ info = PythonInfo()
+ info.real_prefix = "/some/real/prefix"
+ assert info._fast_get_system_executable() is None
+
+
+def test_fast_get_system_executable_no_base_executable(mocker: MockerFixture) -> None:
+ info = PythonInfo()
+ info.real_prefix = None
+ info.base_prefix = "/different/prefix"
+ mocker.patch.object(sys, "_base_executable", None, create=True)
+ assert info._fast_get_system_executable() is None
+
+
+def test_fast_get_system_executable_same_as_current(mocker: MockerFixture) -> None:
+ info = PythonInfo()
+ info.real_prefix = None
+ info.base_prefix = "/different/prefix"
+ mocker.patch.object(sys, "_base_executable", sys.executable, create=True)
+ assert info._fast_get_system_executable() is None
+
+
+def test_try_posix_fallback_not_posix() -> None:
+ info = PythonInfo()
+ info.os = "nt"
+ assert info._try_posix_fallback_executable("/some/python") is None
+
+
+def test_try_posix_fallback_old_python() -> None:
+ info = PythonInfo()
+ info.os = "posix"
+ info.version_info = VersionInfo(3, 10, 0, "final", 0)
+ assert info._try_posix_fallback_executable("/some/python") is None
+
+
+def test_try_posix_fallback_finds_versioned(tmp_path: Path) -> None:
+ info = PythonInfo()
+ info.os = "posix"
+ info.version_info = VersionInfo(3, 12, 0, "final", 0)
+ info.implementation = "CPython"
+ base_exe = str(tmp_path / "python")
+ versioned = tmp_path / "python3"
+ versioned.touch()
+ result = info._try_posix_fallback_executable(base_exe)
+ assert result == str(versioned)
+
+
+def test_try_posix_fallback_pypy(tmp_path: Path) -> None:
+ info = PythonInfo()
+ info.os = "posix"
+ info.version_info = VersionInfo(3, 12, 0, "final", 0)
+ info.implementation = "PyPy"
+ base_exe = str(tmp_path / "python")
+ pypy = tmp_path / "pypy3"
+ pypy.touch()
+ result = info._try_posix_fallback_executable(base_exe)
+ assert result == str(pypy)
+
+
+def test_try_posix_fallback_not_found(tmp_path: Path) -> None:
+ info = PythonInfo()
+ info.os = "posix"
+ info.version_info = VersionInfo(3, 12, 0, "final", 0)
+ info.implementation = "CPython"
+ base_exe = str(tmp_path / "python")
+ assert info._try_posix_fallback_executable(base_exe) is None
+
+
+def test_version_str() -> None:
+ 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])
+
+
+def test_python_name() -> None:
+ 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.real_prefix = "/some/prefix"
+ assert info.is_old_virtualenv is True
+ info.real_prefix = None
+ assert info.is_old_virtualenv is False
+
+
+def test_is_venv() -> None:
+ assert CURRENT.is_venv == (CURRENT.base_prefix is not None)
+
+
+def test_system_prefix() -> None:
+ info = copy.deepcopy(CURRENT)
+ info.real_prefix = "/real"
+ assert info.system_prefix == "/real"
+ info.real_prefix = None
+ info.base_prefix = "/base"
+ assert info.system_prefix == "/base"
+
+
+def test_system_exec_prefix() -> None:
+ info = copy.deepcopy(CURRENT)
+ info.real_prefix = "/real"
+ assert info.system_exec_prefix == "/real"
+ info.real_prefix = None
+ assert info.system_exec_prefix == info.base_exec_prefix or info.exec_prefix
+
+
+def test_repr() -> None:
+ result = repr(CURRENT)
+ assert "PythonInfo" in result
+
+
+def test_str() -> None:
+ result = str(CURRENT)
+ assert "PythonInfo" in result
+ assert "spec=" in result
+
+
+def test_machine_none_platform() -> None:
+ 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
+
+
+def test_from_dict() -> None:
+ data = CURRENT.to_dict()
+ restored = PythonInfo.from_dict(data)
+ 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.system_executable = None
+ target.real_prefix = None
+ target.base_prefix = "/prefix_a"
+
+ second = copy.deepcopy(CURRENT)
+ second.system_executable = None
+ second.real_prefix = None
+ second.base_prefix = "/prefix_b"
+
+ 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])
+
+ with pytest.raises(RuntimeError, match="prefixes are causing a circle"):
+ PythonInfo.resolve_to_system(None, target)
+
+
+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.system_executable = None
+ target.real_prefix = None
+ target.base_prefix = "/prefix_a"
+
+ 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)
+
+ result = PythonInfo.resolve_to_system(None, target)
+ assert result.system_executable is not None
+ assert any("links back to itself" in r.message for r in caplog.records)
+
+
+def test_discover_exe_cache_hit() -> None:
+ info = copy.deepcopy(CURRENT)
+ cached = copy.deepcopy(CURRENT)
+ PythonInfo._cache_exe_discovery["/some/prefix", True] = cached
+ try:
+ result = info.discover_exe(MagicMock(), prefix="/some/prefix", exact=True)
+ assert result is cached
+ finally:
+ del PythonInfo._cache_exe_discovery["/some/prefix", True]
+
+
+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)
+ 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
+
+
+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
+
+
+def test_satisfies_prerelease_version() -> None:
+ info = copy.deepcopy(CURRENT)
+ info.version_info = VersionInfo(3, 14, 0, "alpha", 1)
+ spec = PythonSpec.from_string_spec(">=3.14.0a1")
+ assert info.satisfies(spec, impl_must_match=False) is True
+
+
+def test_satisfies_prerelease_beta() -> None:
+ info = copy.deepcopy(CURRENT)
+ info.version_info = VersionInfo(3, 14, 0, "beta", 1)
+ spec = PythonSpec.from_string_spec(">=3.14.0b1")
+ assert info.satisfies(spec, impl_must_match=False) is True
+
+
+def test_satisfies_prerelease_candidate() -> None:
+ info = copy.deepcopy(CURRENT)
+ info.version_info = VersionInfo(3, 14, 0, "candidate", 1)
+ spec = PythonSpec.from_string_spec(">=3.14.0rc1")
+ assert info.satisfies(spec, impl_must_match=False) is True
+
+
+def test_satisfies_path_not_abs_basename_match() -> None:
+ info = copy.deepcopy(CURRENT)
+ basename = Path(info.original_executable).stem
+ spec = PythonSpec.from_string_spec(basename)
+ assert info.satisfies(spec, impl_must_match=False) is True
+
+
+def test_satisfies_path_not_abs_basename_no_match() -> None:
+ 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)
+ mocker.patch.object(sys, "platform", "win32")
+ info.original_executable = "/some/path/python.exe"
+ spec = PythonSpec.from_string_spec("python")
+ spec.path = "python"
+ assert info.satisfies(spec, impl_must_match=False) is True
+
+
+def test_distutils_install() -> None:
+ info = PythonInfo()
+ result = info._distutils_install()
+ assert isinstance(result, dict)
+
+
+def test_install_path() -> None:
+ assert isinstance(CURRENT.install_path("purelib"), str)
+
+
+def test_system_include() -> None:
+ result = CURRENT.system_include
+ assert isinstance(result, str)
+
+
+def test_system_include_fallback(mocker: MockerFixture) -> None:
+ info = copy.deepcopy(CURRENT)
+ mocker.patch("os.path.exists", side_effect=lambda p: "include" not in p or "dist" in p.lower())
+ result = info.system_include
+ assert isinstance(result, str)
+
+
+def test_sysconfig_path_missing_key() -> None:
+ assert not CURRENT.sysconfig_path("nonexistent_key")
+
+
+def test_sysconfig_path_with_config_var() -> None:
+ result = CURRENT.sysconfig_path("stdlib", {})
+ assert isinstance(result, str)
+
+
+def test_current_system_cached(session_cache: DiskCache) -> None:
+ PythonInfo._current_system = None
+ result1 = PythonInfo.current_system(session_cache)
+ result2 = PythonInfo.current_system(session_cache)
+ assert result1 is result2
+
+
+def test_current_cached(session_cache: DiskCache) -> None:
+ PythonInfo._current = None
+ result1 = PythonInfo.current(session_cache)
+ result2 = PythonInfo.current(session_cache)
+ assert result1 is result2
+
+
+def test_from_exe_resolve_error(mocker: MockerFixture, caplog: pytest.LogCaptureFixture) -> None:
+ caplog.set_level(logging.INFO)
+ fake_info = PythonInfo()
+ fake_info.original_executable = "/fake/python"
+ mocker.patch(
+ "python_discovery._cached_py_info.from_exe",
+ return_value=fake_info,
+ )
+ mocker.patch.object(PythonInfo, "resolve_to_system", side_effect=RuntimeError("test error"))
+ 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")
+ assert isinstance(result, str)
+ assert len(result) > 0
+
+
+def test_satisfies_abs_spec_path_falls_through() -> None:
+ 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)
+ 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)
+ with pytest.raises(RuntimeError, match="failed to query current Python interpreter"):
+ PythonInfo.current()
+ PythonInfo._current = None
+
+
+def test_current_system_returns_none_raises(mocker: MockerFixture) -> None:
+ PythonInfo._current_system = None
+ mocker.patch.object(PythonInfo, "from_exe", return_value=None)
+ 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)
+ 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)
+ discovered: list[PythonInfo] = []
+ result = info._check_exe(MagicMock(), 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)
+ discovered: list[PythonInfo] = []
+ result = info._check_exe(MagicMock(), 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.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.executable = "/usr/bin/python"
+ info.implementation = "CPython"
+ names = list(info._possible_base())
+ assert "python" in names
+ assert "cpython" in names
+
+
+def test_possible_base_case_sensitive(mocker: MockerFixture) -> None:
+ info = copy.deepcopy(CURRENT)
+ info.executable = "/usr/bin/CPython3.12"
+ info.implementation = "CPython"
+ mocker.patch("python_discovery._compat.fs_is_case_sensitive", return_value=True)
+ 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()]
+ assert len(lower_names) >= 1
+ assert len(upper_names) >= 1
+
+
+def test_possible_base_case_sensitive_upper_equals_base(mocker: MockerFixture) -> None:
+ info = copy.deepcopy(CURRENT)
+ info.executable = "/usr/bin/JYTHON"
+ info.implementation = "JYTHON"
+ mocker.patch("python_discovery._compat.fs_is_case_sensitive", return_value=True)
+ names = list(info._possible_base())
+ assert "jython" in names
+ assert "JYTHON" in names
+
+
+def test_resolve_to_system_resolved_from_exe(mocker: MockerFixture, caplog: pytest.LogCaptureFixture) -> None:
+ caplog.set_level(logging.DEBUG)
+ target = copy.deepcopy(CURRENT)
+ target.system_executable = "/some/system/python"
+ target.executable = "/some/venv/python"
+
+ resolved = copy.deepcopy(CURRENT)
+ resolved.system_executable = "/some/system/python"
+ resolved.executable = "/some/system/python"
+
+ mocker.patch.object(PythonInfo, "from_exe", return_value=resolved)
+ result = PythonInfo.resolve_to_system(None, target)
+ assert result.executable == "/some/venv/python"
+
+
+def test_resolve_to_system_from_exe_returns_none(mocker: MockerFixture) -> None:
+ target = copy.deepcopy(CURRENT)
+ target.system_executable = "/some/system/python"
+ target.executable = "/some/venv/python"
+
+ mocker.patch.object(PythonInfo, "from_exe", return_value=None)
+ result = PythonInfo.resolve_to_system(None, target)
+ assert result.executable == "/some/venv/python"
diff --git a/tests/test_py_spec.py b/tests/test_py_spec.py
index c861536..af31356 100644
--- a/tests/test_py_spec.py
+++ b/tests/test_py_spec.py
@@ -1,12 +1,13 @@
from __future__ import annotations
import sys
-from copy import copy
-from typing import TYPE_CHECKING, Tuple, cast
+from typing import TYPE_CHECKING
import pytest
-from py_discovery import PythonSpec
+from python_discovery import PythonSpec
+from python_discovery._py_info import normalize_isa
+from python_discovery._specifier import SimpleSpecifierSet as SpecifierSet
if TYPE_CHECKING:
from pathlib import Path
@@ -35,7 +36,7 @@ def test_spec_satisfies_path_ok() -> None:
assert spec.satisfies(spec) is True
-def test_spec_satisfies_path_nok(tmp_path: Path) -> None:
+def test_spec_satisfies_path_nok(tmp_path: pytest.TempPathFactory) -> None:
spec = PythonSpec.from_string_spec(sys.executable)
of = PythonSpec.from_string_spec(str(tmp_path))
assert spec.satisfies(of) is False
@@ -49,6 +50,16 @@ def test_spec_satisfies_arch() -> None:
assert spec_2.satisfies(spec_1) is False
+def test_spec_satisfies_free_threaded() -> None:
+ spec_1 = PythonSpec.from_string_spec("python3.13t")
+ spec_2 = PythonSpec.from_string_spec("python3.13")
+
+ assert spec_1.satisfies(spec_1) is True
+ assert spec_1.free_threaded is True
+ assert spec_2.satisfies(spec_1) is False
+ assert spec_2.free_threaded is False
+
+
@pytest.mark.parametrize(
("req", "spec"),
[("py", "python"), ("jython", "jython"), ("CPython", "cpython")],
@@ -70,13 +81,18 @@ def test_spec_satisfies_implementation_nok() -> None:
def _version_satisfies_pairs() -> list[tuple[str, str]]:
target: set[tuple[str, str]] = set()
version = tuple(str(i) for i in sys.version_info[0:3])
- for i in range(len(version) + 1):
- req = ".".join(version[0:i])
- for j in range(i + 1):
- sat = ".".join(version[0:j])
- # can be satisfied in both directions
- target.add((req, sat))
- target.add((sat, req))
+ for threading in (False, True):
+ for depth in range(len(version) + 1):
+ req = ".".join(version[0:depth])
+ for sub in range(depth + 1):
+ sat = ".".join(version[0:sub])
+ if sat:
+ target.add((req, sat))
+ target.add((sat, req))
+ if threading and sat and req:
+ target.add((f"{req}t", f"{sat}t"))
+ target.add((f"{sat}t", f"{req}t"))
+
return sorted(target)
@@ -89,18 +105,19 @@ def test_version_satisfies_ok(req: str, spec: str) -> None:
def _version_not_satisfies_pairs() -> list[tuple[str, str]]:
target: set[tuple[str, str]] = set()
- version = tuple(str(i) for i in cast(Tuple[int, ...], sys.version_info[0:3]))
+ version = tuple(str(i) for i in sys.version_info[0:3])
for major in range(len(version)):
req = ".".join(version[0 : major + 1])
for minor in range(major + 1):
- sat_ver = list(cast(Tuple[int, ...], sys.version_info[0 : minor + 1]))
+ sat_ver: list[int] = [int(v) for v in sys.version_info[0 : minor + 1]]
for patch in range(minor + 1):
- for o in [1, -1]:
- temp = copy(sat_ver)
- temp[patch] += o
- if temp[patch] >= 0: # pragma: no branch
- sat = ".".join(str(i) for i in temp)
- target.add((req, sat))
+ for offset in [1, -1]:
+ 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))
return sorted(target)
@@ -116,3 +133,105 @@ def test_relative_spec(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
a_relative_path = str((tmp_path / "a" / "b").relative_to(tmp_path))
spec = PythonSpec.from_string_spec(a_relative_path)
assert spec.path == a_relative_path
+
+
+@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"),
+ ],
+)
+def test_specifier_parsing(text: str, expected: str) -> None:
+ spec = PythonSpec.from_string_spec(text)
+ assert spec.version_specifier == SpecifierSet.from_string(expected)
+
+
+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")
+
+
+def test_specifier_satisfies_with_partial_information() -> None:
+ spec = PythonSpec.from_string_spec(">=3.12")
+ candidate = PythonSpec.from_string_spec("python3.12")
+ assert candidate.satisfies(spec) is True
+
+
+@pytest.mark.parametrize(
+ ("spec_str", "expected_machine"),
+ [
+ pytest.param("cpython3.12-64-arm64", "arm64", id="arm64"),
+ pytest.param("cpython3.12-64-x86_64", "x86_64", id="x86_64"),
+ pytest.param("cpython3.12-32-x86", "x86", id="x86"),
+ pytest.param("cpython3.12-64-aarch64", "arm64", id="aarch64"),
+ pytest.param("cpython3.12-64-ppc64le", "ppc64le", id="ppc64le"),
+ pytest.param("cpython3.12-64-s390x", "s390x", id="s390x"),
+ pytest.param("cpython3.12-64-riscv64", "riscv64", id="riscv64"),
+ pytest.param("cpython3.12-64", None, id="no-machine"),
+ pytest.param("cpython3.12", None, id="no-arch-no-machine"),
+ pytest.param("python3.12-64-arm64", "arm64", id="python-impl"),
+ ],
+)
+def test_spec_parse_machine(spec_str: str, expected_machine: str | None) -> None:
+ spec = PythonSpec.from_string_spec(spec_str)
+ assert spec.machine == expected_machine
+
+
+@pytest.mark.parametrize(
+ ("spec_str", "expected_arch", "expected_machine"),
+ [
+ pytest.param("cpython3.12-64-arm64", 64, "arm64", id="64bit-arm64"),
+ pytest.param("cpython3.12-32-x86", 32, "x86", id="32bit-x86"),
+ pytest.param("cpython3.12-64", 64, None, id="64bit-no-machine"),
+ ],
+)
+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
+
+
+@pytest.mark.parametrize(
+ ("left", "right", "expected"),
+ [
+ pytest.param("cpython3.12-64-arm64", "cpython3.12-64-arm64", True, id="same-machine"),
+ pytest.param("cpython3.12-64-arm64", "cpython3.12-64-x86_64", False, id="different-machine"),
+ pytest.param("cpython3.12-64-arm64", "cpython3.12-64", True, id="none-matches-any"),
+ pytest.param("cpython3.12-64-amd64", "cpython3.12-64-x86_64", True, id="amd64-eq-x86_64"),
+ pytest.param("cpython3.12-64-aarch64", "cpython3.12-64-arm64", True, id="aarch64-eq-arm64"),
+ ],
+)
+def test_spec_satisfies_machine(left: str, right: str, expected: bool) -> None:
+ assert PythonSpec.from_string_spec(left).satisfies(PythonSpec.from_string_spec(right)) is expected
+
+
+@pytest.mark.parametrize(
+ ("isa", "normalized"),
+ [
+ pytest.param("amd64", "x86_64", id="amd64"),
+ pytest.param("aarch64", "arm64", id="aarch64"),
+ pytest.param("x86_64", "x86_64", id="x86_64"),
+ pytest.param("arm64", "arm64", id="arm64"),
+ pytest.param("x86", "x86", id="x86"),
+ pytest.param("ppc64le", "ppc64le", id="ppc64le"),
+ pytest.param("riscv64", "riscv64", id="riscv64"),
+ pytest.param("s390x", "s390x", id="s390x"),
+ ],
+)
+def test_normalize_isa(isa: str, normalized: str) -> None:
+ assert normalize_isa(isa) == normalized
+
+
+@pytest.mark.parametrize(
+ ("spec_str", "in_repr"),
+ [
+ pytest.param("cpython3.12-64-arm64", "machine=arm64", id="with-machine"),
+ pytest.param("cpython3.12-64", "architecture=64", id="without-machine"),
+ ],
+)
+def test_spec_repr_machine(spec_str: str, in_repr: str) -> None:
+ assert in_repr in repr(PythonSpec.from_string_spec(spec_str))
diff --git a/tests/test_py_spec_extra.py b/tests/test_py_spec_extra.py
new file mode 100644
index 0000000..d779e23
--- /dev/null
+++ b/tests/test_py_spec_extra.py
@@ -0,0 +1,130 @@
+from __future__ import annotations
+
+from unittest.mock import MagicMock
+
+from python_discovery import PythonSpec
+
+
+def test_specifier_parse_failure_fallback() -> None:
+ spec = PythonSpec.from_string_spec("not_a_valid_anything_really")
+ assert spec.path == "not_a_valid_anything_really"
+ assert spec.version_specifier is None
+
+
+def test_version_specifier_satisfies_micro() -> None:
+ spec = PythonSpec.from_string_spec(">=3.12.0")
+ candidate = PythonSpec("", "CPython", 3, 12, 1, None, None)
+ assert candidate.satisfies(spec) is True
+
+
+def test_version_specifier_satisfies_fails_micro() -> None:
+ spec = PythonSpec.from_string_spec(">=3.13.0")
+ candidate = PythonSpec("", "CPython", 3, 12, 5, None, None)
+ assert candidate.satisfies(spec) is False
+
+
+def test_version_specifier_no_components() -> None:
+ spec = PythonSpec.from_string_spec(">=3.12")
+ candidate = PythonSpec("", None, None, None, None, None, None)
+ assert candidate.satisfies(spec) is True
+
+
+def test_check_version_specifier_precision() -> None:
+ spec = PythonSpec.from_string_spec(">=3.12")
+ candidate = PythonSpec("", "CPython", 3, None, None, None, None)
+ assert candidate._check_version_specifier(spec) is True
+
+
+def test_check_version_specifier_precision_micro() -> None:
+ spec = PythonSpec.from_string_spec(">=3.12.0")
+ candidate = PythonSpec("", "CPython", 3, 12, None, None, None)
+ assert candidate._check_version_specifier(spec) is True
+
+
+def test_check_version_specifier_fails() -> None:
+ spec = PythonSpec.from_string_spec(">=3.13")
+ candidate = PythonSpec("", "CPython", 3, 12, 0, None, None)
+ assert candidate._check_version_specifier(spec) is False
+
+
+def test_check_version_specifier_none() -> None:
+ spec = PythonSpec("", None, None, None, None, None, None)
+ candidate = PythonSpec("", "CPython", 3, 12, 0, None, None)
+ assert candidate._check_version_specifier(spec) is True
+
+
+def test_get_required_precision_none() -> None:
+ from python_discovery._specifier import SimpleSpecifier
+
+ specifier = SimpleSpecifier(
+ spec_str=">=3.12",
+ operator=">=",
+ version_str="3.12",
+ is_wildcard=False,
+ wildcard_precision=None,
+ version=None,
+ )
+ assert PythonSpec._get_required_precision(specifier) is None
+
+
+def test_get_required_precision_normal() -> None:
+ from python_discovery._specifier import SimpleSpecifier
+
+ specifier = SimpleSpecifier.from_string(">=3.12.0")
+ assert PythonSpec._get_required_precision(specifier) == 3
+
+
+def test_generate_re_no_threaded() -> None:
+ spec = PythonSpec.from_string_spec("python3.12")
+ pat = spec.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)
+ assert pat.fullmatch("python3.12t") is not None
+
+
+def test_single_digit_version() -> None:
+ spec = PythonSpec.from_string_spec("python3")
+ assert spec.major == 3
+ assert spec.minor is None
+
+
+def test_specifier_with_invalid_inner() -> None:
+ spec = PythonSpec.from_string_spec(">=not_a_version")
+ assert spec.path is None or spec.version_specifier is not None or spec.path == ">=not_a_version"
+
+
+def test_two_digit_version() -> None:
+ spec = PythonSpec.from_string_spec("python312")
+ assert spec.major == 3
+ assert spec.minor == 12
+
+
+def test_single_digit_major_only() -> None:
+ spec = PythonSpec.from_string_spec("python3")
+ assert spec.major == 3
+ assert spec.minor is None
+
+
+def test_specifier_set_parsed_for_valid_format() -> None:
+ spec = PythonSpec.from_string_spec("cpython>=3.12")
+ assert spec.version_specifier is not None
+ assert spec.implementation == "cpython"
+
+
+def test_get_required_precision_attribute_error() -> None:
+ from python_discovery._specifier import SimpleSpecifier
+
+ mock_version = MagicMock(spec=[])
+ specifier = SimpleSpecifier(
+ spec_str=">=3.12",
+ operator=">=",
+ version_str="3.12",
+ is_wildcard=False,
+ wildcard_precision=None,
+ version=mock_version,
+ )
+ assert PythonSpec._get_required_precision(specifier) is None
diff --git a/tests/test_specifier.py b/tests/test_specifier.py
new file mode 100644
index 0000000..00d19a9
--- /dev/null
+++ b/tests/test_specifier.py
@@ -0,0 +1,299 @@
+from __future__ import annotations
+
+import pytest
+
+from python_discovery._specifier import SimpleSpecifier, SimpleSpecifierSet, SimpleVersion
+
+# --- SimpleVersion ---
+
+
+@pytest.mark.parametrize(
+ ("version_str", "release", "pre_type", "pre_num"),
+ [
+ pytest.param("3.11.2", (3, 11, 2), None, None, id="basic"),
+ pytest.param("3.14.0a1", (3, 14, 0), "a", 1, id="alpha"),
+ pytest.param("3.14.0b2", (3, 14, 0), "b", 2, id="beta"),
+ pytest.param("3.14.0rc1", (3, 14, 0), "rc", 1, id="rc"),
+ pytest.param("3", (3, 0, 0), None, None, id="major-only"),
+ pytest.param("3.12", (3, 12, 0), None, None, id="major-minor"),
+ ],
+)
+def test_version_parse(
+ version_str: str,
+ release: tuple[int, int, int],
+ pre_type: str | None,
+ 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
+
+
+def test_version_invalid_raises() -> None:
+ with pytest.raises(ValueError, match="Invalid version"):
+ SimpleVersion.from_string("not_a_version")
+
+
+def test_version_eq_same() -> None:
+ assert SimpleVersion.from_string("3.11") == SimpleVersion.from_string("3.11")
+
+
+def test_version_eq_different() -> None:
+ assert SimpleVersion.from_string("3.11") != SimpleVersion.from_string("3.12")
+
+
+def test_version_eq_not_implemented() -> None:
+ result = SimpleVersion.from_string("3.11").__eq__("3.11") # noqa: PLC2801
+ assert result is NotImplemented
+
+
+def test_version_hash() -> None:
+ assert hash(SimpleVersion.from_string("3.11")) == hash(SimpleVersion.from_string("3.11"))
+
+
+@pytest.mark.parametrize(
+ ("left", "right", "expected"),
+ [
+ pytest.param("3.11", "3.12", True, id="release"),
+ pytest.param("3.14.0a1", "3.14.0", True, id="prerelease-vs-final"),
+ pytest.param("3.14.0", "3.14.0a1", False, id="final-not-less-than-prerelease"),
+ pytest.param("3.14.0", "3.14.0", False, id="equal"),
+ pytest.param("3.14.0a1", "3.14.0b1", True, id="alpha-lt-beta"),
+ pytest.param("3.14.0b1", "3.14.0rc1", True, id="beta-lt-rc"),
+ pytest.param("3.14.0a1", "3.14.0a2", True, id="same-type-ordering"),
+ ],
+)
+def test_version_lt(left: str, right: str, expected: bool) -> None:
+ assert (SimpleVersion.from_string(left) < SimpleVersion.from_string(right)) is expected
+
+
+def test_version_lt_not_implemented() -> None:
+ result = SimpleVersion.from_string("3.11").__lt__("3.12") # noqa: PLC2801
+ assert result is NotImplemented
+
+
+def test_version_le() -> None:
+ assert SimpleVersion.from_string("3.11") <= SimpleVersion.from_string("3.12")
+ assert SimpleVersion.from_string("3.11") <= SimpleVersion.from_string("3.11")
+
+
+def test_version_gt() -> None:
+ assert SimpleVersion.from_string("3.12") > SimpleVersion.from_string("3.11")
+
+
+def test_version_gt_not_implemented() -> None:
+ result = SimpleVersion.from_string("3.11").__gt__("3.11") # noqa: PLC2801
+ assert result is NotImplemented
+
+
+def test_version_ge() -> None:
+ assert SimpleVersion.from_string("3.12") >= SimpleVersion.from_string("3.11")
+ assert SimpleVersion.from_string("3.12") >= SimpleVersion.from_string("3.12")
+
+
+def test_version_str() -> None:
+ assert str(SimpleVersion.from_string("3.11")) == "3.11"
+
+
+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")
+
+
+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.is_wildcard is False
+
+
+def test_specifier_parse_wildcard() -> None:
+ spec = SimpleSpecifier.from_string("==3.11.*")
+ assert spec.is_wildcard is True
+ assert spec.wildcard_precision == 2
+
+
+@pytest.mark.parametrize(
+ ("spec_str", "version_str", "expected"),
+ [
+ pytest.param("==3.11.*", "3.11.5", True, id="wildcard-eq-match"),
+ pytest.param("==3.11.*", "3.12.0", False, id="wildcard-eq-no-match"),
+ pytest.param("!=3.11.*", "3.11.5", False, id="wildcard-ne-match"),
+ pytest.param("!=3.11.*", "3.12.0", True, id="wildcard-ne-no-match"),
+ pytest.param(">=3.11.*", "3.11.5", False, id="wildcard-unsupported-op"),
+ pytest.param(">=3.12", "3.12.0", True, id="gte-match"),
+ pytest.param(">=3.12", "3.13.0", True, id="gte-above"),
+ pytest.param(">=3.12", "3.11.0", False, id="gte-below"),
+ pytest.param("<=3.12", "3.12.0", True, id="lte-match"),
+ pytest.param("<=3.12", "3.11.0", True, id="lte-below"),
+ pytest.param("<=3.12", "3.13.0", False, id="lte-above"),
+ pytest.param(">3.12", "3.13.0", True, id="gt-above"),
+ pytest.param(">3.12", "3.12.0", False, id="gt-equal"),
+ pytest.param("<3.12", "3.11.0", True, id="lt-below"),
+ pytest.param("<3.12", "3.12.0", False, id="lt-equal"),
+ pytest.param("==3.12.0", "3.12.0", True, id="eq-match"),
+ pytest.param("==3.12.0", "3.12.1", False, id="eq-no-match"),
+ pytest.param("!=3.12.0", "3.12.0", False, id="ne-match"),
+ pytest.param("!=3.12.0", "3.12.1", True, id="ne-no-match"),
+ pytest.param("===3.12", "3.12", True, id="exact-match"),
+ pytest.param("===3.12", "3.12.0", False, id="exact-no-match"),
+ pytest.param("~=3.12.0", "3.12.5", True, id="compatible-above"),
+ pytest.param("~=3.12.0", "3.13.0", False, id="compatible-next-minor"),
+ pytest.param("~=3.12.0", "3.11.0", False, id="compatible-below"),
+ pytest.param("~=3.12.0", "3.11.9", False, id="compatible-just-below"),
+ pytest.param(">=3.12", "not_a_version", False, id="invalid-version"),
+ ],
+)
+def test_specifier_contains(spec_str: str, version_str: str, expected: bool) -> None:
+ spec = SimpleSpecifier.from_string(spec_str)
+ assert spec.contains(version_str) is expected
+
+
+def test_specifier_contains_version_none() -> None:
+ spec = SimpleSpecifier(
+ spec_str=">=3.12",
+ operator=">=",
+ version_str="3.12",
+ is_wildcard=False,
+ wildcard_precision=None,
+ version=None,
+ )
+ assert spec.contains("3.12") is False
+
+
+def test_specifier_wildcard_version_none() -> None:
+ spec = SimpleSpecifier(
+ spec_str="==3.11.*",
+ operator="==",
+ version_str="3.11",
+ is_wildcard=True,
+ wildcard_precision=2,
+ version=None,
+ )
+ assert spec.contains("3.11.0") is False
+
+
+def test_specifier_compatible_release_version_none() -> None:
+ spec = SimpleSpecifier(
+ spec_str="~=3.12",
+ operator="~=",
+ version_str="3.12",
+ is_wildcard=False,
+ wildcard_precision=None,
+ version=None,
+ )
+ assert spec._check_compatible_release(SimpleVersion.from_string("3.12")) is False
+
+
+def test_specifier_eq() -> None:
+ assert SimpleSpecifier.from_string(">=3.12") == SimpleSpecifier.from_string(">=3.12")
+
+
+def test_specifier_eq_not_implemented() -> None:
+ result = SimpleSpecifier.from_string(">=3.12").__eq__(">=3.12") # noqa: PLC2801
+ assert result is NotImplemented
+
+
+def test_specifier_hash() -> None:
+ assert hash(SimpleSpecifier.from_string(">=3.12")) == hash(SimpleSpecifier.from_string(">=3.12"))
+
+
+def test_specifier_str() -> None:
+ assert str(SimpleSpecifier.from_string(">=3.12")) == ">=3.12"
+
+
+def test_specifier_repr() -> None:
+ assert repr(SimpleSpecifier.from_string(">=3.12")) == "SimpleSpecifier('>=3.12')"
+
+
+def test_specifier_version_parse_failure_stores_none() -> None:
+ spec = SimpleSpecifier.from_string(">=abc.*")
+ assert spec.version is None
+
+
+def test_specifier_unknown_operator() -> None:
+ spec = SimpleSpecifier(
+ spec_str="??3.12",
+ operator="??",
+ version_str="3.12",
+ is_wildcard=False,
+ wildcard_precision=None,
+ version=SimpleVersion.from_string("3.12"),
+ )
+ assert spec.contains("3.12.0") is False
+
+
+# --- SimpleSpecifierSet ---
+
+
+@pytest.mark.parametrize(
+ ("spec_str", "version_str", "expected"),
+ [
+ pytest.param("", "3.12", True, id="empty-always-matches"),
+ pytest.param(">=3.12", "3.12.0", True, id="single-match"),
+ pytest.param(">=3.12", "3.11.0", False, id="single-no-match"),
+ pytest.param(">=3.12,<3.14", "3.12.0", True, id="compound-lower-bound"),
+ pytest.param(">=3.12,<3.14", "3.13.0", True, id="compound-middle"),
+ pytest.param(">=3.12,<3.14", "3.14.0", False, id="compound-upper-bound"),
+ pytest.param(">=3.12,<3.14", "3.11.0", False, id="compound-below"),
+ ],
+)
+def test_specifier_set_contains(spec_str: str, version_str: str, expected: bool) -> None:
+ spec_set = SimpleSpecifierSet.from_string(spec_str)
+ assert spec_set.contains(version_str) is expected
+
+
+def test_specifier_set_iter() -> None:
+ spec_set = SimpleSpecifierSet.from_string(">=3.12,<3.14")
+ specs = list(spec_set)
+ assert len(specs) == 2
+
+
+def test_specifier_set_eq() -> None:
+ assert SimpleSpecifierSet.from_string(">=3.12") == SimpleSpecifierSet.from_string(">=3.12")
+
+
+def test_specifier_set_eq_not_implemented() -> None:
+ result = SimpleSpecifierSet.from_string(">=3.12").__eq__(">=3.12") # noqa: PLC2801
+ assert result is NotImplemented
+
+
+def test_specifier_set_hash() -> None:
+ assert hash(SimpleSpecifierSet.from_string(">=3.12")) == hash(SimpleSpecifierSet.from_string(">=3.12"))
+
+
+def test_specifier_set_str() -> None:
+ assert str(SimpleSpecifierSet.from_string(">=3.12")) == ">=3.12"
+
+
+def test_specifier_set_repr() -> None:
+ assert repr(SimpleSpecifierSet.from_string(">=3.12")) == "SimpleSpecifierSet('>=3.12')"
+
+
+def test_specifier_set_invalid_specifier_skipped() -> None:
+ spec_set = SimpleSpecifierSet.from_string(">=3.12, invalid_spec")
+ assert len(spec_set.specifiers) == 1
+
+
+def test_specifier_set_contains_no_specifiers() -> None:
+ spec_set = SimpleSpecifierSet.from_string()
+ assert spec_set.contains("3.12") is True
+
+
+def test_specifier_set_empty_item_in_comma_list() -> None:
+ spec_set = SimpleSpecifierSet.from_string(">=3.12,,<3.14")
+ assert len(spec_set.specifiers) == 2
+
+
+def test_specifier_compatible_release_major_only() -> None:
+ spec = SimpleSpecifier.from_string("~=3")
+ assert spec.contains("3.0.0") is True
+ assert spec.contains("3.0.5") is True
diff --git a/tests/test_version.py b/tests/test_version.py
deleted file mode 100644
index eaff7c0..0000000
--- a/tests/test_version.py
+++ /dev/null
@@ -1,7 +0,0 @@
-from __future__ import annotations
-
-
-def test_version() -> None:
- from py_discovery import __version__
-
- assert __version__
diff --git a/tests/windows/conftest.py b/tests/windows/conftest.py
new file mode 100644
index 0000000..25d5a35
--- /dev/null
+++ b/tests/windows/conftest.py
@@ -0,0 +1,175 @@
+from __future__ import annotations
+
+import os
+import sys
+from contextlib import contextmanager
+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 typing_extensions import Self
+
+
+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) # noqa: S102
+ return loc["enum_collect"], loc["value_collect"], loc["key_open"], loc["hive_open"] # type: ignore[return-value]
+
+
+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
+
+
+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 # type: ignore[return-value]
+
+ return _enum_key
+
+
+def _make_query_value_ex(value_collect: dict[object, dict[str, object]]) -> object:
+ def _query_value_ex(key: object, value_name: str) -> object:
+ key_id = key.value if isinstance(key, _Key) else key
+ result = value_collect[key_id][value_name]
+ 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]:
+ if len(args) == 2:
+ key, value = args
+ key_id = key.value if isinstance(key, _Key) else key
+ result = _Key(key_open[key_id][value])
+ elif len(args) == 4:
+ result = hive_open[args]
+ else:
+ raise RuntimeError
+ value = result.value if isinstance(result, _Key) else result
+ if isinstance(value, OSError):
+ raise value
+ yield result
+
+ return _open_key_ex
+
+
+@pytest.fixture
+def _mock_registry(monkeypatch: pytest.MonkeyPatch) -> None:
+ if sys.platform != "win32":
+ winreg = _create_winreg_mock()
+ monkeypatch.setitem(sys.modules, "winreg", winreg)
+ else:
+ import winreg
+
+ enum_collect, value_collect, key_open, hive_open = _load_registry_data(winreg)
+
+ monkeypatch.setattr(winreg, "EnumKey", _make_enum_key(enum_collect))
+ monkeypatch.setattr(winreg, "QueryValueEx", _make_query_value_ex(value_collect))
+ monkeypatch.setattr(winreg, "OpenKeyEx", _make_open_key_ex(key_open, hive_open))
+ real_exists = os.path.exists
+
+ def _mock_exists(path: str) -> bool:
+ if isinstance(path, str) and ("\\" in path or path.startswith(("C:", "Z:"))):
+ return True
+ return real_exists(path)
+
+ monkeypatch.setattr("os.path.exists", _mock_exists)
+
+
+def _mock_pyinfo(major: int, minor: int, arch: int, exe: str, threaded: bool = False) -> MagicMock:
+ from python_discovery._py_info import VersionInfo
+
+ 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)
+
+ info.satisfies = satisfies
+ return info
+
+
+@pytest.fixture
+def _populate_pyinfo_cache(monkeypatch: pytest.MonkeyPatch) -> None:
+ from python_discovery._cached_py_info import _CACHE
+
+ python_core_path = "C:\\Users\\user\\AppData\\Local\\Programs\\Python"
+ interpreters = [
+ ("ContinuumAnalytics", 3, 10, 32, False, "C:\\Users\\user\\Miniconda3\\python.exe"),
+ ("ContinuumAnalytics", 3, 10, 64, False, "C:\\Users\\user\\Miniconda3-64\\python.exe"),
+ ("PythonCore", 3, 9, 64, False, f"{python_core_path}\\Python36\\python.exe"),
+ ("PythonCore", 3, 9, 64, False, f"{python_core_path}\\Python36\\python.exe"),
+ ("PythonCore", 3, 5, 64, False, f"{python_core_path}\\Python35\\python.exe"),
+ ("PythonCore", 3, 9, 64, False, f"{python_core_path}\\Python36\\python.exe"),
+ ("PythonCore", 3, 7, 32, False, f"{python_core_path}\\Python37-32\\python.exe"),
+ ("PythonCore", 3, 12, 64, False, f"{python_core_path}\\Python312\\python.exe"),
+ ("PythonCore", 3, 13, 64, True, f"{python_core_path}\\Python313\\python3.13t.exe"),
+ ("PythonCore", 2, 7, 64, False, "C:\\Python27\\python.exe"),
+ ("PythonCore", 3, 4, 64, False, "C:\\Python34\\python.exe"),
+ ("CompanyA", 3, 6, 64, False, "Z:\\CompanyA\\Python\\3.6\\python.exe"),
+ ]
+ for _, major, minor, arch, threaded, exe in interpreters:
+ info = _mock_pyinfo(major, minor, arch, exe, threaded)
+ monkeypatch.setitem(_CACHE, Path(info.executable), info)
diff --git a/tests/windows/test_windows.py b/tests/windows/test_windows.py
index d4f868d..a61aedd 100644
--- a/tests/windows/test_windows.py
+++ b/tests/windows/test_windows.py
@@ -1,182 +1,35 @@
from __future__ import annotations
import sys
-import textwrap
-from contextlib import contextmanager
-from pathlib import Path
-from typing import TYPE_CHECKING, Any, Iterator, Tuple, cast
import pytest
-from py_discovery import PythonInfo, PythonSpec, VersionInfo
-from py_discovery._windows import discover_pythons, pep514, propose_interpreters # type: ignore[attr-defined]
-
-if TYPE_CHECKING:
- from types import TracebackType
-
- from pytest_mock import MockerFixture
-
- if sys.version_info >= (3, 11): # pragma: no cover (py311+)
- from typing import Self
- else: # pragma: no cover ( None: # noqa: C901
- loc: dict[str, Any] = {}
- glob: dict[str, Any] = {}
- mock_value_str = (Path(__file__).parent / "winreg-mock-values.py").read_text(encoding="utf-8")
- exec(mock_value_str, glob, loc) # noqa: S102
- enum_collect: dict[int, list[str | OSError]] = loc["enum_collect"]
- value_collect: dict[int, dict[str, tuple[str, int] | OSError]] = loc["value_collect"]
- key_open: dict[int, dict[str, int | OSError]] = loc["key_open"]
- hive_open: dict[tuple[int, str, int, int], int | OSError] = loc["hive_open"]
-
- 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
-
- def _enum_key(key: int | Key, 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
-
- def _query_value_ex(key: Key, value_name: str) -> tuple[str, int]:
- key_id = key.value if isinstance(key, Key) else key
- result = value_collect[key_id][value_name]
- if isinstance(result, OSError):
- raise result
- return result
-
- @contextmanager
- def _open_key_ex(*args: str | int) -> Iterator[Key | int]:
- if len(args) == 2:
- key, value = cast(int, args[0]), cast(str, args[1])
- key_id = key.value if isinstance(key, Key) else key
- got = key_open[key_id][value]
- if isinstance(got, OSError):
- raise got
- yield Key(got) # this needs to be something that can be withed, so let's wrap it
- elif len(args) == 4: # pragma: no branch
- got = hive_open[cast(Tuple[int, str, int, int], args)]
- if isinstance(got, OSError):
- raise got
- yield got
- else:
- raise RuntimeError # pragma: no cover
-
- mocker.patch("py_discovery._windows.pep514.EnumKey", side_effect=_enum_key)
- mocker.patch("py_discovery._windows.pep514.QueryValueEx", side_effect=_query_value_ex)
- mocker.patch("py_discovery._windows.pep514.OpenKeyEx", side_effect=_open_key_ex)
- mocker.patch("os.path.exists", return_value=True)
+from python_discovery import PythonSpec
+@pytest.mark.skipif(sys.platform != "win32", reason="propose_interpreters calls from_exe with Windows paths")
@pytest.mark.usefixtures("_mock_registry")
+@pytest.mark.usefixtures("_populate_pyinfo_cache")
@pytest.mark.parametrize(
("string_spec", "expected_exe"),
[
- # 64-bit over 32-bit
("python3.10", "C:\\Users\\user\\Miniconda3-64\\python.exe"),
("cpython3.10", "C:\\Users\\user\\Miniconda3-64\\python.exe"),
- # 1 installation of 3.9 available
("python3.12", "C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python312\\python.exe"),
("cpython3.12", "C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python312\\python.exe"),
- # resolves to the highest available version
- ("python", "C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python312\\python.exe"),
- ("cpython", "C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python312\\python.exe"),
- # Non-standard org name
+ ("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"),
],
)
-def test_propose_interpreters(string_spec: str, expected_exe: str, mocker: MockerFixture) -> None:
- mocker.patch("sys.platform", "win32")
- mocker.patch("sysconfig.get_config_var", return_value=f"{sys.version_info.major}{sys.version_info}")
- mocker.patch("sysconfig.get_makefile_filename", return_value="make")
+def test_propose_interpreters(string_spec: str, expected_exe: str) -> None:
+ from python_discovery._windows import propose_interpreters
spec = PythonSpec.from_string_spec(string_spec)
- mocker.patch(
- "py_discovery._windows.Pep514PythonInfo.from_exe",
- return_value=_mock_pyinfo(spec.major, spec.minor, spec.architecture, expected_exe),
- )
-
- interpreter = next(propose_interpreters(spec, env={}))
+ interpreter = next(propose_interpreters(spec=spec, cache=None, env={}))
assert interpreter.executable == expected_exe
-
-
-def _mock_pyinfo(major: int | None, minor: int | None, arch: int | None, exe: str) -> PythonInfo:
- """Return PythonInfo objects with essential metadata set for the given args."""
- info = PythonInfo()
- info.base_prefix = str(Path(exe).parent)
- info.executable = info.original_executable = info.system_executable = exe
- info.implementation = "CPython"
- info.architecture = arch or 64
- info.version_info = VersionInfo(major, minor, 0, "final", 0)
- return info
-
-
-@pytest.mark.usefixtures("_mock_registry")
-def test_pep514() -> None:
- interpreters = list(discover_pythons())
- assert interpreters == [
- ("ContinuumAnalytics", 3, 10, 32, "C:\\Users\\user\\Miniconda3\\python.exe", None),
- ("ContinuumAnalytics", 3, 10, 64, "C:\\Users\\user\\Miniconda3-64\\python.exe", None),
- ("PythonCore", 3, 9, 64, "C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python39\\python.exe", None),
- ("PythonCore", 3, 9, 64, "C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python39\\python.exe", None),
- ("PythonCore", 3, 8, 64, "C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python38\\python.exe", None),
- ("PythonCore", 3, 9, 64, "C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python39\\python.exe", None),
- ("PythonCore", 3, 10, 32, "C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python310-32\\python.exe", None),
- ("PythonCore", 3, 12, 64, "C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python312\\python.exe", None),
- ("CompanyA", 3, 6, 64, "Z:\\CompanyA\\Python\\3.6\\python.exe", None),
- ("PythonCore", 2, 7, 64, "C:\\Python27\\python.exe", None),
- ("PythonCore", 3, 7, 64, "C:\\Python37\\python.exe", None),
- ]
-
-
-@pytest.mark.usefixtures("_mock_registry")
-def test_pep514_run(capsys: pytest.CaptureFixture[str], caplog: pytest.LogCaptureFixture) -> None:
- pep514._run() # noqa: SLF001
- out, err = capsys.readouterr()
- expected = textwrap.dedent(
- r"""
- ('CompanyA', 3, 6, 64, 'Z:\\CompanyA\\Python\\3.6\\python.exe', None)
- ('ContinuumAnalytics', 3, 10, 32, 'C:\\Users\\user\\Miniconda3\\python.exe', None)
- ('ContinuumAnalytics', 3, 10, 64, 'C:\\Users\\user\\Miniconda3-64\\python.exe', None)
- ('PythonCore', 2, 7, 64, 'C:\\Python27\\python.exe', None)
- ('PythonCore', 3, 10, 32, 'C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python310-32\\python.exe', None)
- ('PythonCore', 3, 12, 64, 'C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python312\\python.exe', None)
- ('PythonCore', 3, 7, 64, 'C:\\Python37\\python.exe', None)
- ('PythonCore', 3, 8, 64, 'C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python38\\python.exe', None)
- ('PythonCore', 3, 9, 64, 'C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python39\\python.exe', None)
- ('PythonCore', 3, 9, 64, 'C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python39\\python.exe', None)
- ('PythonCore', 3, 9, 64, 'C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python39\\python.exe', None)
- """,
- ).strip()
- assert out.strip() == expected
- assert not err
- prefix = "PEP-514 violation in Windows Registry at "
- expected_logs = [
- f"{prefix}HKEY_CURRENT_USER/PythonCore/3.1/SysArchitecture error: invalid format magic",
- f"{prefix}HKEY_CURRENT_USER/PythonCore/3.2/SysArchitecture error: arch is not string: 100",
- f"{prefix}HKEY_CURRENT_USER/PythonCore/3.3 error: no ExecutablePath or default for it",
- f"{prefix}HKEY_CURRENT_USER/PythonCore/3.3 error: could not load exe with value None",
- f"{prefix}HKEY_CURRENT_USER/PythonCore/3.11/InstallPath error: missing",
- f"{prefix}HKEY_CURRENT_USER/PythonCore/3.12/SysVersion error: invalid format magic",
- f"{prefix}HKEY_CURRENT_USER/PythonCore/3.X/SysVersion error: version is not string: 2778",
- f"{prefix}HKEY_CURRENT_USER/PythonCore/3.X error: invalid format 3.X",
- ]
- assert caplog.messages == expected_logs
diff --git a/tests/windows/test_windows_pep514.py b/tests/windows/test_windows_pep514.py
new file mode 100644
index 0000000..1dedfb6
--- /dev/null
+++ b/tests/windows/test_windows_pep514.py
@@ -0,0 +1,156 @@
+from __future__ import annotations
+
+import sys
+import textwrap
+
+import pytest
+
+
+@pytest.mark.usefixtures("_mock_registry")
+def test_pep514_discovers_interpreters() -> None:
+ from python_discovery._windows._pep514 import discover_pythons
+
+ interpreters = list(discover_pythons())
+ assert len(interpreters) == 12
+ companies = {i[0] for i in interpreters}
+ assert "ContinuumAnalytics" in companies
+ assert "PythonCore" in companies
+ assert "CompanyA" in companies
+
+
+@pytest.mark.usefixtures("_mock_registry")
+def test_pep514_parse_functions() -> None:
+ from python_discovery._windows._pep514 import parse_arch, parse_version
+
+ assert parse_arch("64bit") == 64
+ assert parse_arch("32bit") == 32
+ with pytest.raises(ValueError, match="invalid format"):
+ parse_arch("magic")
+ with pytest.raises(ValueError, match="arch is not string"):
+ 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)
+ with pytest.raises(ValueError, match="invalid format"):
+ parse_version("3.X")
+ with pytest.raises(ValueError, match="version is not string"):
+ 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
+
+ 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),
+ (
+ "PythonCore",
+ 3,
+ 9,
+ 64,
+ False,
+ "C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python39\\python.exe",
+ None,
+ ),
+ (
+ "PythonCore",
+ 3,
+ 9,
+ 64,
+ False,
+ "C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python39\\python.exe",
+ None,
+ ),
+ (
+ "PythonCore",
+ 3,
+ 8,
+ 64,
+ False,
+ "C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python38\\python.exe",
+ None,
+ ),
+ (
+ "PythonCore",
+ 3,
+ 9,
+ 64,
+ False,
+ "C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python39\\python.exe",
+ None,
+ ),
+ (
+ "PythonCore",
+ 3,
+ 10,
+ 32,
+ False,
+ "C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python310-32\\python.exe",
+ None,
+ ),
+ (
+ "PythonCore",
+ 3,
+ 12,
+ 64,
+ False,
+ "C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python312\\python.exe",
+ None,
+ ),
+ (
+ "PythonCore",
+ 3,
+ 13,
+ 64,
+ True,
+ "C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python313\\python3.13t.exe",
+ None,
+ ),
+ ("CompanyA", 3, 6, 64, False, "Z:\\CompanyA\\Python\\3.6\\python.exe", None),
+ ("PythonCore", 2, 7, 64, False, "C:\\Python27\\python.exe", None),
+ ("PythonCore", 3, 7, 64, False, "C:\\Python37\\python.exe", None),
+ ]
+
+
+@pytest.mark.skipif(sys.platform != "win32", reason="path joining differs on POSIX")
+@pytest.mark.usefixtures("_mock_registry")
+def test_pep514_run(capsys: pytest.CaptureFixture[str], caplog: pytest.LogCaptureFixture) -> None:
+ from python_discovery._windows import _pep514 as pep514
+
+ pep514._run()
+ out, err = capsys.readouterr()
+ py = r"C:\Users\user\AppData\Local\Programs\Python"
+ expected = textwrap.dedent(
+ rf"""
+ ('CompanyA', 3, 6, 64, False, 'Z:\\CompanyA\\Python\\3.6\\python.exe', None)
+ ('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)
+ ('PythonCore', 2, 7, 64, False, 'C:\\Python27\\python.exe', None)
+ ('PythonCore', 3, 10, 32, False, '{py}\\Python310-32\\python.exe', None)
+ ('PythonCore', 3, 12, 64, False, '{py}\\Python312\\python.exe', None)
+ ('PythonCore', 3, 13, 64, True, '{py}\\Python313\\python3.13t.exe', None)
+ ('PythonCore', 3, 7, 64, False, 'C:\\Python37\\python.exe', None)
+ ('PythonCore', 3, 8, 64, False, '{py}\\Python38\\python.exe', None)
+ ('PythonCore', 3, 9, 64, False, '{py}\\Python39\\python.exe', None)
+ ('PythonCore', 3, 9, 64, False, '{py}\\Python39\\python.exe', None)
+ ('PythonCore', 3, 9, 64, False, '{py}\\Python39\\python.exe', None)
+ """,
+ ).strip()
+ assert out.strip() == expected
+ assert not err
+ prefix = "PEP-514 violation in Windows Registry at "
+ expected_logs = [
+ f"{prefix}HKEY_CURRENT_USER/PythonCore/3.1/SysArchitecture error: invalid format magic",
+ f"{prefix}HKEY_CURRENT_USER/PythonCore/3.2/SysArchitecture error: arch is not string: 100",
+ f"{prefix}HKEY_CURRENT_USER/PythonCore/3.3 error: no ExecutablePath or default for it",
+ f"{prefix}HKEY_CURRENT_USER/PythonCore/3.3 error: could not load exe with value None",
+ f"{prefix}HKEY_CURRENT_USER/PythonCore/3.11/InstallPath error: missing",
+ f"{prefix}HKEY_CURRENT_USER/PythonCore/3.12/SysVersion error: invalid format magic",
+ f"{prefix}HKEY_CURRENT_USER/PythonCore/3.X/SysVersion error: version is not string: 2778",
+ f"{prefix}HKEY_CURRENT_USER/PythonCore/3.X error: invalid format 3.X",
+ ]
+ assert caplog.messages == expected_logs
diff --git a/tests/windows/winreg-mock-values.py b/tests/windows/winreg_mock_values.py
similarity index 74%
rename from tests/windows/winreg-mock-values.py
rename to tests/windows/winreg_mock_values.py
index cc4e81e..e218180 100644
--- a/tests/windows/winreg-mock-values.py
+++ b/tests/windows/winreg_mock_values.py
@@ -1,17 +1,9 @@
from __future__ import annotations
-from py_discovery._windows.pep514 import (
- HKEY_CURRENT_USER,
- HKEY_LOCAL_MACHINE,
- KEY_READ,
- KEY_WOW64_32KEY,
- KEY_WOW64_64KEY,
-)
-
hive_open = {
- (HKEY_CURRENT_USER, "Software\\Python", 0, KEY_READ): 78701856,
- (HKEY_LOCAL_MACHINE, "Software\\Python", 0, KEY_READ | KEY_WOW64_64KEY): 78701840,
- (HKEY_LOCAL_MACHINE, "Software\\Python", 0, KEY_READ | KEY_WOW64_32KEY): 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(
2,
"The system cannot find the file specified",
),
@@ -41,6 +33,8 @@
"3.11": 78700656,
"3.12\\InstallPath": 78703632,
"3.12": 78702608,
+ "3.13t\\InstallPath": 78703633,
+ "3.13t": 78702609,
"3.X": 78703088,
},
78702960: {"2.7\\InstallPath": 78700912, "2.7": 78703136, "3.7\\InstallPath": 78703648, "3.7": 78704032},
@@ -51,45 +45,46 @@
},
}
value_collect = {
- 78703568: {"SysVersion": ("3.10", 1), "SysArchitecture": ("32bit", 1)},
+ 78703568: {"SysVersion": ("3.10", 1), "SysArchitecture": ("32bit", 1), "DisplayName": ("Python 3.10 (32-bit)", 1)},
78703200: {
"ExecutablePath": ("C:\\Users\\user\\Miniconda3\\python.exe", 1),
"ExecutableArguments": OSError(2, "The system cannot find the file specified"),
},
- 78702368: {"SysVersion": ("3.10", 1), "SysArchitecture": ("64bit", 1)},
+ 78702368: {"SysVersion": ("3.10", 1), "SysArchitecture": ("64bit", 1), "DisplayName": ("Python 3.10 (64-bit)", 1)},
78703520: {
"ExecutablePath": ("C:\\Users\\user\\Miniconda3-64\\python.exe", 1),
"ExecutableArguments": OSError(2, "The system cannot find the file specified"),
},
- 78700704: {"SysVersion": ("3.9", 1), "SysArchitecture": ("magic", 1)},
+ 78700704: {"SysVersion": ("3.9", 1), "SysArchitecture": ("magic", 1), "DisplayName": ("Python 3.9 (wizardry)", 1)},
78701824: {
"ExecutablePath": ("C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python39\\python.exe", 1),
"ExecutableArguments": OSError(2, "The system cannot find the file specified"),
},
- 78704368: {"SysVersion": ("3.9", 1), "SysArchitecture": (100, 4)},
+ 78704368: {"SysVersion": ("3.9", 1), "SysArchitecture": (100, 4), "DisplayName": ("Python 3.9 (64-bit)", 1)},
78704048: {
"ExecutablePath": ("C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python39\\python.exe", 1),
"ExecutableArguments": OSError(2, "The system cannot find the file specified"),
},
- 78703024: {"SysVersion": ("3.9", 1), "SysArchitecture": ("64bit", 1)},
+ 78703024: {"SysVersion": ("3.9", 1), "SysArchitecture": ("64bit", 1), "DisplayName": ("Python 3.9 (64-bit)", 1)},
78701936: {
"ExecutablePath": OSError(2, "The system cannot find the file specified"),
- "": OSError(2, "The system cannot find the file specified"),
+ None: OSError(2, "The system cannot find the file specified"),
},
78701792: {
"SysVersion": OSError(2, "The system cannot find the file specified"),
"SysArchitecture": OSError(2, "The system cannot find the file specified"),
+ "DisplayName": OSError(2, "The system cannot find the file specified"),
},
78703792: {
"ExecutablePath": ("C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python38\\python.exe", 1),
"ExecutableArguments": OSError(2, "The system cannot find the file specified"),
},
- 78703424: {"SysVersion": ("3.9", 1), "SysArchitecture": ("64bit", 1)},
+ 78703424: {"SysVersion": ("3.9", 1), "SysArchitecture": ("64bit", 1), "DisplayName": ("Python 3.9 (64-bit)", 1)},
78701888: {
"ExecutablePath": ("C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python39\\python.exe", 1),
"ExecutableArguments": OSError(2, "The system cannot find the file specified"),
},
- 78704512: {"SysVersion": ("3.10", 1), "SysArchitecture": ("32bit", 1)},
+ 78704512: {"SysVersion": ("3.10", 1), "SysArchitecture": ("32bit", 1), "DisplayName": ("Python 3.10 (32-bit)", 1)},
78703600: {
"ExecutablePath": ("C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python310-32\\python.exe", 1),
"ExecutableArguments": OSError(2, "The system cannot find the file specified"),
@@ -97,36 +92,56 @@
78700656: {
"SysVersion": OSError(2, "The system cannot find the file specified"),
"SysArchitecture": OSError(2, "The system cannot find the file specified"),
+ "DisplayName": OSError(2, "The system cannot find the file specified"),
+ },
+ 78702608: {
+ "SysVersion": ("magic", 1),
+ "SysArchitecture": ("64bit", 1),
+ "DisplayName": ("Python 3.12 (wizard edition)", 1),
},
- 78702608: {"SysVersion": ("magic", 1), "SysArchitecture": ("64bit", 1)},
78703632: {
"ExecutablePath": ("C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python312\\python.exe", 1),
"ExecutableArguments": OSError(2, "The system cannot find the file specified"),
},
+ 78702609: {
+ "SysVersion": ("3.13", 1),
+ "SysArchitecture": ("64bit", 1),
+ "DisplayName": ("Python 3.13 (64-bit, freethreaded)", 1),
+ },
+ 78703633: {
+ "ExecutablePath": ("C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python313\\python3.13t.exe", 1),
+ "ExecutableArguments": OSError(2, "The system cannot find the file specified"),
+ },
78703088: {"SysVersion": (2778, 11)},
78703136: {
"SysVersion": OSError(2, "The system cannot find the file specified"),
"SysArchitecture": OSError(2, "The system cannot find the file specified"),
+ "DisplayName": OSError(2, "The system cannot find the file specified"),
},
78700912: {
"ExecutablePath": OSError(2, "The system cannot find the file specified"),
- "": ("C:\\Python27\\", 1),
+ None: ("C:\\Python27\\", 1),
"ExecutableArguments": OSError(2, "The system cannot find the file specified"),
},
78704032: {
"SysVersion": OSError(2, "The system cannot find the file specified"),
"SysArchitecture": OSError(2, "The system cannot find the file specified"),
+ "DisplayName": OSError(2, "The system cannot find the file specified"),
},
78703648: {
"ExecutablePath": OSError(2, "The system cannot find the file specified"),
- "": ("C:\\Python37\\", 1),
+ None: ("C:\\Python37\\", 1),
"ExecutableArguments": OSError(2, "The system cannot find the file specified"),
},
88810000: {
"ExecutablePath": ("Z:\\CompanyA\\Python\\3.6\\python.exe", 1),
"ExecutableArguments": OSError(2, "The system cannot find the file specified"),
},
- 88820000: {"SysVersion": ("3.6", 1), "SysArchitecture": ("64bit", 1)},
+ 88820000: {
+ "SysVersion": ("3.6", 1),
+ "SysArchitecture": ("64bit", 1),
+ "DisplayName": OSError(2, "The system cannot find the file specified"),
+ },
}
enum_collect = {
78701856: [
@@ -145,6 +160,7 @@
"3.10-32",
"3.11",
"3.12",
+ "3.13t",
"3.X",
OSError(22, "No more data is available", None, 259, None),
],
diff --git a/tox.ini b/tox.ini
deleted file mode 100644
index eaa33f1..0000000
--- a/tox.ini
+++ /dev/null
@@ -1,86 +0,0 @@
-[tox]
-requires =
- tox>=4.2
-env_list =
- fix
- py312
- py311
- py310
- py39
- py38
- py37
- type
- docs
- pkg_meta
-skip_missing_interpreters = true
-
-[testenv]
-description = run the tests with pytest under {envname}
-package = wheel
-wheel_build_env = .pkg
-extras =
- testing
-pass_env =
- FORCE_COLOR
- PYTEST_*
- SSL_CERT_FILE
-set_env =
- COVERAGE_FILE = {env:COVERAGE_FILE:{toxworkdir}{/}.coverage.{envname}}
-commands =
- pytest {tty:--color=yes} {posargs: --no-cov-on-fail --cov-context=test \
- --cov={envsitepackagesdir}{/}py_discovery --cov={toxinidir}{/}tests --cov-config={toxinidir}{/}pyproject.toml \
- --cov-report=term-missing:skip-covered --cov-report=html:{envtmpdir}{/}htmlcov \
- --cov-report=xml:{toxworkdir}{/}coverage.{envname}.xml --junitxml={toxworkdir}{/}junit.{envname}.xml \
- tests}
-labels = test
-
-[testenv:fix]
-description = run formatter and linters
-skip_install = true
-deps =
- pre-commit>=3.6
-pass_env =
- {[testenv]passenv}
- PROGRAMDATA
-commands =
- pre-commit run --all-files --show-diff-on-failure {tty:--color=always} {posargs}
-
-[testenv:type]
-description = run type check on code base
-deps =
- mypy==1.8
-set_env =
- {tty:MYPY_FORCE_COLOR = 1}
-commands =
- mypy src/py_discovery
- mypy tests
-
-[testenv:docs]
-description = build documentation
-extras =
- docs
-commands =
- sphinx-build -d "{envtmpdir}{/}doctree" docs --color -b html -W {posargs:"{toxworkdir}{/}docs_out"}
- python -c 'print(r"documentation available under {posargs:file://{toxworkdir}{/}docs_out}{/}index.html")'
-
-[testenv:pkg_meta]
-description = check that the long description is valid
-skip_install = true
-deps =
- build[virtualenv]>=1.0.3
- check-wheel-contents>=0.6
- twine>=4.0.2
-commands =
- python -m build -o {envtmpdir} -s -w .
- twine check --strict {envtmpdir}{/}*
- check-wheel-contents --no-config {envtmpdir}
-
-[testenv:dev]
-description = dev environment with all deps at {envdir}
-package = editable
-extras =
- docs
- testing
-commands =
- python -m pip list --format=columns
- python -c "print(r'{envpython}')"
diff --git a/tox.toml b/tox.toml
new file mode 100644
index 0000000..3b57209
--- /dev/null
+++ b/tox.toml
@@ -0,0 +1,78 @@
+requires = ["tox>=4.38", "tox-uv>=1.29"]
+env_list = ["fix", "3.14", "3.13", "3.12", "3.11", "3.10", "3.9", "3.8", "type-3.8", "type-3.14", "docs", "pkg_meta"]
+skip_missing_interpreters = true
+
+[env_run_base]
+description = "run the tests with pytest"
+package = "wheel"
+wheel_build_env = ".pkg"
+extras = ["testing"]
+pass_env = ["DIFF_AGAINST", "PYTEST_*"]
+set_env.COVERAGE_FILE = "{work_dir}{/}.coverage.{env_name}"
+commands = [
+ ["coverage", "erase"],
+ [
+ "coverage",
+ "run",
+ "-m",
+ "pytest",
+ { replace = "posargs", extend = true, default = [
+ "--junitxml",
+ "{work_dir}{/}junit.{env_name}.xml",
+ "tests",
+ ] },
+ ],
+ ["coverage", "combine"],
+ ["coverage", "report"],
+ ["coverage", "html", "-d", "{env_tmp_dir}{/}htmlcov"],
+]
+
+[env.fix]
+description = "run static analysis and style check using flake8"
+skip_install = true
+deps = ["pre-commit-uv>=4.2.1"]
+pass_env = ["HOMEPATH", "PROGRAMDATA"]
+commands = [["pre-commit", "run", "--all-files", "--show-diff-on-failure"]]
+
+[env."type-3.8"]
+description = "run type check on code base (3.8)"
+deps = ["ty==0.0.17"]
+commands = [["ty", "check", "--output-format", "concise", "--error-on-warning", "--python-version", "3.8", "."]]
+
+[env."type-3.14"]
+description = "run type check on code base (3.14)"
+deps = ["ty==0.0.17"]
+commands = [["ty", "check", "--output-format", "concise", "--error-on-warning", "--python-version", "3.14", "."]]
+
+[env.docs]
+description = "build documentation"
+extras = ["docs"]
+commands = [
+ [
+ "sphinx-build",
+ "-d",
+ "{env_tmp_dir}{/}doctree",
+ "docs",
+ "--color",
+ "-b",
+ "html",
+ "-W",
+ { replace = "posargs", extend = true, default = ["{work_dir}{/}docs_out"] },
+ ],
+]
+
+[env.pkg_meta]
+description = "check that the long description is valid"
+skip_install = true
+deps = ["check-wheel-contents>=0.6.3", "twine>=6.2", "uv>=0.10.4"]
+commands = [
+ ["uv", "build", "--sdist", "--wheel", "--out-dir", "{env_tmp_dir}", "."],
+ ["twine", "check", "{env_tmp_dir}{/}*"],
+ ["check-wheel-contents", "--no-config", "{env_tmp_dir}"],
+]
+
+[env.dev]
+description = "generate a DEV environment"
+package = "editable"
+extras = ["testing"]
+commands = [["uv", "pip", "tree"], ["python", "-c", "import sys; print(sys.executable)"]]