python_discovery-1.2.2/.pre-commit-config.yaml0000644000000000000000000000221313615410400016323 0ustar00repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: v6.0.0 hooks: - id: end-of-file-fixer - id: trailing-whitespace - repo: https://github.com/python-jsonschema/check-jsonschema rev: 0.37.1 hooks: - id: check-github-workflows args: ["--verbose"] - repo: https://github.com/codespell-project/codespell rev: v2.4.2 hooks: - id: codespell additional_dependencies: ["tomli>=2.4"] - repo: https://github.com/tox-dev/pyproject-fmt rev: "v2.21.0" hooks: - id: pyproject-fmt - repo: https://github.com/astral-sh/ruff-pre-commit rev: "v0.15.9" 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: https://github.com/zizmorcore/zizmor-pre-commit rev: v1.23.1 hooks: - id: zizmor - repo: meta hooks: - id: check-hooks-apply - id: check-useless-excludes python_discovery-1.2.2/.readthedocs.yaml0000644000000000000000000000040713615410400015274 0ustar00version: 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 -- "$READTHEDOCS_OUTPUT/html" python_discovery-1.2.2/.readthedocs.yml0000644000000000000000000000022713615410400015133 0ustar00version: 2 build: os: ubuntu-22.04 tools: python: "3.12" commands: - pip install tox - tox r -e docs -- "${READTHEDOCS_OUTPUT}"/html python_discovery-1.2.2/CODE_OF_CONDUCT.md0000644000000000000000000000626413615410400014653 0ustar00# Contributor Covenant Code of Conduct ## Our Pledge In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. ## Our Standards Examples of behavior that contributes to creating a positive environment include: - Using welcoming and inclusive language - Being respectful of differing viewpoints and experiences - Gracefully accepting constructive criticism - Focusing on what is best for the community - Showing empathy towards other community members Examples of unacceptable behavior by participants include: - The use of sexualized language or imagery and unwelcome sexual attention or advances - Trolling, insulting/derogatory comments, and personal or political attacks - Public or private harassment - Publishing others' private information, such as a physical or electronic address, without explicit permission - Other conduct which could reasonably be considered inappropriate in a professional setting ## Our Responsibilities Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. ## Scope This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. ## Enforcement Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at tox-dev@python.org. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. ## Attribution This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [https://www.contributor-covenant.org/version/1/4/code-of-conduct.html][version] [homepage]: https://www.contributor-covenant.org/ [version]: https://www.contributor-covenant.org/version/1/4/ python_discovery-1.2.2/tox.toml0000644000000000000000000000431013615410400013551 0ustar00requires = ["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)"]] python_discovery-1.2.2/.github/FUNDING.yaml0000644000000000000000000000002413615410400015356 0ustar00github: gaborbernat python_discovery-1.2.2/.github/SECURITY.md0000644000000000000000000000045513615410400015201 0ustar00# Security Policy ## Supported Versions Only the latest released version is supported with security updates. Older versions will not receive patches. ## Reporting a Vulnerability Please report security vulnerabilities via [GitHub's private vulnerability reporting](../../security/advisories/new). python_discovery-1.2.2/.github/dependabot.yaml0000644000000000000000000000023113615410400016371 0ustar00version: 2 updates: - package-ecosystem: "github-actions" directory: "/" schedule: interval: "daily" cooldown: default-days: 7 python_discovery-1.2.2/.github/release.yaml0000644000000000000000000000012613615410400015707 0ustar00changelog: exclude: authors: - dependabot[bot] - pre-commit-ci[bot] python_discovery-1.2.2/.github/workflows/check.yaml0000644000000000000000000000310113615410400017375 0ustar00name: check on: workflow_dispatch: push: branches: ["main"] tags-ignore: ["**"] pull_request: schedule: - cron: "0 8 * * *" concurrency: group: check-${{ github.ref }} cancel-in-progress: true permissions: contents: read 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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: fetch-depth: 0 persist-credentials: false - name: Install the latest version of uv uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0 with: enable-cache: false 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 python_discovery-1.2.2/.github/workflows/release.yaml0000644000000000000000000000303513615410400017746 0ustar00name: Release to PyPI on: push: tags: ["*"] permissions: contents: read env: dists-artifact-name: python-package-distributions jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: fetch-depth: 0 persist-credentials: false - name: Install the latest version of uv uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0 with: enable-cache: false 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@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7 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@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: name: ${{ env.dists-artifact-name }} path: dist/ - name: Publish to PyPI uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0 with: attestations: true python_discovery-1.2.2/docs/conf.py0000644000000000000000000000260413615410400014275 0ustar00"""Sphinx configuration for python-discovery documentation.""" from __future__ import annotations from datetime import datetime, timezone from python_discovery import __version__ company = "tox-dev" 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.intersphinx", "sphinx_autodoc_typehints", "sphinxcontrib.mermaid", ] extlinks = { "issue": ("https://github.com/tox-dev/python-discovery/issues/%s", "#%s"), "pull": ("https://github.com/tox-dev/python-discovery/pull/%s", "PR #%s"), "user": ("https://github.com/%s", "@%s"), } intersphinx_mapping = { "python": ("https://docs.python.org/3", None), } templates_path = [] source_suffix = ".rst" exclude_patterns = ["_build", "changelog/*.rst"] 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"] python_discovery-1.2.2/docs/explanation.rst0000644000000000000000000001523313615410400016054 0ustar00How 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`). Subprocess timeout behavior ---------------------------- When python-discovery verifies an interpreter candidate, it runs a subprocess to query its metadata. On slow systems (especially Windows), Python startup can take significant time. The default timeout is **15 seconds** to balance responsiveness with accommodation for real-world conditions. If your system consistently hits timeouts, you can customize the timeout via the ``PY_DISCOVERY_TIMEOUT`` environment variable (in seconds): .. code-block:: console # Increase timeout to 30 seconds export PY_DISCOVERY_TIMEOUT=30 python -c "from python_discovery import get_interpreter; get_interpreter('python3.12')" The timeout applies to each individual interpreter being queried. If you set a value that is too low, legitimate interpreters may be skipped; if too high, the discovery process may take longer to fail when encountering problematic interpreters. 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``. python_discovery-1.2.2/docs/index.rst0000644000000000000000000000250513615410400014637 0ustar00python-discovery ================ 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. 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. .. 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) # /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 reference/environment-variables .. toctree:: :caption: Explanation :hidden: explanation python_discovery-1.2.2/docs/_static/custom.css0000644000000000000000000000007013615410400016443 0ustar00.sidebar-logo img { max-width: 100%; width: 100%; } python_discovery-1.2.2/docs/_static/logo.svg0000644000000000000000000000104313615410400016101 0ustar00 python_discovery-1.2.2/docs/changelog/59.bugfix.rst0000644000000000000000000000012213615410400017170 0ustar00export normalize_isa and deprecate KNOWN_ARCHITECTURES - by :user:`rahuldevikar`. python_discovery-1.2.2/docs/how-to/standalone-usage.rst0000644000000000000000000001161413615410400020200 0ustar00How-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) Customize interpreter query timeout ------------------------------------- On slower systems (especially Windows), Python startup can take more than the default 15 seconds. If your discovery process times out when looking for interpreters, you can extend the timeout via the ``PY_DISCOVERY_TIMEOUT`` environment variable. .. code-block:: python import os from python_discovery import get_interpreter # Increase timeout to 30 seconds for slow environments env = {**os.environ, "PY_DISCOVERY_TIMEOUT": "30"} result = get_interpreter("python3.12", env=env) The timeout value should be a number in seconds. Each interpreter candidate is given this much time to respond. If a timeout occurs, the candidate is skipped and the search continues with the next one. 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. python_discovery-1.2.2/docs/reference/api.rst0000644000000000000000000000016413615410400016236 0ustar00API reference ============= .. automodule:: python_discovery :members: :undoc-members: :show-inheritance: python_discovery-1.2.2/docs/reference/environment-variables.rst0000644000000000000000000000225113615410400021776 0ustar00Environment Variables ====================== ``PY_DISCOVERY_TIMEOUT`` ------------------------ Controls the timeout for querying individual Python interpreters. **Type:** Float (seconds) **Default:** ``15`` **Description:** When python-discovery verifies an interpreter candidate, it runs a subprocess to collect metadata (version, architecture, platform, etc.). On slower systems—particularly Windows with antivirus software or other tools—Python startup can exceed the default timeout. Setting this variable extends the allowed time for each interpreter query. **Examples:** .. code-block:: bash # Allow interpreters 30 seconds to respond export PY_DISCOVERY_TIMEOUT=30 # Or pass in Python import os os.environ["PY_DISCOVERY_TIMEOUT"] = "30" **Notes:** - The timeout applies per candidate, not to the entire discovery process - If a candidate times out, it is skipped and discovery continues with the next one - Setting the value too low may skip legitimate interpreters - Setting it too high increases discovery time when encountering problematic interpreters - The value is read from the environment dict passed to :func:`~python_discovery.get_interpreter` python_discovery-1.2.2/docs/tutorial/getting-started.rst0000644000000000000000000001464513615410400020510 0ustar00Getting 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") Handling slow interpreter queries ---------------------------------- On some systems (especially Windows with antivirus or other tools), Python startup is slow. If discovery times out, increase the timeout using the ``PY_DISCOVERY_TIMEOUT`` environment variable. .. code-block:: python import os from python_discovery import get_interpreter # Allow up to 30 seconds per interpreter os.environ["PY_DISCOVERY_TIMEOUT"] = "30" result = get_interpreter("python3.12", cache=cache) Or, pass it directly in a custom environment dict: .. code-block:: python import os from python_discovery import get_interpreter env = {**os.environ, "PY_DISCOVERY_TIMEOUT": "30"} result = get_interpreter("python3.12", env=env, cache=cache) python_discovery-1.2.2/src/python_discovery/__init__.py0000644000000000000000000000131013615410400020347 0ustar00"""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 KNOWN_ARCHITECTURES, PythonInfo, normalize_isa from ._py_spec import PythonSpec from ._specifier import SimpleSpecifier, SimpleSpecifierSet, SimpleVersion __version__ = version("python-discovery") __all__ = [ "KNOWN_ARCHITECTURES", "ContentStore", "DiskCache", "PyInfoCache", "PythonInfo", "PythonSpec", "SimpleSpecifier", "SimpleSpecifierSet", "SimpleVersion", "__version__", "get_interpreter", "normalize_isa", ] python_discovery-1.2.2/src/python_discovery/_cache.py0000644000000000000000000001170713615410400020025 0ustar00"""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: """Return whether the cached content exists.""" ... def read(self) -> dict | None: """Read the cached content, or ``None`` if unavailable or corrupt.""" ... def write(self, content: dict) -> None: """ Persist *content* to the store. :param content: interpreter metadata to cache. """ ... def remove(self) -> None: """Delete the cached content.""" ... @contextmanager def locked(self) -> Generator[None]: """Context manager that acquires an exclusive lock on this store.""" ... @runtime_checkable class PyInfoCache(Protocol): """Cache interface for Python interpreter information.""" def py_info(self, path: Path) -> ContentStore: """ Return the content store for the interpreter at *path*. :param path: absolute path to a Python executable. """ ... def py_info_clear(self) -> None: """Remove all cached interpreter information.""" ... 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``). :param root: root directory for the on-disk cache. """ 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: """ Return the content store for the interpreter at *path*. :param path: absolute path to a Python executable. """ key = sha256(str(path).encode("utf-8")).hexdigest() return DiskContentStore(self._py_info_dir, key) def py_info_clear(self) -> None: """Remove all cached interpreter information.""" 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", ] python_discovery-1.2.2/src/python_discovery/_cached_py_info.py0000644000000000000000000002053413615410400021712 0ustar00"""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, TimeoutExpired # 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() timeout = float(env.get("PY_DISCOVERY_TIMEOUT", "15")) 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(timeout=timeout) code = process.returncode except TimeoutExpired: process.kill() process.communicate() out, err, code = "", "timed out", -1 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", ] python_discovery-1.2.2/src/python_discovery/_compat.py0000644000000000000000000000130513615410400020236 0ustar00"""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", ] python_discovery-1.2.2/src/python_discovery/_discovery.py0000644000000000000000000003102613615410400020765 0ustar00from __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, predicate: Callable[[PythonInfo], bool] | None = None, ) -> PythonInfo | None: """ Find a Python interpreter matching *key*. Iterates over one or more specification strings and returns the first interpreter that satisfies the spec and passes the optional *predicate*. :param key: interpreter specification string(s) — an absolute path, a version (``3.12``), an implementation prefix (``cpython3.12``), or a PEP 440 specifier (``>=3.10``). When a sequence is given each entry is tried in order. :param try_first_with: executables to probe before the normal discovery search. :param cache: interpreter metadata cache; when ``None`` results are not cached. :param env: environment mapping for ``PATH`` lookup; defaults to :data:`os.environ`. :param predicate: optional callback applied after an interpreter matches the spec. Return ``True`` to accept the interpreter, ``False`` to skip it and continue searching. :return: the first matching interpreter, or ``None`` if no match is found. """ 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, predicate): return result return None def _find_interpreter( key: str, try_first_with: Iterable[str], cache: PyInfoCache | None = None, env: Mapping[str, str] | None = None, predicate: Callable[[PythonInfo], bool] | 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) and ( predicate is None or predicate(interpreter) ): _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]: """ Yield ``(interpreter, impl_must_match)`` candidates for *spec*. :param spec: the parsed interpreter specification to match against. :param try_first_with: executable paths to probe before the standard search. :param cache: interpreter metadata cache; when ``None`` results are not cached. :param env: environment mapping for ``PATH`` lookup; defaults to :data:`os.environ`. """ 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", ] python_discovery-1.2.2/src/python_discovery/_py_info.py0000644000000000000000000010321613615410400020422 0ustar00"""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*. :param key: sysconfig installation scheme key (e.g. ``"scripts"``, ``"purelib"``). """ 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. :param key: sysconfig path key (e.g. ``"purelib"``, ``"include"``). :param config_var: replacement mapping for sysconfig variables; when ``None`` uses the interpreter's own values. :param sep: path separator to use in the result. """ 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*. :param cache: the cache store to clear. """ 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. :param spec: the specification to check against. :param impl_must_match: when ``True``, the implementation name must match exactly. """ 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 for specifier in spec.version_specifier: assert specifier.version is not None # noqa: S101 numeric_version = specifier.version_str for prefix in ("rc", "b", "a"): if prefix in numeric_version: numeric_version = numeric_version.split(prefix)[0] break precision = numeric_version.count(".") + 1 release = ".".join(str(c) for c in [version_info.major, version_info.minor, version_info.micro][:precision]) if ( version_info.releaselevel != "final" and (precision == 3 or specifier.version.pre_type is not None) # noqa: PLR2004 and (suffix := {"alpha": "a", "beta": "b", "candidate": "rc"}.get(version_info.releaselevel)) ): release = f"{release}{suffix}{version_info.serial}" if not specifier.contains(release): return False return True _current_system = None _current = None @classmethod def current(cls, cache: PyInfoCache | None = None) -> PythonInfo: """ Locate the current host interpreter information. :param cache: interpreter metadata cache; when ``None`` results are not cached. """ 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. :param cache: interpreter metadata cache; when ``None`` results are not cached. """ if cls._current_system is None: result = cls.from_exe(sys.executable, cache, raise_on_error=True, resolve_to_host=True) if result is None: msg = "failed to query current system Python interpreter" raise RuntimeError(msg) cls._current_system = result return cls._current_system def to_json(self) -> str: """Serialize this interpreter information to a JSON string.""" return json.dumps(self.to_dict(), indent=2) def to_dict(self) -> dict[str, object]: """Convert this interpreter information to a plain dictionary.""" 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. :param exe: path to the Python executable. :param cache: interpreter metadata cache; when ``None`` results are not cached. :param raise_on_error: raise on failure instead of returning ``None``. :param ignore_cache: bypass the cache and re-query the interpreter. :param resolve_to_host: resolve through virtualenv layers to the system interpreter. :param env: environment mapping; defaults to :data:`os.environ`. """ 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: """ Deserialize interpreter information from a JSON string. :param payload: JSON produced by :meth:`to_json`. """ raw = json.loads(payload) return cls.from_dict(raw.copy()) @classmethod def from_dict(cls, data: dict[str, object]) -> PythonInfo: """ Reconstruct a :class:`PythonInfo` from a plain dictionary. :param data: dictionary produced by :meth:`to_dict`. """ 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: """ Walk virtualenv/venv prefix chains to find the underlying system interpreter. :param cache: interpreter metadata cache; when ``None`` results are not cached. :param target: the interpreter to resolve. """ 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. :param cache: interpreter metadata cache. :param prefix: directory prefix to search under. :param exact: when ``True``, require an exact version match. :param env: environment mapping; defaults to :data:`os.environ`. """ 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 KNOWN_ARCHITECTURES: frozenset[str] = frozenset({ "arm64", "loongarch64", "ppc", "ppc64", "ppc64le", "riscv64", "s390x", "sparc64", "x86", "x86_64", }) """Known CPU architecture (ISA) values after normalization. .. deprecated:: Use :func:`normalize_isa` instead, which handles both known and unknown architectures. """ def normalize_isa(isa: str) -> str: """ Normalize an ISA (instruction set architecture) string to a canonical form. Known aliases are mapped (e.g. ``amd64`` → ``x86_64``, ``aarch64`` → ``arm64``). Unrecognized values are lowercased and returned as-is. """ low = isa.lower() return { "amd64": "x86_64", "aarch64": "arm64", "i386": "x86", "i486": "x86", "i586": "x86", "i686": "x86", "powerpc": "ppc", "powerpc64": "ppc64", "powerpc64le": "ppc64le", "sparcv9": "sparc64", }.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() python_discovery-1.2.2/src/python_discovery/_py_spec.py0000644000000000000000000002260113615410400020417 0ustar00"""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. :param str_spec: the raw specification string as provided by the caller. :param implementation: interpreter implementation name (e.g. ``"cpython"``, ``"pypy"``), or ``None`` for any. :param major: required major version, or ``None`` for any. :param minor: required minor version, or ``None`` for any. :param micro: required micro (patch) version, or ``None`` for any. :param architecture: required pointer-size bitness (``32`` or ``64``), or ``None`` for any. :param path: filesystem path to a specific interpreter, or ``None``. :param free_threaded: whether a free-threaded build is required, or ``None`` for any. :param machine: required ISA (e.g. ``"arm64"``), or ``None`` for any. :param version_specifier: PEP 440 version constraints, or ``None``. """ 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 :class:`PythonSpec`. :param string_spec: an interpreter spec — an absolute path, a version string, an implementation prefix, or a PEP 440 specifier. """ 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 interpreter filenames. :param windows: if ``True``, require a ``.exe`` suffix. """ 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: """``True`` if the spec refers to an absolute filesystem path.""" return self.path is not None and pathlib.Path(self.path).is_absolute() def _check_version_specifier(self, spec: PythonSpec) -> bool: """Check if version specifier is satisfied.""" 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). :param spec: the requirement to check against. """ if spec.is_abs and self.is_abs and self.path != spec.path: return False if ( spec.implementation is not None and self.implementation is not None and spec.implementation.lower() != self.implementation.lower() ): return False if spec.architecture is not None and spec.architecture != self.architecture: return False if spec.machine is not None and self.machine is not None and spec.machine != self.machine: return False if spec.free_threaded is not None and spec.free_threaded != self.free_threaded: return False if spec.version_specifier is not None and not self._check_version_specifier(spec): return False return all( req is None or our is None or our == req for our, req in zip((self.major, self.minor, self.micro), (spec.major, spec.minor, spec.micro)) ) def __repr__(self) -> str: name = type(self).__name__ params = ( "implementation", "major", "minor", "micro", "architecture", "machine", "path", "free_threaded", "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", ] python_discovery-1.2.2/src/python_discovery/_specifier.py0000644000000000000000000002420013615410400020723 0ustar00"""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. :param version_str: the original version string. :param major: major version number. :param minor: minor version number. :param micro: micro (patch) version number. :param pre_type: pre-release label (``"a"``, ``"b"``, or ``"rc"``), or ``None``. :param pre_num: pre-release sequence number, or ``None``. :param release: the ``(major, minor, micro)`` tuple. """ 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: """ Parse a PEP 440 version string (e.g. ``3.12.1``). :param version_str: the version string to parse. """ 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. :param spec_str: the original specifier string (e.g. ``>=3.10``). :param operator: the comparison operator (``==``, ``>=``, ``<``, etc.). :param version_str: the version portion of the specifier, without the operator. :param is_wildcard: ``True`` if the specifier uses a wildcard suffix (``.*``). :param wildcard_precision: number of version components before the wildcard, or ``None``. :param version: the parsed version, or ``None`` if parsing failed. """ 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: """ Parse a single PEP 440 specifier (e.g. ``>=3.10``). :param spec_str: the specifier string to parse. """ 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. :param version_str: the version string to test. """ 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. :param specifiers_str: the original comma-separated specifier string. :param specifiers: the parsed individual specifiers. """ specifiers_str: str specifiers: tuple[SimpleSpecifier, ...] @classmethod def from_string(cls, specifiers_str: str = "") -> SimpleSpecifierSet: """ Parse a comma-separated PEP 440 specifier string (e.g. ``>=3.10,<4``). :param specifiers_str: the specifier string to parse. """ 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. :param version_str: the version string to test. """ if not self.specifiers: return True return all(spec.contains(version_str) for spec in self.specifiers) def __iter__(self) -> Iterator[SimpleSpecifier]: return iter(self.specifiers) 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", ] python_discovery-1.2.2/src/python_discovery/py.typed0000644000000000000000000000000013615410400017730 0ustar00python_discovery-1.2.2/src/python_discovery/_windows/__init__.py0000644000000000000000000000047313615410400022211 0ustar00"""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", ] python_discovery-1.2.2/src/python_discovery/_windows/_pep514.py0000644000000000000000000001675213615410400021636 0ustar00"""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() python_discovery-1.2.2/src/python_discovery/_windows/_propose.py0000644000000000000000000000303513615410400022275 0ustar00from __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", ] python_discovery-1.2.2/tests/conftest.py0000644000000000000000000000154513615410400015412 0ustar00from __future__ import annotations 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 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 msg = "test not valid if run under system" raise pytest.skip.Exception(msg) python_discovery-1.2.2/tests/test_cache.py0000644000000000000000000000641013615410400015663 0ustar00from __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() python_discovery-1.2.2/tests/test_cached_py_info.py0000644000000000000000000002063113615410400017553 0ustar00from __future__ import annotations import json import logging import os import sys from pathlib import Path from subprocess import TimeoutExpired 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_timeout(mocker: MockerFixture) -> None: mock_process = MagicMock() mock_process.communicate.side_effect = [TimeoutExpired(cmd="python", timeout=30), ("", "")] 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 "timed out" in str(failure) assert result is None mock_process.kill.assert_called_once() assert mock_process.communicate.call_count == 2 def test_run_subprocess_custom_timeout(mocker: MockerFixture) -> None: mock_process = MagicMock() mock_process.communicate.return_value = (json.dumps(PythonInfo().to_dict()), "") mock_process.returncode = 0 mocker.patch("python_discovery._cached_py_info.Popen", return_value=mock_process) env = dict(os.environ) env["PY_DISCOVERY_TIMEOUT"] = "30" _run_subprocess(PythonInfo, sys.executable, env) mock_process.communicate.assert_called_once_with(timeout=30.0) 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) python_discovery-1.2.2/tests/test_discovery.py0000644000000000000000000004312613615410400016634 0ustar00from __future__ import annotations import logging import os import stat import subprocess import sys from pathlib import Path from typing import TYPE_CHECKING from unittest.mock import patch from uuid import uuid4 import pytest 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.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) 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: monkeypatch.setenv("PATH", str(tmp_path)) interpreter = get_interpreter(uuid4().hex, []) assert interpreter is None 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, [], session_cache) assert result is not None 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) 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)) 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) 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) result = get_interpreter(["magic-one", "magic-two"], cache=session_cache) assert result is None assert "accepted" not in caplog.text 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) def test_predicate_filters_interpreters(session_cache: DiskCache) -> None: result = get_interpreter(sys.executable, [], session_cache, predicate=lambda _: False) assert result is None def test_predicate_accepts_interpreter(session_cache: DiskCache) -> None: result = get_interpreter(sys.executable, [], session_cache, predicate=lambda _: True) assert result is not None assert result.executable == sys.executable def test_predicate_none_is_noop(session_cache: DiskCache) -> None: result = get_interpreter(sys.executable, [], session_cache, predicate=None) assert result is not None assert result.executable == sys.executable def test_predicate_with_fallback_specs(session_cache: DiskCache) -> None: current = PythonInfo.current_system(session_cache) major, minor = current.version_info.major, current.version_info.minor accepted_exe: str | None = None def reject_first(info: PythonInfo) -> bool: nonlocal accepted_exe if accepted_exe is None: accepted_exe = str(info.executable) return False return True result = get_interpreter([f"{major}.{minor}", sys.executable], [], session_cache, predicate=reject_first) assert accepted_exe is not None assert result is not None assert str(result.executable) != accepted_exe python_discovery-1.2.2/tests/test_discovery_extra.py0000644000000000000000000002033213615410400020031 0ustar00from __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 python_discovery-1.2.2/tests/test_py_info_extra.py0000644000000000000000000004413313615410400017472 0ustar00from __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 @pytest.mark.parametrize( ("version_info", "spec_str", "expected"), [ pytest.param(VersionInfo(3, 14, 0, "alpha", 1), ">=3.14.0a1", True, id="alpha_match_exact"), pytest.param(VersionInfo(3, 14, 0, "beta", 1), ">=3.14.0b1", True, id="beta_match_exact"), pytest.param(VersionInfo(3, 14, 0, "candidate", 1), ">=3.14.0rc1", True, id="rc_match_exact"), pytest.param(VersionInfo(3, 15, 0, "alpha", 6), ">=3.15", True, id="prerelease_match_major_minor"), pytest.param(VersionInfo(3, 15, 0, "alpha", 6), ">=3.15.0", False, id="prerelease_not_match_full_precision"), pytest.param(VersionInfo(3, 15, 0, "alpha", 5), "<3.15.0a6", True, id="earlier_prerelease_less_than"), pytest.param(VersionInfo(3, 15, 0, "alpha", 6), "<3.15.0a6", False, id="prerelease_not_less_than_itself"), pytest.param(VersionInfo(3, 15, 0, "alpha", 6), ">=3.15.0a6", True, id="prerelease_match_itself"), pytest.param(VersionInfo(3, 15, 0, "alpha", 6), ">=3.15.0a7", False, id="prerelease_not_match_later"), pytest.param(VersionInfo(3, 15, 0, "final", 0), ">=3.15.0a6", True, id="final_greater_than_prerelease"), pytest.param(VersionInfo(3, 15, 0, "final", 0), "<3.15.0a6", False, id="final_not_less_than_prerelease"), pytest.param(VersionInfo(3, 15, 0, "final", 0), ">=3.15", True, id="final_match_major_minor"), pytest.param(VersionInfo(3, 15, 1, "alpha", 1), ">=3.15.0", True, id="later_micro_prerelease_match"), ], ) def test_satisfies_version_specifier_prerelease(version_info: VersionInfo, spec_str: str, expected: bool) -> None: info = copy.deepcopy(CURRENT) info.version_info = version_info spec = PythonSpec.from_string_spec(spec_str) assert info.satisfies(spec, impl_must_match=False) is expected def test_satisfies_path_not_abs_basename_match() -> None: info = copy.deepcopy(CURRENT) 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" python_discovery-1.2.2/tests/test_py_spec.py0000644000000000000000000002211213615410400016257 0ustar00from __future__ import annotations import sys from typing import TYPE_CHECKING import pytest 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 def test_bad_py_spec() -> None: text = "python2.3.4.5" spec = PythonSpec.from_string_spec(text) assert text in repr(spec) assert spec.str_spec == text assert spec.path == text content = vars(spec) del content["str_spec"] del content["path"] assert all(v is None for v in content.values()) def test_py_spec_first_digit_only_major() -> None: spec = PythonSpec.from_string_spec("278") assert spec.major == 2 assert spec.minor == 78 def test_spec_satisfies_path_ok() -> None: spec = PythonSpec.from_string_spec(sys.executable) assert spec.satisfies(spec) is True 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 def test_spec_satisfies_arch() -> None: spec_1 = PythonSpec.from_string_spec("python-32") spec_2 = PythonSpec.from_string_spec("python-64") assert spec_1.satisfies(spec_1) is True 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")], ) def test_spec_satisfies_implementation_ok(req: str, spec: str) -> None: spec_1 = PythonSpec.from_string_spec(req) spec_2 = PythonSpec.from_string_spec(spec) assert spec_1.satisfies(spec_1) is True assert spec_2.satisfies(spec_1) is True def test_spec_satisfies_implementation_nok() -> None: spec_1 = PythonSpec.from_string_spec("cpython") spec_2 = PythonSpec.from_string_spec("jython") assert spec_2.satisfies(spec_1) is False assert spec_1.satisfies(spec_2) is False 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 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) @pytest.mark.parametrize(("req", "spec"), _version_satisfies_pairs()) def test_version_satisfies_ok(req: str, spec: str) -> None: req_spec = PythonSpec.from_string_spec(f"python{req}") sat_spec = PythonSpec.from_string_spec(f"python{spec}") assert sat_spec.satisfies(req_spec) is True def _version_not_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 major in range(len(version)): req = ".".join(version[0 : major + 1]) for minor in range(major + 1): sat_ver: list[int] = [int(v) for v in sys.version_info[0 : minor + 1]] for patch in range(minor + 1): 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) @pytest.mark.parametrize(("req", "spec"), _version_not_satisfies_pairs()) def test_version_satisfies_nok(req: str, spec: str) -> None: req_spec = PythonSpec.from_string_spec(f"python{req}") sat_spec = PythonSpec.from_string_spec(f"python{spec}") assert sat_spec.satisfies(req_spec) is False def test_relative_spec(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.chdir(tmp_path) 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("i386", "x86", id="i386"), pytest.param("i486", "x86", id="i486"), pytest.param("i586", "x86", id="i586"), pytest.param("i686", "x86", id="i686"), pytest.param("ppc64le", "ppc64le", id="ppc64le"), pytest.param("powerpc", "ppc", id="powerpc"), pytest.param("powerpc64", "ppc64", id="powerpc64"), pytest.param("powerpc64le", "ppc64le", id="powerpc64le"), pytest.param("riscv64", "riscv64", id="riscv64"), pytest.param("s390x", "s390x", id="s390x"), pytest.param("sparcv9", "sparc64", id="sparcv9"), pytest.param("sparc64", "sparc64", id="sparc64"), pytest.param("alpha", "alpha", id="alpha-passthrough"), ], ) 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)) python_discovery-1.2.2/tests/test_py_spec_extra.py0000644000000000000000000001017113615410400017464 0ustar00from __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 python_discovery-1.2.2/tests/test_specifier.py0000644000000000000000000002447413615410400016603 0ustar00from __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 python_discovery-1.2.2/tests/py_info/test_py_info.py0000644000000000000000000004061013615410400017726 0ustar00from __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 assert machine == machine.lower(), f"machine value should be lowercase: {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] python_discovery-1.2.2/tests/py_info/test_py_info_exe_based_of.py0000644000000000000000000000536213615410400022416 0ustar00from __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) python_discovery-1.2.2/tests/windows/conftest.py0000644000000000000000000001543213615410400017104 0ustar00from __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) python_discovery-1.2.2/tests/windows/test_windows.py0000644000000000000000000000336113615410400020006 0ustar00from __future__ import annotations import sys import pytest 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"), [ ("python3.10", "C:\\Users\\user\\Miniconda3-64\\python.exe"), ("cpython3.10", "C:\\Users\\user\\Miniconda3-64\\python.exe"), ("python3.12", "C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python312\\python.exe"), ("cpython3.12", "C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python312\\python.exe"), ("python", "C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python313\\python3.13t.exe"), ("cpython", "C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python313\\python3.13t.exe"), ("python3", "C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python312\\python.exe"), ("cpython3", "C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python312\\python.exe"), ("python3.6", "Z:\\CompanyA\\Python\\3.6\\python.exe"), ("cpython3.6", "Z:\\CompanyA\\Python\\3.6\\python.exe"), ("3t", "C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python313\\python3.13t.exe"), ("python3.13t", "C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python313\\python3.13t.exe"), ], ) def test_propose_interpreters(string_spec: str, expected_exe: str) -> None: from python_discovery._windows import propose_interpreters spec = PythonSpec.from_string_spec(string_spec) interpreter = next(propose_interpreters(spec=spec, cache=None, env={})) assert interpreter.executable == expected_exe python_discovery-1.2.2/tests/windows/test_windows_pep514.py0000644000000000000000000001327713615410400021113 0ustar00from __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 python_discovery-1.2.2/tests/windows/winreg_mock_values.py0000644000000000000000000001672213615410400021145 0ustar00from __future__ import annotations hive_open = { (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", ), } key_open = { 78701152: { "Anaconda310-32\\InstallPath": 78703200, "Anaconda310-32": 78703568, "Anaconda310-64\\InstallPath": 78703520, "Anaconda310-64": 78702368, }, 78701856: {"ContinuumAnalytics": 78701152, "PythonCore": 78702656, "CompanyA": 88800000}, 78702656: { "3.1\\InstallPath": 78701824, "3.1": 78700704, "3.2\\InstallPath": 78704048, "3.2": 78704368, "3.3\\InstallPath": 78701936, "3.3": 78703024, "3.8\\InstallPath": 78703792, "3.8": 78701792, "3.9\\InstallPath": 78701888, "3.9": 78703424, "3.10-32\\InstallPath": 78703600, "3.10-32": 78704512, "3.11\\InstallPath": OSError(2, "The system cannot find the file specified"), "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}, 78701840: {"PythonCore": 78702960}, 88800000: { "3.6\\InstallPath": 88810000, "3.6": 88820000, }, } value_collect = { 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), "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), "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), "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), "DisplayName": ("Python 3.9 (64-bit)", 1)}, 78701936: { "ExecutablePath": 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), "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), "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"), }, 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), }, 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"), 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"), 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), "DisplayName": OSError(2, "The system cannot find the file specified"), }, } enum_collect = { 78701856: [ "ContinuumAnalytics", "PythonCore", "CompanyA", OSError(22, "No more data is available", None, 259, None), ], 78701152: ["Anaconda310-32", "Anaconda310-64", OSError(22, "No more data is available", None, 259, None)], 78702656: [ "3.1", "3.2", "3.3", "3.8", "3.9", "3.10-32", "3.11", "3.12", "3.13t", "3.X", OSError(22, "No more data is available", None, 259, None), ], 78701840: ["PyLauncher", "PythonCore", OSError(22, "No more data is available", None, 259, None)], 78702960: ["2.7", "3.7", OSError(22, "No more data is available", None, 259, None)], 88800000: ["3.6", OSError(22, "No more data is available", None, 259, None)], } python_discovery-1.2.2/.gitignore0000644000000000000000000000007713615410400014040 0ustar00*.pyc *.egg-info dist/ .tox/ /src/python_discovery/_version.py python_discovery-1.2.2/LICENSE0000644000000000000000000000177713615410400013065 0ustar00Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. python_discovery-1.2.2/README.md0000644000000000000000000000415713615410400013332 0ustar00# [`python-discovery`](https://python-discovery.readthedocs.io/en/latest/) [![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/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.yaml/badge.svg)](https://github.com/tox-dev/python-discovery/actions/workflows/check.yaml) [![Documentation Status](https://readthedocs.org/projects/python-discovery/badge/?version=latest)](https://python-discovery.readthedocs.io/en/latest/?badge=latest) ## What is python-discovery? `python-discovery` is a library for discovering Python interpreters installed on your machine. You may have multiple Python versions from system packages, [pyenv](https://github.com/pyenv/pyenv), [mise](https://mise.jdx.dev/), [asdf](https://asdf-vm.com/), [uv](https://docs.astral.sh/uv/), or the Windows registry (PEP 514). This library finds the right one for you. 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. ## Usage ```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) # /usr/bin/python3.12 print(result.implementation) # CPython print(result.version_info[:3]) # (3, 12, 1) ``` The `get_interpreter()` function accepts various specification formats: - Absolute path: `/usr/bin/python3.12` - Version: `3.12` - Implementation prefix: `cpython3.12` - PEP 440 specifier: `>=3.10`, `>=3.11,<3.13` ## Documentation Full documentation is available at [python-discovery.readthedocs.io](https://python-discovery.readthedocs.io/en/latest/) python_discovery-1.2.2/pyproject.toml0000644000000000000000000001234613615410400014766 0ustar00[build-system] build-backend = "hatchling.build" requires = [ "hatch-vcs>=0.5", "hatchling>=1.28", ] [project] name = "python-discovery" description = "Python interpreter discovery" readme = "README.md" keywords = [ "discovery", "interpreter", "python", ] license.file = "LICENSE" maintainers = [ { name = "Bernát Gábor", email = "gaborjbernat@gmail.com" }, ] requires-python = ">=3.8" classifiers = [ "Development Status :: 5 - Production/Stable", "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.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 :: Utilities", ] dynamic = [ "version", ] dependencies = [ "filelock>=3.15.4", "platformdirs<5,>=4.3.6", ] optional-dependencies.docs = [ "furo>=2025.12.19", "sphinx>=9.1", "sphinx-autodoc-typehints>=3.6.3", "sphinxcontrib-mermaid>=2", ] optional-dependencies.testing = [ "covdefaults>=2.3", "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] version.source = "vcs" [tool.ruff] line-length = 120 format.preview = true format.docstring-code-line-length = 100 format.docstring-code-format = true lint.select = [ "ALL", ] lint.ignore = [ "COM812", # Conflict with formatter "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 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", # 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 = [ "python_discovery", ], required-imports = [ "from __future__ import annotations", ] } lint.preview = true [tool.codespell] builtin = "clear,usage,en-GB_to_en-US" write-changes = true count = true [tool.pyproject-fmt] max_supported_python = "3.14" [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" [tool.pytest] ini_options.markers = [ "graalpy" ] [tool.coverage] run.branch = true run.dynamic_context = "test_function" run.parallel = true run.plugins = [ "covdefaults", ] run.source = [ "python_discovery", "tests", ] paths.source = [ "src", ".tox*/*/lib/python*/site-packages", ".tox*/pypy*/site-packages", ".tox*\\*\\Lib\\site-packages", "*/src", "*\\src", ] report.fail_under = 100 report.omit = [ "src/python_discovery/_windows/*", "tests/windows/*", ] report.partial_branches = [ "assert any\\(", ] report.show_missing = true html.show_contexts = true html.skip_covered = false python_discovery-1.2.2/PKG-INFO0000644000000000000000000001243413615410400013145 0ustar00Metadata-Version: 2.4 Name: python-discovery Version: 1.2.2 Summary: Python interpreter discovery Project-URL: Changelog, https://github.com/tox-dev/python-discovery/releases Project-URL: Documentation, https://python-discovery.readthedocs.io Project-URL: Homepage, https://github.com/tox-dev/python-discovery Project-URL: Source, https://github.com/tox-dev/python-discovery Project-URL: Tracker, https://github.com/tox-dev/python-discovery/issues Maintainer-email: Bernát Gábor License: Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. License-File: LICENSE Keywords: discovery,interpreter,python Classifier: Development Status :: 5 - Production/Stable Classifier: Intended Audience :: Developers Classifier: License :: OSI Approved :: MIT License Classifier: Operating System :: MacOS :: MacOS X Classifier: Operating System :: Microsoft :: Windows Classifier: Operating System :: POSIX Classifier: Programming Language :: Python :: 3 :: Only Classifier: Programming Language :: Python :: 3.8 Classifier: Programming Language :: Python :: 3.9 Classifier: Programming Language :: Python :: 3.10 Classifier: Programming Language :: Python :: 3.11 Classifier: Programming Language :: Python :: 3.12 Classifier: Programming Language :: Python :: 3.13 Classifier: Programming Language :: Python :: 3.14 Classifier: Programming Language :: Python :: Implementation :: CPython Classifier: Topic :: Software Development :: Libraries Classifier: Topic :: Utilities Requires-Python: >=3.8 Requires-Dist: filelock>=3.15.4 Requires-Dist: platformdirs<5,>=4.3.6 Provides-Extra: docs Requires-Dist: furo>=2025.12.19; extra == 'docs' Requires-Dist: sphinx-autodoc-typehints>=3.6.3; extra == 'docs' Requires-Dist: sphinx>=9.1; extra == 'docs' Requires-Dist: sphinxcontrib-mermaid>=2; extra == 'docs' Provides-Extra: testing Requires-Dist: covdefaults>=2.3; extra == 'testing' Requires-Dist: coverage>=7.5.4; extra == 'testing' Requires-Dist: pytest-mock>=3.14; extra == 'testing' Requires-Dist: pytest>=8.3.5; extra == 'testing' Requires-Dist: setuptools>=75.1; extra == 'testing' Description-Content-Type: text/markdown # [`python-discovery`](https://python-discovery.readthedocs.io/en/latest/) [![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/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.yaml/badge.svg)](https://github.com/tox-dev/python-discovery/actions/workflows/check.yaml) [![Documentation Status](https://readthedocs.org/projects/python-discovery/badge/?version=latest)](https://python-discovery.readthedocs.io/en/latest/?badge=latest) ## What is python-discovery? `python-discovery` is a library for discovering Python interpreters installed on your machine. You may have multiple Python versions from system packages, [pyenv](https://github.com/pyenv/pyenv), [mise](https://mise.jdx.dev/), [asdf](https://asdf-vm.com/), [uv](https://docs.astral.sh/uv/), or the Windows registry (PEP 514). This library finds the right one for you. 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. ## Usage ```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) # /usr/bin/python3.12 print(result.implementation) # CPython print(result.version_info[:3]) # (3, 12, 1) ``` The `get_interpreter()` function accepts various specification formats: - Absolute path: `/usr/bin/python3.12` - Version: `3.12` - Implementation prefix: `cpython3.12` - PEP 440 specifier: `>=3.10`, `>=3.11,<3.13` ## Documentation Full documentation is available at [python-discovery.readthedocs.io](https://python-discovery.readthedocs.io/en/latest/)