From 4ecde94f5402150a27f149de9db385e656fd49e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Wed, 25 Feb 2026 16:47:46 +0000 Subject: [PATCH] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor:=20rewrite=20with?= =?UTF-8?q?=20standalone=20python=5Fdiscovery=20from=20virtualenv?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The original py-discovery package was a thin stub. This replaces it with the battle-tested interpreter discovery engine extracted from virtualenv, renamed from py_discovery to python_discovery. The rewrite brings a complete implementation: spec-based interpreter matching, filesystem and registry discovery, caching with file locks, PEP 514 Windows support, version specifiers, and version manager shim resolution (pyenv/mise/asdf). Full type annotations target Python 3.8+ with ty verification against both 3.8 and 3.14. Documentation follows Diataxis structure with tutorial, how-to, explanation, and API reference. --- .github/workflows/check.yaml | 54 ++ .github/workflows/check.yml | 91 --- .github/workflows/release.yaml | 48 ++ .github/workflows/release.yml | 27 - .gitignore | 12 +- .pre-commit-config.yaml | 27 +- .readthedocs.yaml | 8 + .readthedocs.yml | 4 +- README.md | 12 +- docs/_static/custom.css | 4 + docs/_static/logo.svg | 11 + docs/conf.py | 58 +- docs/explanation.rst | 153 ++++ docs/how-to/standalone-usage.rst | 143 ++++ docs/index.rst | 50 +- docs/reference/api.rst | 7 + docs/tutorial/getting-started.rst | 180 +++++ pyproject.toml | 165 ++-- src/py_discovery/__init__.py | 22 - src/py_discovery/_builtin.py | 194 ----- src/py_discovery/_discover.py | 57 -- src/py_discovery/_info.py | 715 ----------------- src/py_discovery/_spec.py | 132 ---- src/py_discovery/_windows/__init__.py | 49 -- src/py_discovery/_windows/pep514.py | 242 ------ src/python_discovery/__init__.py | 22 + src/python_discovery/_cache.py | 153 ++++ src/python_discovery/_cached_py_info.py | 259 +++++++ src/python_discovery/_compat.py | 29 + src/python_discovery/_discovery.py | 308 ++++++++ src/python_discovery/_py_info.py | 726 ++++++++++++++++++ src/python_discovery/_py_spec.py | 235 ++++++ src/python_discovery/_specifier.py | 264 +++++++ src/python_discovery/_windows/__init__.py | 13 + src/python_discovery/_windows/_pep514.py | 222 ++++++ src/python_discovery/_windows/_propose.py | 53 ++ .../py.typed | 0 tests/conftest.py | 41 +- tests/py_info/test_py_info.py | 455 +++++++++++ tests/py_info/test_py_info_exe_based_of.py | 82 ++ tests/test_cache.py | 115 +++ tests/test_cached_py_info.py | 216 ++++++ tests/test_discovery.py | 408 +++++++++- tests/test_discovery_extra.py | 241 ++++++ tests/test_py_info.py | 443 ----------- tests/test_py_info_exe_based_of.py | 67 -- tests/test_py_info_extra.py | 512 ++++++++++++ tests/test_py_spec.py | 157 +++- tests/test_py_spec_extra.py | 130 ++++ tests/test_specifier.py | 299 ++++++++ tests/test_version.py | 7 - tests/windows/conftest.py | 175 +++++ tests/windows/test_windows.py | 171 +---- tests/windows/test_windows_pep514.py | 156 ++++ ...g-mock-values.py => winreg_mock_values.py} | 62 +- tox.ini | 86 --- tox.toml | 78 ++ 57 files changed, 6133 insertions(+), 2517 deletions(-) create mode 100644 .github/workflows/check.yaml delete mode 100644 .github/workflows/check.yml create mode 100644 .github/workflows/release.yaml delete mode 100644 .github/workflows/release.yml create mode 100644 .readthedocs.yaml create mode 100644 docs/_static/custom.css create mode 100644 docs/_static/logo.svg create mode 100644 docs/explanation.rst create mode 100644 docs/how-to/standalone-usage.rst create mode 100644 docs/reference/api.rst create mode 100644 docs/tutorial/getting-started.rst delete mode 100644 src/py_discovery/__init__.py delete mode 100644 src/py_discovery/_builtin.py delete mode 100644 src/py_discovery/_discover.py delete mode 100644 src/py_discovery/_info.py delete mode 100644 src/py_discovery/_spec.py delete mode 100644 src/py_discovery/_windows/__init__.py delete mode 100644 src/py_discovery/_windows/pep514.py create mode 100644 src/python_discovery/__init__.py create mode 100644 src/python_discovery/_cache.py create mode 100644 src/python_discovery/_cached_py_info.py create mode 100644 src/python_discovery/_compat.py create mode 100644 src/python_discovery/_discovery.py create mode 100644 src/python_discovery/_py_info.py create mode 100644 src/python_discovery/_py_spec.py create mode 100644 src/python_discovery/_specifier.py create mode 100644 src/python_discovery/_windows/__init__.py create mode 100644 src/python_discovery/_windows/_pep514.py create mode 100644 src/python_discovery/_windows/_propose.py rename src/{py_discovery => python_discovery}/py.typed (100%) create mode 100644 tests/py_info/test_py_info.py create mode 100644 tests/py_info/test_py_info_exe_based_of.py create mode 100644 tests/test_cache.py create mode 100644 tests/test_cached_py_info.py create mode 100644 tests/test_discovery_extra.py delete mode 100644 tests/test_py_info.py delete mode 100644 tests/test_py_info_exe_based_of.py create mode 100644 tests/test_py_info_extra.py create mode 100644 tests/test_py_spec_extra.py create mode 100644 tests/test_specifier.py delete mode 100644 tests/test_version.py create mode 100644 tests/windows/conftest.py create mode 100644 tests/windows/test_windows_pep514.py rename tests/windows/{winreg-mock-values.py => winreg_mock_values.py} (74%) delete mode 100644 tox.ini create mode 100644 tox.toml 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/) -[![PyPI](https://img.shields.io/pypi/v/py-discovery?style=flat-square)](https://pypi.org/project/py-discovery/) +[![PyPI](https://img.shields.io/pypi/v/python-discovery?style=flat-square)](https://pypi.org/project/python-discovery/) [![Supported Python -versions](https://img.shields.io/pypi/pyversions/py-discovery.svg)](https://pypi.org/project/py-discovery/) -[![Downloads](https://static.pepy.tech/badge/py-discovery/month)](https://pepy.tech/project/py-discovery) -[![check](https://github.com/tox-dev/py-discovery/actions/workflows/check.yml/badge.svg)](https://github.com/tox-dev/py-discovery/actions/workflows/check.yml) -[![Documentation Status](https://readthedocs.org/projects/py-discovery/badge/?version=latest)](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/) +[![Downloads](https://static.pepy.tech/badge/python-discovery/month)](https://pepy.tech/project/python-discovery) +[![check](https://github.com/tox-dev/python-discovery/actions/workflows/check.yml/badge.svg)](https://github.com/tox-dev/python-discovery/actions/workflows/check.yml) +[![Documentation Status](https://readthedocs.org/projects/python-discovery/badge/?version=latest)](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)"]]