python_discovery-1.9.5/.pre-commit-config.yaml0000644000000000000000000000460215273371321016353 0ustar00ci: autoupdate_schedule: quarterly default_language_version: python: python3.15 repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: 3e8a8703264a2f4a69428a0aa4dcb512790b2c8c # frozen: v6.0.0 hooks: - id: end-of-file-fixer - id: trailing-whitespace - repo: https://github.com/python-jsonschema/check-jsonschema rev: a76b92851fc9eede9f9bc4073eb55202f34eb0df # frozen: 0.39.0 hooks: - id: check-github-workflows args: ["--verbose"] - repo: https://github.com/codespell-project/codespell rev: 57b21406f092110c18776e39b0bda50d37c945c8 # frozen: v2.4.3 hooks: - id: codespell - repo: https://github.com/tox-dev/pyproject-fmt rev: "50e0f3aee9dc5747a5f173f79a7417403c18dda6" # frozen: v2.30.3 hooks: - id: pyproject-fmt - repo: https://github.com/astral-sh/ruff-pre-commit rev: "25bcf95026b96e2dd3c0aec3090c3d6ef6ca2e6c" # frozen: v0.17.3 hooks: - id: ruff-format - id: ruff args: ["--fix", "--unsafe-fixes", "--exit-non-zero-on-fix"] - repo: https://github.com/google/yamlfmt rev: "21ca5323a9c87ee37a434e0ca908efc0a89daa07" # frozen: v0.21.0 hooks: - id: yamlfmt - repo: https://github.com/executablebooks/mdformat rev: 82912cdaea4fb830f751504486a7879c70526547 # frozen: 1.0.0 hooks: - id: mdformat args: ["--wrap", "120"] additional_dependencies: - mdformat-gfm==1.0.0 - mdformat-ruff==0.1.3 - repo: https://github.com/scop/pre-commit-shfmt rev: 479be5958357ba5ab65ce47172117348516860e9 # frozen: v3.14.1-1 hooks: - id: shfmt args: ["-i", "2"] - repo: https://github.com/shellcheck-py/shellcheck-py rev: 745eface02aef23e168a8afb6b5737818efbea95 # frozen: v0.11.0.1 hooks: - id: shellcheck - repo: https://github.com/LilSpazJoekp/docstrfmt rev: c1813841673cdcd6cf602dde1edb78a5d5e45235 # frozen: v2.2.1 hooks: - id: docstrfmt files: | (?x) ^docs/changelog[.]rst$ # Only the generated release history. args: ["-l", "120", "--preserve-adornments"] additional_dependencies: - "sphinx==9.2.0" - repo: https://github.com/zizmorcore/zizmor-pre-commit rev: c92afdf2c6ca336aaa8f39e2bd8d48f62dbd8e1f # frozen: v1.31.1 hooks: - id: zizmor - repo: meta hooks: - id: check-hooks-apply - id: check-useless-excludes python_discovery-1.9.5/.readthedocs.yaml0000644000000000000000000000040715273371321015320 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.15 --managed-python - ~/.local/bin/tox run -e docs -- "$READTHEDOCS_OUTPUT/html" python_discovery-1.9.5/.readthedocs.yml0000644000000000000000000000040415273371321015154 0ustar00version: 2 build: os: ubuntu-22.04 tools: {} commands: - curl -LsSf https://astral.sh/uv/install.sh | sh - ~/.local/bin/uv tool install tox --with tox-uv -p 3.15 --managed-python - ~/.local/bin/tox run -e docs -- "${READTHEDOCS_OUTPUT}"/html python_discovery-1.9.5/CODE_OF_CONDUCT.md0000644000000000000000000000626415273371321014677 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.9.5/CONTRIBUTING.md0000644000000000000000000000022015273371321014313 0ustar00# Contributing ## Tests Add a test for every new feature and bug fix. A change without a test needs a reason in the pull request description. python_discovery-1.9.5/README.md0000644000000000000000000000520015273371321013344 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) [![OpenSSF Scorecard](https://api.scorecard.dev/projects/github.com/tox-dev/python-discovery/badge)](https://scorecard.dev/viewer/?uri=github.com/tox-dev/python-discovery) [![OpenSSF Best Practices](https://www.bestpractices.dev/projects/16262/badge)](https://www.bestpractices.dev/projects/16262) ## 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. The library runs on Python 3.9+ and discovers interpreters as old as Python 3.6; anything older is skipped with a warning naming the version it found. ## 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` - [Version specifier](https://packaging.python.org/en/latest/specifications/version-specifiers/): `>=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.9.5/pylock.toml0000644000000000000000000026316115273371321014277 0ustar00lock-version = "1.0" environments = [ "python_version == \"3.15\" and sys_platform == \"linux\" and platform_machine == \"x86_64\" and implementation_name == \"cpython\" and platform_python_implementation == \"CPython\" and platform_system == \"Linux\"", ] requires-python = ">=3.9" dependency-groups = [ "build", "docs", "pkg-meta", "project-runtime", "release-news", "release-notes", "release-prepare", "test", ] default-groups = [ "project-runtime", ] created-by = "nab" [[packages]] name = "accessible-pygments" version = "0.0.5" marker = "\"docs\" in dependency_groups" requires-python = ">=3.9" dependencies = [ { name = "pygments" }, ] index = "https://pypi.org/simple/" [packages.sdist] name = "accessible_pygments-0.0.5.tar.gz" upload-time = 2024-05-10 11:23:10.216775+00:00 url = "https://files.pythonhosted.org/packages/bc/c1/bbac6a50d02774f91572938964c582fff4270eee73ab822a4aeea4d8b11b/accessible_pygments-0.0.5.tar.gz" size = 1377899 [packages.sdist.hashes] sha256 = "40918d3e6a2b619ad424cb91e556bd3bd8865443d9f22f1dcdf79e33c8046872" [[packages.wheels]] name = "accessible_pygments-0.0.5-py3-none-any.whl" upload-time = 2024-05-10 11:23:08.421378+00:00 url = "https://files.pythonhosted.org/packages/8d/3f/95338030883d8c8b91223b4e21744b04d11b161a3ef117295d8241f50ab4/accessible_pygments-0.0.5-py3-none-any.whl" size = 1395903 [packages.wheels.hashes] sha256 = "88ae3211e68a1d0b011504b2ffc1691feafce124b845bd072ab6f9f66f34d4b7" [[packages]] name = "alabaster" version = "1.0.0" marker = "\"docs\" in dependency_groups" requires-python = ">=3.10" index = "https://pypi.org/simple/" [packages.sdist] name = "alabaster-1.0.0.tar.gz" upload-time = 2024-07-26 18:15:03.762569+00:00 url = "https://files.pythonhosted.org/packages/a6/f8/d9c74d0daf3f742840fd818d69cfae176fa332022fd44e3469487d5a9420/alabaster-1.0.0.tar.gz" size = 24210 [packages.sdist.hashes] sha256 = "c00dca57bca26fa62a6d7d0a9fcce65f3e026e9bfe33e9c538fd3fbb2144fd9e" [[packages.wheels]] name = "alabaster-1.0.0-py3-none-any.whl" upload-time = 2024-07-26 18:15:02.050654+00:00 url = "https://files.pythonhosted.org/packages/7e/b3/6b4067be973ae96ba0d615946e314c5ae35f9f993eca561b356540bb0c2b/alabaster-1.0.0-py3-none-any.whl" size = 13929 [packages.wheels.hashes] sha256 = "fc6786402dc3fcb2de3cabd5fe455a2db534b371124f1f21de8731783dec828b" [[packages]] name = "annotated-types" version = "0.8.0" marker = "\"pkg-meta\" in dependency_groups" requires-python = ">=3.10" index = "https://pypi.org/simple/" [packages.sdist] name = "annotated_types-0.8.0.tar.gz" upload-time = 2026-07-23 20:16:13.995706+00:00 url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz" size = 15893 [packages.sdist.hashes] sha256 = "13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7" [[packages.wheels]] name = "annotated_types-0.8.0-py3-none-any.whl" upload-time = 2026-07-23 20:16:12.938651+00:00 url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl" size = 13427 [packages.wheels.hashes] sha256 = "f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0" [[packages]] name = "attrs" version = "26.1.0" marker = "\"pkg-meta\" in dependency_groups" requires-python = ">=3.9" index = "https://pypi.org/simple/" [packages.sdist] name = "attrs-26.1.0.tar.gz" upload-time = 2026-03-19 14:22:25.026315+00:00 url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz" size = 952055 [packages.sdist.hashes] sha256 = "d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32" [[packages.wheels]] name = "attrs-26.1.0-py3-none-any.whl" upload-time = 2026-03-19 14:22:23.645947+00:00 url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl" size = 67548 [packages.wheels.hashes] sha256 = "c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309" [[packages]] name = "babel" version = "2.18.0" marker = "\"docs\" in dependency_groups" requires-python = ">=3.8" index = "https://pypi.org/simple/" [packages.sdist] name = "babel-2.18.0.tar.gz" upload-time = 2026-02-01 12:30:56.078194+00:00 url = "https://files.pythonhosted.org/packages/7d/b2/51899539b6ceeeb420d40ed3cd4b7a40519404f9baf3d4ac99dc413a834b/babel-2.18.0.tar.gz" size = 9959554 [packages.sdist.hashes] sha256 = "b80b99a14bd085fcacfa15c9165f651fbb3406e66cc603abf11c5750937c992d" [[packages.wheels]] name = "babel-2.18.0-py3-none-any.whl" upload-time = 2026-02-01 12:30:53.445172+00:00 url = "https://files.pythonhosted.org/packages/77/f5/21d2de20e8b8b0408f0681956ca2c69f1320a3848ac50e6e7f39c6159675/babel-2.18.0-py3-none-any.whl" size = 10196845 [packages.wheels.hashes] sha256 = "e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35" [[packages]] name = "beautifulsoup4" version = "4.15.0" marker = "\"docs\" in dependency_groups" requires-python = ">=3.7.0" dependencies = [ { name = "soupsieve" }, { name = "typing-extensions" }, ] index = "https://pypi.org/simple/" [packages.sdist] name = "beautifulsoup4-4.15.0.tar.gz" upload-time = 2026-06-07 16:44:20.453113+00:00 url = "https://files.pythonhosted.org/packages/43/65/318323f98dbee45d42dff61d8f047181bc6f2268a9068cfad035a46be5af/beautifulsoup4-4.15.0.tar.gz" size = 632571 [packages.sdist.hashes] sha256 = "288e3ca7d54b06f2ac191970bc275c1939cb46d450b255bf6718b04aa37ab4f7" [[packages.wheels]] name = "beautifulsoup4-4.15.0-py3-none-any.whl" upload-time = 2026-06-07 16:44:21.566528+00:00 url = "https://files.pythonhosted.org/packages/88/c6/92fcd42f1ba33e1184263f25bfabf3d27c383410470f169e4b8163bf9c17/beautifulsoup4-4.15.0-py3-none-any.whl" size = 109924 [packages.wheels.hashes] sha256 = "d6f88de62e1d4e38ecb1077eb9724cd0eff29d2a08ca16a401e9b9e93f117cf9" [[packages]] name = "certifi" version = "2026.10.23" marker = "\"docs\" in dependency_groups or \"pkg-meta\" in dependency_groups" requires-python = ">=3.7" index = "https://pypi.org/simple/" [packages.sdist] name = "certifi-2026.10.23.tar.gz" upload-time = 2026-10-23 11:36:16.478386+00:00 url = "https://files.pythonhosted.org/packages/fd/11/b524cbd9bbdaca58608e4cf4053fd463417549e1959fa8d3aa18319f9555/certifi-2026.10.23.tar.gz" size = 136225 [packages.sdist.hashes] sha256 = "a9895e6e2758b371e0545e321a3a8fd1fcfd9829ada8824a936dfb16aaaf36cd" [[packages.wheels]] name = "certifi-2026.10.23-py3-none-any.whl" upload-time = 2026-10-23 11:36:14.763067+00:00 url = "https://files.pythonhosted.org/packages/32/d1/a3652fe2c19134d0d15922177821d863cbd7e842d53a21bbe7e8e3a1b5ac/certifi-2026.10.23-py3-none-any.whl" size = 135082 [packages.wheels.hashes] sha256 = "e9b54de76e530bb6cf0ccd036787103acb58fd42ba930ea8bf948ddbaa2f61d1" [[packages]] name = "cffi" version = "2.1.1" marker = "\"pkg-meta\" in dependency_groups" requires-python = ">=3.10" dependencies = [ { name = "pycparser" }, ] index = "https://pypi.org/simple/" [packages.sdist] name = "cffi-2.1.1.tar.gz" upload-time = 2026-08-03 21:21:18.939988+00:00 url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz" size = 530807 [packages.sdist.hashes] sha256 = "dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be" [[packages.wheels]] name = "cffi-2.1.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl" upload-time = 2026-08-03 21:20:57.336082+00:00 url = "https://files.pythonhosted.org/packages/44/16/29e6d01b388bef055ecd6ca8244b3f4d336bd09e92d5d892187b9601084e/cffi-2.1.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl" size = 221630 [packages.wheels.hashes] sha256 = "aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399" [[packages]] name = "charset-normalizer" version = "3.5.2" marker = "\"docs\" in dependency_groups or \"pkg-meta\" in dependency_groups" requires-python = ">=3.7" index = "https://pypi.org/simple/" [packages.sdist] name = "charset_normalizer-3.5.2.tar.gz" upload-time = 2026-09-30 04:39:23.398219+00:00 url = "https://files.pythonhosted.org/packages/33/1c/f41d4e74c28ab327ff3acd36053f7ea506c55872d7a90b0fa71aa3ab0c89/charset_normalizer-3.5.2.tar.gz" size = 172659 [packages.sdist.hashes] sha256 = "39de2a259fc954455c57274dc94c79d5842774e1247a016aff30bc0efed0f4ef" [[packages.wheels]] name = "charset_normalizer-3.5.2-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl" upload-time = 2026-09-30 04:37:37.397915+00:00 url = "https://files.pythonhosted.org/packages/a0/a1/4fbf5d0f0f1b2a080474c1cf9a2f12c4c6531bb0e8ba591055e846d2b4e9/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl" size = 257336 [packages.wheels.hashes] sha256 = "3df041de8887954562c9b261cba85ca0e9ded74048daf125f45edcfaa4832229" [[packages.wheels]] name = "charset_normalizer-3.5.2-cp37-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl" upload-time = 2026-09-30 04:38:28.032254+00:00 url = "https://files.pythonhosted.org/packages/7e/84/371eac6b30bdbcbf2d632a1a01809103459216fcaae61b8b8d922c1bfb8a/charset_normalizer-3.5.2-cp37-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl" size = 253276 [packages.wheels.hashes] sha256 = "1c50fe28bbc2ced33386f298650d91218076c05420e6cbd790b913adc41659e7" [[packages.wheels]] name = "charset_normalizer-3.5.2-py3-none-any.whl" upload-time = 2026-09-30 04:39:21.828049+00:00 url = "https://files.pythonhosted.org/packages/fc/ad/d07d7862a62ffa6d79d68074d14823243dd235a77c45262acbf6adeb28bf/charset_normalizer-3.5.2-py3-none-any.whl" size = 68872 [packages.wheels.hashes] sha256 = "b6b751274acb69d77b3323d6b7dbaa3c7fdfc1eb829b7eb61d262f32e1af9685" [[packages]] name = "check-wheel-contents" version = "0.6.3" marker = "\"pkg-meta\" in dependency_groups" requires-python = ">=3.10" dependencies = [ { name = "attrs" }, { name = "click" }, { name = "packaging" }, { name = "pydantic" }, { name = "wheel-filename" }, ] index = "https://pypi.org/simple/" [packages.sdist] name = "check_wheel_contents-0.6.3.tar.gz" upload-time = 2025-08-02 14:01:45.546952+00:00 url = "https://files.pythonhosted.org/packages/95/37/2b9e0d38c2f668791ffe8b97711185c364d91c027e47f189c371c2348d18/check_wheel_contents-0.6.3.tar.gz" size = 586023 [packages.sdist.hashes] sha256 = "10e6939e2fe4e6ce1edf2ff6ec6157808677e80782e78021ae139dd88473a442" [[packages.wheels]] name = "check_wheel_contents-0.6.3-py3-none-any.whl" upload-time = 2025-08-02 14:01:43.968164+00:00 url = "https://files.pythonhosted.org/packages/be/05/f39fde9f31ef80b285ef5822fad4ddabf73fec62a1f02c5beb4b2f328972/check_wheel_contents-0.6.3-py3-none-any.whl" size = 27541 [packages.wheels.hashes] sha256 = "5ae39c8c434b972f0740d04610759168590713175aab584b012b1b84f6771874" [[packages]] name = "click" version = "8.5.0" marker = "\"docs\" in dependency_groups or \"pkg-meta\" in dependency_groups or \"release-news\" in dependency_groups or \"release-notes\" in dependency_groups or \"release-prepare\" in dependency_groups" requires-python = ">=3.10" index = "https://pypi.org/simple/" [packages.sdist] name = "click-8.5.0.tar.gz" upload-time = 2026-08-26 13:33:14.560531+00:00 url = "https://files.pythonhosted.org/packages/c7/0e/7fa0ef50764b67090eca4114772a2abf8b6148198475e54c660b97caeee6/click-8.5.0.tar.gz" size = 382235 [packages.sdist.hashes] sha256 = "ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34" [[packages.wheels]] name = "click-8.5.0-py3-none-any.whl" upload-time = 2026-08-26 13:33:12.928043+00:00 url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl" size = 125251 [packages.wheels.hashes] sha256 = "255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360" [[packages]] name = "covdefaults" version = "2.3.0" marker = "\"test\" in dependency_groups" requires-python = ">=3.7" dependencies = [ { name = "coverage" }, ] index = "https://pypi.org/simple/" [packages.sdist] name = "covdefaults-2.3.0.tar.gz" upload-time = 2023-03-05 16:43:34.779146+00:00 url = "https://files.pythonhosted.org/packages/44/ee/9a6f2611f72e4c5657ae5542a510cf4164d2c673687c0ea73bb1cbd85b4d/covdefaults-2.3.0.tar.gz" size = 4835 [packages.sdist.hashes] sha256 = "4e99f679f12d792bc62e5510fa3eb59546ed47bd569e36e4fddc4081c9c3ebf7" [[packages.wheels]] name = "covdefaults-2.3.0-py2.py3-none-any.whl" upload-time = 2023-03-05 16:43:33.245581+00:00 url = "https://files.pythonhosted.org/packages/76/4c/823bc951445aa97e5a1b7e337690db3abf85212c8d138e170922e7916ac8/covdefaults-2.3.0-py2.py3-none-any.whl" size = 5144 [packages.wheels.hashes] sha256 = "2832961f6ffcfe4b57c338bc3418a3526f495c26fb9c54565409c5532f7c41be" [[packages]] name = "coverage" version = "7.17.0" marker = "\"test\" in dependency_groups" requires-python = ">=3.11" index = "https://pypi.org/simple/" [packages.sdist] name = "coverage-7.17.0.tar.gz" upload-time = 2026-10-22 16:06:50.009670+00:00 url = "https://files.pythonhosted.org/packages/d2/79/e78bd8f75a9b8e32233ce92d4607f0a21ce88a9156cace2884e7175a82f5/coverage-7.17.0.tar.gz" size = 991021 [packages.sdist.hashes] sha256 = "cc010a0a7fbdd9bb8bdfd0d3f04a5151275860213cbd7e2b876b29778489c712" [[packages.wheels]] name = "coverage-7.17.0-cp315-cp315-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl" upload-time = 2026-10-22 16:06:08.861264+00:00 url = "https://files.pythonhosted.org/packages/95/ee/84ed138990ed752abd73db4adffc1842d6152017d8e21809cc109a413fda/coverage-7.17.0-cp315-cp315-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl" size = 261735 [packages.wheels.hashes] sha256 = "aed06129a9f530a948928260d7d2d81c47261355b0127e3c782251b38958463c" [[packages.wheels]] name = "coverage-7.17.0-py3-none-any.whl" upload-time = 2026-10-22 16:06:48.471847+00:00 url = "https://files.pythonhosted.org/packages/c9/17/48032919343afa2906112d458aceda2043afdb24a2ee8eb4c60904d8ddf1/coverage-7.17.0-py3-none-any.whl" size = 217383 [packages.wheels.hashes] sha256 = "bac487dce0d2aff28ddf1a533a916f771416eee0e493960f611e49037b878e9d" [[packages]] name = "cryptography" version = "50.0.2" marker = "\"pkg-meta\" in dependency_groups" requires-python = "!=3.9.0,!=3.9.1,>=3.9" dependencies = [ { name = "cffi" }, ] index = "https://pypi.org/simple/" [packages.sdist] name = "cryptography-50.0.2.tar.gz" upload-time = 2026-09-30 15:30:04.884601+00:00 url = "https://files.pythonhosted.org/packages/9d/af/182eb91b0df3fe75c4d9f26fe70684569566745f6ba7e5c9c73a862c5252/cryptography-50.0.2.tar.gz" size = 880623 [packages.sdist.hashes] sha256 = "7b46165bb56eb4704e2eaaf86f3c940d19154535d9b0ca7d6d590b04060e00d5" [[packages.wheels]] name = "cryptography-50.0.2-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl" upload-time = 2026-09-30 14:43:49.010540+00:00 url = "https://files.pythonhosted.org/packages/21/69/64cef1f702bf6657e0cc186ed1a2891d50d29fb41586b254e1c07adea261/cryptography-50.0.2-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl" size = 4719841 [packages.wheels.hashes] sha256 = "630ebfea3bf689d075f82316324ff7433dc447fe6bc1bfc76524b74b4a9567d2" [[packages.wheels]] name = "cryptography-50.0.2-cp311-abi3-manylinux_2_28_x86_64.whl" upload-time = 2026-09-30 14:43:55.272150+00:00 url = "https://files.pythonhosted.org/packages/1a/f1/b474e930c4d910328780e3940da76f5aa5cbc48ce1fc14e44d239d9ea9db/cryptography-50.0.2-cp311-abi3-manylinux_2_28_x86_64.whl" size = 4753050 [packages.wheels.hashes] sha256 = "4061c0079120205fb760c58acab6443e217307dcf05e3702cf970e0689972856" [[packages.wheels]] name = "cryptography-50.0.2-cp311-abi3-manylinux_2_34_x86_64.whl" upload-time = 2026-09-30 14:44:04.545137+00:00 url = "https://files.pythonhosted.org/packages/d3/69/2c833a049475e0a3444e94c7d0aca0aa51d166374a449b09e92ac98138de/cryptography-50.0.2-cp311-abi3-manylinux_2_34_x86_64.whl" size = 4752576 [packages.wheels.hashes] sha256 = "9dab55f57c74c3cad24c323bacbbd04be4705ba6eb0d92e920b1fc4837ed5079" [[packages.wheels]] name = "cryptography-50.0.2-cp315-abi3.abi3t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl" upload-time = 2026-09-30 14:44:45.769143+00:00 url = "https://files.pythonhosted.org/packages/aa/a4/2c8d734e43d97f0842ee9f1b7b4bfb3d0cf5e19edebf43c2afe6675c2320/cryptography-50.0.2-cp315-abi3.abi3t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl" size = 4966465 [packages.wheels.hashes] sha256 = "f2f9bd7f90c64fe89253f0a2c05e3c4856072660429ce8831b4235bf29403a67" [[packages.wheels]] name = "cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_x86_64.whl" upload-time = 2026-09-30 14:44:53.379605+00:00 url = "https://files.pythonhosted.org/packages/02/a8/8df951850d6b31d2a00218f19e2b3f999523437ed7a819df7fa427942fca/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_x86_64.whl" size = 5001199 [packages.wheels.hashes] sha256 = "58a0c478eeca76fe5e07993c5a0703def34a6dc6a0cda4f5564639b33112ffe7" [[packages.wheels]] name = "cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_34_x86_64.whl" upload-time = 2026-09-30 14:45:05.009031+00:00 url = "https://files.pythonhosted.org/packages/24/95/83378121ef3eaaaf71d4b781577ff794acb39b9e1b87a3f156898c8497ed/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_34_x86_64.whl" size = 5000859 [packages.wheels.hashes] sha256 = "e105ab60406787da31fccc883fc0f733af1efd78f0136a4599692c4083a73d0c" [[packages.wheels]] name = "cryptography-50.0.2-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl" upload-time = 2026-09-30 15:29:26.807001+00:00 url = "https://files.pythonhosted.org/packages/bf/f1/1c1f6874e8550cfddd4b688ceb38cefb6ed15ceed224d56f133f3d88c214/cryptography-50.0.2-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl" size = 4749145 [packages.wheels.hashes] sha256 = "ee247f5c245c9a2fe7c8e2214e295918838e44e00a45a6718451e4004219e767" [[packages.wheels]] name = "cryptography-50.0.2-cp39-abi3-manylinux_2_28_x86_64.whl" upload-time = 2026-09-30 15:29:32.605292+00:00 url = "https://files.pythonhosted.org/packages/4f/87/ef344a9e616871f2519c22d6afcda79ddd5d35e9592d95eb6e677608d055/cryptography-50.0.2-cp39-abi3-manylinux_2_28_x86_64.whl" size = 4781387 [packages.wheels.hashes] sha256 = "f21e8a22c8605750c7af886bab299a363721264061b4ac0a30efb73cfd58efc5" [[packages.wheels]] name = "cryptography-50.0.2-cp39-abi3-manylinux_2_34_x86_64.whl" upload-time = 2026-09-30 15:29:41.251208+00:00 url = "https://files.pythonhosted.org/packages/40/cf/6b5c8e2fd9202d98988ab7cb5cc5c991704c4ad55f492ff408e4969f83f1/cryptography-50.0.2-cp39-abi3-manylinux_2_34_x86_64.whl" size = 4780973 [packages.wheels.hashes] sha256 = "1981f1db4630889b9ef7803fadef12b056f428cb6b85c27ba57b774793b6093c" [[packages]] name = "cython" version = "3.3.0" marker = "\"build\" in dependency_groups" requires-python = ">=3.9" index = "https://pypi.org/simple/" [packages.sdist] name = "cython-3.3.0.tar.gz" upload-time = 2026-08-22 05:16:39.493040+00:00 url = "https://files.pythonhosted.org/packages/a9/d8/4981ef716ad0e3ff0d3ef383aefc6b03c4a88dee33b272bf8e0d833001ca/cython-3.3.0.tar.gz" size = 3727515 [packages.sdist.hashes] sha256 = "eed0d93fbca7087f143b42c34b05a825849bdf17f101572c2105acfa49aa88b8" [[packages.wheels]] name = "cython-3.3.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl" upload-time = 2026-08-22 05:17:36.538454+00:00 url = "https://files.pythonhosted.org/packages/c9/aa/4c0b6773ecf6bc1ec6cda7db8312a566611b330fb6dae87d740e44a47822/cython-3.3.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl" size = 3454285 [packages.wheels.hashes] sha256 = "b447f6906e0555f05dc4742ef1f99091b1e5d9aa9f16616e772fbf9ff6271616" [[packages.wheels]] name = "cython-3.3.0-py3-none-any.whl" upload-time = 2026-08-22 05:16:36.741632+00:00 url = "https://files.pythonhosted.org/packages/bf/77/67b0b24e45073a699610e50f00c18474ff9b09ea29ecc95083bdf5e60acd/cython-3.3.0-py3-none-any.whl" size = 1349151 [packages.wheels.hashes] sha256 = "9b24b5c8cd536946b62086fcafee6d5509d3f549f72d553d2336af87ffbe0da1" [[packages]] name = "docutils" version = "0.23" marker = "\"docs\" in dependency_groups or \"pkg-meta\" in dependency_groups or \"release-news\" in dependency_groups or \"release-notes\" in dependency_groups or \"release-prepare\" in dependency_groups" requires-python = ">=3.9" index = "https://pypi.org/simple/" [packages.sdist] name = "docutils-0.23.tar.gz" upload-time = 2026-05-27 17:41:06.934109+00:00 url = "https://files.pythonhosted.org/packages/39/a4/5180d9afc57e8fca05601dd652bdff19604c218814037fe90ffc7625a50a/docutils-0.23.tar.gz" size = 2303823 [packages.sdist.hashes] sha256 = "746f5060322511280a1e50eb76846ed6bf2342984b2ac04dc42caa1a8d78799e" [[packages.wheels]] name = "docutils-0.23-py3-none-any.whl" upload-time = 2026-05-27 17:40:58.442528+00:00 url = "https://files.pythonhosted.org/packages/32/91/30151a39f7570f448ed84529390628a651d7f27c87d73c9b887f8189695e/docutils-0.23-py3-none-any.whl" size = 634701 [packages.wheels.hashes] sha256 = "25d013af9bf23bc1c7b2b093dff4208166c53a94786c9e447808335ef1185fea" [[packages]] name = "filelock" version = "4.2.4" marker = "\"project-runtime\" in dependency_groups" requires-python = ">=3.11" index = "https://pypi.org/simple/" [packages.sdist] name = "filelock-4.2.4.tar.gz" upload-time = 2026-10-19 01:57:51.001795+00:00 url = "https://files.pythonhosted.org/packages/d8/28/3893324bcadf8f89b1616637468d766af3f625008c0735bf704fdfa86617/filelock-4.2.4.tar.gz" size = 652444 [packages.sdist.hashes] sha256 = "c6d8663535e1650ee430507b4ffbac7b464dd64b7647ea0112a9f406499d163d" [[packages.wheels]] name = "filelock-4.2.4-py3-none-any.whl" upload-time = 2026-10-19 01:57:49.419460+00:00 url = "https://files.pythonhosted.org/packages/e0/1b/5e74289a1878e666bb4b65fe5805a04cafcac2aa5f519ef1ead67ef91c56/filelock-4.2.4-py3-none-any.whl" size = 162475 [packages.wheels.hashes] sha256 = "301241438c57936ef93f4243a94f2559022268bfcf9b375a9a72eb0f8db8358e" [[packages]] name = "furo" version = "2025.12.19" marker = "\"docs\" in dependency_groups" requires-python = ">=3.8" dependencies = [ { name = "accessible-pygments" }, { name = "beautifulsoup4" }, { name = "pygments" }, { name = "sphinx" }, { name = "sphinx-basic-ng" }, ] index = "https://pypi.org/simple/" [packages.sdist] name = "furo-2025.12.19.tar.gz" upload-time = 2025-12-19 17:34:40.889109+00:00 url = "https://files.pythonhosted.org/packages/ec/20/5f5ad4da6a5a27c80f2ed2ee9aee3f9e36c66e56e21c00fde467b2f8f88f/furo-2025.12.19.tar.gz" size = 1661473 [packages.sdist.hashes] sha256 = "188d1f942037d8b37cd3985b955839fea62baa1730087dc29d157677c857e2a7" [[packages.wheels]] name = "furo-2025.12.19-py3-none-any.whl" upload-time = 2025-12-19 17:34:38.905692+00:00 url = "https://files.pythonhosted.org/packages/f4/b2/50e9b292b5cac13e9e81272c7171301abc753a60460d21505b606e15cf21/furo-2025.12.19-py3-none-any.whl" size = 339262 [packages.wheels.hashes] sha256 = "bb0ead5309f9500130665a26bee87693c41ce4dbdff864dbfb6b0dae4673d24f" [[packages]] name = "hatch-fancy-pypi-readme" version = "25.1.0" marker = "\"build\" in dependency_groups" requires-python = ">=3.8" dependencies = [ { name = "hatchling" }, ] index = "https://pypi.org/simple/" [packages.sdist] name = "hatch_fancy_pypi_readme-25.1.0.tar.gz" upload-time = 2025-05-01 10:34:42.989334+00:00 url = "https://files.pythonhosted.org/packages/f3/0f/aed57c301f339936eb91cb4d8c1e5088a101081854bd3ec18a889df32365/hatch_fancy_pypi_readme-25.1.0.tar.gz" size = 29701 [packages.sdist.hashes] sha256 = "9c58ed3dff90d51f43414ce37009ad1d5b0f08ffc9fc216998a06380f01c0045" [[packages.wheels]] name = "hatch_fancy_pypi_readme-25.1.0-py3-none-any.whl" upload-time = 2025-05-01 10:34:41.204793+00:00 url = "https://files.pythonhosted.org/packages/58/b4/ad71e051b34f1935783de37beffee16e343ff2514b204dc515f9e939d4af/hatch_fancy_pypi_readme-25.1.0-py3-none-any.whl" size = 10046 [packages.wheels.hashes] sha256 = "ce0134c40d63d874ac48f48ccc678b8f3b62b8e50e9318520d2bffc752eedaf3" [[packages]] name = "hatch-vcs" version = "0.5.0" marker = "\"build\" in dependency_groups" requires-python = ">=3.9" dependencies = [ { name = "hatchling" }, { name = "setuptools-scm" }, ] index = "https://pypi.org/simple/" [packages.sdist] name = "hatch_vcs-0.5.0.tar.gz" upload-time = 2025-05-27 05:16:04.490926+00:00 url = "https://files.pythonhosted.org/packages/6b/b0/4cc743d38adbee9d57d786fa496ed1daadb17e48589b6da8fa55717a0746/hatch_vcs-0.5.0.tar.gz" size = 11424 [packages.sdist.hashes] sha256 = "0395fa126940340215090c344a2bf4e2a77bcbe7daab16f41b37b98c95809ff9" [[packages.wheels]] name = "hatch_vcs-0.5.0-py3-none-any.whl" upload-time = 2025-05-27 05:16:03.184909+00:00 url = "https://files.pythonhosted.org/packages/5f/48/1f85cee4b7b4f40b9b814b1febbc661bda6ced9649e410a0b74f6e415dd0/hatch_vcs-0.5.0-py3-none-any.whl" size = 8507 [packages.wheels.hashes] sha256 = "b49677dbdc597460cc22d01b27ab3696f5e16a21ecf2700fb01bc28e1f2a04a7" [[packages]] name = "hatchling" version = "1.32.4" marker = "\"build\" in dependency_groups" requires-python = ">=3.10" dependencies = [ { name = "packaging" }, { name = "pathspec" }, { name = "pluggy" }, { name = "tomlkit" }, { name = "trove-classifiers" }, ] index = "https://pypi.org/simple/" [packages.sdist] name = "hatchling-1.32.4.tar.gz" upload-time = 2026-09-20 22:48:45.398505+00:00 url = "https://files.pythonhosted.org/packages/f6/97/b5312f01a8c6daf729a9d272dd442e0c546dbcc630495788786c4b567ed0/hatchling-1.32.4.tar.gz" size = 58065 [packages.sdist.hashes] sha256 = "c4468f73144c054d2aab4ef0f0378c43b9878bf07f8ffd6b79690e970d375f07" [[packages.wheels]] name = "hatchling-1.32.4-py3-none-any.whl" upload-time = 2026-09-20 22:48:44.190681+00:00 url = "https://files.pythonhosted.org/packages/5f/80/91f51f439c05d4ec4623c22928ce16a938d6d793bf709477830823497859/hatchling-1.32.4-py3-none-any.whl" size = 78748 [packages.wheels.hashes] sha256 = "08ecf7548fb48205e7f213d70c71e67b8271b7242093dc3f1da578b42c734a2c" [[packages]] name = "id" version = "1.6.1" marker = "\"pkg-meta\" in dependency_groups" requires-python = ">=3.9" dependencies = [ { name = "urllib3" }, ] index = "https://pypi.org/simple/" [packages.sdist] name = "id-1.6.1.tar.gz" upload-time = 2026-02-04 16:19:41.260133+00:00 url = "https://files.pythonhosted.org/packages/6d/04/c2156091427636080787aac190019dc64096e56a23b7364d3c1764ee3a06/id-1.6.1.tar.gz" size = 18088 [packages.sdist.hashes] sha256 = "d0732d624fb46fd4e7bc4e5152f00214450953b9e772c182c1c22964def1a069" [[packages.wheels]] name = "id-1.6.1-py3-none-any.whl" upload-time = 2026-02-04 16:19:40.051745+00:00 url = "https://files.pythonhosted.org/packages/42/77/de194443bf38daed9452139e960c632b0ef9f9a5dd9ce605fdf18ca9f1b1/id-1.6.1-py3-none-any.whl" size = 14689 [packages.wheels.hashes] sha256 = "f5ec41ed2629a508f5d0988eda142e190c9c6da971100612c4de9ad9f9b237ca" [[packages]] name = "idna" version = "3.20" marker = "\"docs\" in dependency_groups or \"pkg-meta\" in dependency_groups" requires-python = ">=3.9" index = "https://pypi.org/simple/" [packages.sdist] name = "idna-3.20.tar.gz" upload-time = 2026-09-17 14:11:04.752193+00:00 url = "https://files.pythonhosted.org/packages/f5/08/8eea9d4b8302028f3abb2c0813953f7aec26d33b7a8960ed760e65ff29fa/idna-3.20.tar.gz" size = 216463 [packages.sdist.hashes] sha256 = "a7db850025b95ded1eae8a46181a1a6c56c92c96f0e2b005d9ff8dc0210cab44" [[packages.wheels]] name = "idna-3.20-py3-none-any.whl" upload-time = 2026-09-17 14:11:03.168153+00:00 url = "https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl" size = 69583 [packages.wheels.hashes] sha256 = "ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c" [[packages]] name = "imagesize" version = "2.0.1" marker = "\"docs\" in dependency_groups" requires-python = ">=3.10" index = "https://pypi.org/simple/" [packages.sdist] name = "imagesize-2.0.1.tar.gz" upload-time = 2026-08-24 12:35:19.130667+00:00 url = "https://files.pythonhosted.org/packages/fb/5e/513ff06670c84e7b9887c1fdf61b2d42b4f574a831f2f1d2222023049d8a/imagesize-2.0.1.tar.gz" size = 1883774 [packages.sdist.hashes] sha256 = "b2ba6a4dea487a7ebcd53248d3476aca449d30db12a2dde5e0c5ca9624fd77e5" [[packages.wheels]] name = "imagesize-2.0.1-py3-none-any.whl" upload-time = 2026-08-24 12:35:12.548956+00:00 url = "https://files.pythonhosted.org/packages/01/f9/575c8d760eae1fc99651b7cc5efd96ad5379ca4d6b53750b0fb4fe983f34/imagesize-2.0.1-py3-none-any.whl" size = 14794 [packages.wheels.hashes] sha256 = "ea0c9a0384df69ed86a943a15cde37d0360b82491b3910dc2215e202e62b5b02" [[packages]] name = "iniconfig" version = "2.3.1" marker = "\"test\" in dependency_groups" requires-python = ">=3.10" index = "https://pypi.org/simple/" [packages.sdist] name = "iniconfig-2.3.1.tar.gz" upload-time = 2026-10-06 22:48:38.076390+00:00 url = "https://files.pythonhosted.org/packages/01/e1/2069291243c926a2ff1cd706c7f3eeb9b62144bf60f77c9fb9ff2fb26bd3/iniconfig-2.3.1.tar.gz" size = 21209 [packages.sdist.hashes] sha256 = "67f4b9c50da0dedf52af349e7749a80a9057a5031199791b906c3bb3ae878960" [[packages.wheels]] name = "iniconfig-2.3.1-py3-none-any.whl" upload-time = 2026-10-06 22:48:36.959685+00:00 url = "https://files.pythonhosted.org/packages/56/43/4ca9e49d27a1fcf6bece6f6aec0ea46bb9112489b93d4b688fb415457bdb/iniconfig-2.3.1-py3-none-any.whl" size = 7552 [packages.wheels.hashes] sha256 = "9121e2c1fdb355232495be3194c8dfe87ccc2d5dee45947b78e68f499790d7a7" [[packages]] name = "jaraco-classes" version = "3.4.0" marker = "\"pkg-meta\" in dependency_groups" requires-python = ">=3.8" dependencies = [ { name = "more-itertools" }, ] index = "https://pypi.org/simple/" [packages.sdist] name = "jaraco.classes-3.4.0.tar.gz" upload-time = 2024-03-31 07:27:36.643708+00:00 url = "https://files.pythonhosted.org/packages/06/c0/ed4a27bc5571b99e3cff68f8a9fa5b56ff7df1c2251cc715a652ddd26402/jaraco.classes-3.4.0.tar.gz" size = 11780 [packages.sdist.hashes] sha256 = "47a024b51d0239c0dd8c8540c6c7f484be3b8fcf0b2d85c13825780d3b3f3acd" [[packages.wheels]] name = "jaraco.classes-3.4.0-py3-none-any.whl" upload-time = 2024-03-31 07:27:34.792832+00:00 url = "https://files.pythonhosted.org/packages/7f/66/b15ce62552d84bbfcec9a4873ab79d993a1dd4edb922cbfccae192bd5b5f/jaraco.classes-3.4.0-py3-none-any.whl" size = 6777 [packages.wheels.hashes] sha256 = "f662826b6bed8cace05e7ff873ce0f9283b5c924470fe664fff1c2f00f581790" [[packages]] name = "jaraco-context" version = "6.1.2" marker = "\"pkg-meta\" in dependency_groups" requires-python = ">=3.10" index = "https://pypi.org/simple/" [packages.sdist] name = "jaraco_context-6.1.2.tar.gz" upload-time = 2026-03-20 22:13:33.922545+00:00 url = "https://files.pythonhosted.org/packages/af/50/4763cd07e722bb6285316d390a164bc7e479db9d90daa769f22578f698b4/jaraco_context-6.1.2.tar.gz" size = 16801 [packages.sdist.hashes] sha256 = "f1a6c9d391e661cc5b8d39861ff077a7dc24dc23833ccee564b234b81c82dfe3" [[packages.wheels]] name = "jaraco_context-6.1.2-py3-none-any.whl" upload-time = 2026-03-20 22:13:32.808832+00:00 url = "https://files.pythonhosted.org/packages/f2/58/bc8954bda5fcda97bd7c19be11b85f91973d67a706ed4a3aec33e7de22db/jaraco_context-6.1.2-py3-none-any.whl" size = 7871 [packages.wheels.hashes] sha256 = "bf8150b79a2d5d91ae48629d8b427a8f7ba0e1097dd6202a9059f29a36379535" [[packages]] name = "jaraco-functools" version = "4.6.0" marker = "\"pkg-meta\" in dependency_groups" requires-python = ">=3.10" dependencies = [ { name = "more-itertools" }, ] index = "https://pypi.org/simple/" [packages.sdist] name = "jaraco_functools-4.6.0.tar.gz" upload-time = 2026-07-14 01:28:02.544761+00:00 url = "https://files.pythonhosted.org/packages/6c/1f/c23395957d41ccf27c4e535c3d334c4051e5395b3752057ba4cbaec35c56/jaraco_functools-4.6.0.tar.gz" size = 20837 [packages.sdist.hashes] sha256 = "880c577ec9720b3a052d5bc611fb9f2269b3d87902ef42440df443b88e443280" [[packages.wheels]] name = "jaraco_functools-4.6.0-py3-none-any.whl" upload-time = 2026-07-14 01:28:01.590470+00:00 url = "https://files.pythonhosted.org/packages/02/36/ecc85bc96c273dc8a11273ed4782272975e6338d4a3e9228621175edf0e3/jaraco_functools-4.6.0-py3-none-any.whl" size = 11677 [packages.wheels.hashes] sha256 = "99e3dc0060c5cbe8fcd1cdb36258e2a65ca40f1566b2033b12abb1bb44dd3c30" [[packages]] name = "jeepney" version = "0.9.0" marker = "\"pkg-meta\" in dependency_groups" requires-python = ">=3.7" index = "https://pypi.org/simple/" [packages.sdist] name = "jeepney-0.9.0.tar.gz" upload-time = 2025-02-27 18:51:01.684959+00:00 url = "https://files.pythonhosted.org/packages/7b/6f/357efd7602486741aa73ffc0617fb310a29b588ed0fd69c2399acbb85b0c/jeepney-0.9.0.tar.gz" size = 106758 [packages.sdist.hashes] sha256 = "cf0e9e845622b81e4a28df94c40345400256ec608d0e55bb8a3feaa9163f5732" [[packages.wheels]] name = "jeepney-0.9.0-py3-none-any.whl" upload-time = 2025-02-27 18:51:00.104279+00:00 url = "https://files.pythonhosted.org/packages/b2/a3/e137168c9c44d18eff0376253da9f1e9234d0239e0ee230d2fee6cea8e55/jeepney-0.9.0-py3-none-any.whl" size = 49010 [packages.wheels.hashes] sha256 = "97e5714520c16fc0a45695e5365a2e11b81ea79bba796e26f9f1d178cb182683" [[packages]] name = "jinja2" version = "3.1.6" marker = "\"docs\" in dependency_groups or \"release-news\" in dependency_groups or \"release-notes\" in dependency_groups or \"release-prepare\" in dependency_groups" requires-python = ">=3.7" dependencies = [ { name = "markupsafe" }, ] index = "https://pypi.org/simple/" [packages.sdist] name = "jinja2-3.1.6.tar.gz" upload-time = 2025-03-05 20:05:02.478302+00:00 url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz" size = 245115 [packages.sdist.hashes] sha256 = "0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d" [[packages.wheels]] name = "jinja2-3.1.6-py3-none-any.whl" upload-time = 2025-03-05 20:05:00.369721+00:00 url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl" size = 134899 [packages.wheels.hashes] sha256 = "85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67" [[packages]] name = "keyring" version = "25.7.0" marker = "\"pkg-meta\" in dependency_groups" requires-python = ">=3.9" dependencies = [ { name = "jaraco-classes" }, { name = "jaraco-context" }, { name = "jaraco-functools" }, { name = "jeepney" }, { name = "secretstorage" }, ] index = "https://pypi.org/simple/" [packages.sdist] name = "keyring-25.7.0.tar.gz" upload-time = 2025-11-16 16:26:09.482176+00:00 url = "https://files.pythonhosted.org/packages/43/4b/674af6ef2f97d56f0ab5153bf0bfa28ccb6c3ed4d1babf4305449668807b/keyring-25.7.0.tar.gz" size = 63516 [packages.sdist.hashes] sha256 = "fe01bd85eb3f8fb3dd0405defdeac9a5b4f6f0439edbb3149577f244a2e8245b" [[packages.wheels]] name = "keyring-25.7.0-py3-none-any.whl" upload-time = 2025-11-16 16:26:08.402146+00:00 url = "https://files.pythonhosted.org/packages/81/db/e655086b7f3a705df045bf0933bdd9c2f79bb3c97bfef1384598bb79a217/keyring-25.7.0-py3-none-any.whl" size = 39160 [packages.wheels.hashes] sha256 = "be4a0b195f149690c166e850609a477c532ddbfbaed96a404d4e43f8d5e2689f" [[packages]] name = "markdown-it-py" version = "4.2.0" marker = "\"pkg-meta\" in dependency_groups or \"release-news\" in dependency_groups or \"release-notes\" in dependency_groups or \"release-prepare\" in dependency_groups" requires-python = ">=3.10" dependencies = [ { name = "mdurl" }, ] index = "https://pypi.org/simple/" [packages.sdist] name = "markdown_it_py-4.2.0.tar.gz" upload-time = 2026-05-07 12:08:28.360612+00:00 url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz" size = 82454 [packages.sdist.hashes] sha256 = "04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49" [[packages.wheels]] name = "markdown_it_py-4.2.0-py3-none-any.whl" upload-time = 2026-05-07 12:08:27.182789+00:00 url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl" size = 91687 [packages.wheels.hashes] sha256 = "9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a" [[packages]] name = "markupsafe" version = "3.0.4" marker = "\"docs\" in dependency_groups or \"release-news\" in dependency_groups or \"release-notes\" in dependency_groups or \"release-prepare\" in dependency_groups" requires-python = ">=3.9" index = "https://pypi.org/simple/" [packages.sdist] name = "markupsafe-3.0.4.tar.gz" upload-time = 2026-10-02 23:07:22.290270+00:00 url = "https://files.pythonhosted.org/packages/38/9b/e422a865e1d5d57d0e509b4e0bf1c1a70a7f6382c29a5aa428df994c8bc8/markupsafe-3.0.4.tar.gz" size = 153777 [packages.sdist.hashes] sha256 = "2e9ad7dd851bf45fab9f75cbff4cb493fee9979e8d8c7c9c3ee119022518edd6" [[packages.wheels]] name = "markupsafe-3.0.4-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl" upload-time = 2026-10-02 23:06:22.524416+00:00 url = "https://files.pythonhosted.org/packages/82/96/ef49135ce260db4ca4a12b119ed468449cd248db6b1468e2112b546d7a2e/markupsafe-3.0.4-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl" size = 23111 [packages.wheels.hashes] sha256 = "925f929d6b59a8b3f8b8c6ac363cd0af7eecc81efb3071770b3c6717c450a369" [[packages]] name = "mdformat" version = "1.0.0" marker = "\"release-news\" in dependency_groups or \"release-notes\" in dependency_groups or \"release-prepare\" in dependency_groups" requires-python = ">=3.10" dependencies = [ { name = "markdown-it-py" }, ] index = "https://pypi.org/simple/" [packages.sdist] name = "mdformat-1.0.0.tar.gz" upload-time = 2025-10-16 12:05:03.695033+00:00 url = "https://files.pythonhosted.org/packages/3f/05/32b5e14b192b0a8a309f32232c580aefedd9d06017cb8fe8fce34bec654c/mdformat-1.0.0.tar.gz" size = 56953 [packages.sdist.hashes] sha256 = "4954045fcae797c29f86d4ad879e43bb151fa55dbaf74ac6eaeacf1d45bb3928" [[packages.wheels]] name = "mdformat-1.0.0-py3-none-any.whl" upload-time = 2025-10-16 12:05:02.607432+00:00 url = "https://files.pythonhosted.org/packages/54/9a/8fe71b95985ca7a4001effbcc58e5a07a1f2a2884203f74dcf48a3b08315/mdformat-1.0.0-py3-none-any.whl" size = 53288 [packages.wheels.hashes] sha256 = "bca015d65a1d063a02e885a91daee303057bc7829c2cd37b2075a50dbb65944b" [[packages]] name = "mdformat-gfm" version = "1.0.0" marker = "\"release-news\" in dependency_groups or \"release-notes\" in dependency_groups or \"release-prepare\" in dependency_groups" requires-python = ">=3.10" dependencies = [ { name = "markdown-it-py" }, { name = "mdformat" }, { name = "mdit-py-plugins" }, { name = "wcwidth" }, ] index = "https://pypi.org/simple/" [packages.sdist] name = "mdformat_gfm-1.0.0.tar.gz" upload-time = 2025-10-16 09:12:22.402429+00:00 url = "https://files.pythonhosted.org/packages/56/6f/a626ebb142a290474401b67e2d61e73ce096bf7798ee22dfe6270f924b3f/mdformat_gfm-1.0.0.tar.gz" size = 10112 [packages.sdist.hashes] sha256 = "d1d49a409a6acb774ce7635c72d69178df7dce1dc8cdd10e19f78e8e57b72623" [[packages.wheels]] name = "mdformat_gfm-1.0.0-py3-none-any.whl" upload-time = 2025-10-16 09:12:21.276157+00:00 url = "https://files.pythonhosted.org/packages/e6/18/6bc2189b744dd383cad03764f41f30352b1278d2205096f77a29c0b327ad/mdformat_gfm-1.0.0-py3-none-any.whl" size = 10970 [packages.wheels.hashes] sha256 = "7305a50efd2a140d7c83505b58e3ac5df2b09e293f9bbe72f6c7bee8c678b005" [[packages]] name = "mdit-py-plugins" version = "0.6.1" marker = "\"release-news\" in dependency_groups or \"release-notes\" in dependency_groups or \"release-prepare\" in dependency_groups" requires-python = ">=3.10" dependencies = [ { name = "markdown-it-py" }, ] index = "https://pypi.org/simple/" [packages.sdist] name = "mdit_py_plugins-0.6.1.tar.gz" upload-time = 2026-05-13 09:03:38.910230+00:00 url = "https://files.pythonhosted.org/packages/59/fc/f8d0863f8862f25602c0404d75568e89fb6b4109804645e5cdfb1be5cf56/mdit_py_plugins-0.6.1.tar.gz" size = 56114 [packages.sdist.hashes] sha256 = "a2bca0f039f39dbd35fb74ae1b5f998608c437463371f0ff7f49a19a17a114d0" [[packages.wheels]] name = "mdit_py_plugins-0.6.1-py3-none-any.whl" upload-time = 2026-05-13 09:03:37.760000+00:00 url = "https://files.pythonhosted.org/packages/a5/69/6da5581c6a7fede7dc261bf4e67d6adca4196f176b43288b55b3db395b6e/mdit_py_plugins-0.6.1-py3-none-any.whl" size = 66663 [packages.wheels.hashes] sha256 = "214c82fb2ac524472ab6a5bcab1de80f73b50443e187f401bfd77efbc7c6481d" [[packages]] name = "mdurl" version = "0.1.2" marker = "\"pkg-meta\" in dependency_groups or \"release-news\" in dependency_groups or \"release-notes\" in dependency_groups or \"release-prepare\" in dependency_groups" requires-python = ">=3.7" index = "https://pypi.org/simple/" [packages.sdist] name = "mdurl-0.1.2.tar.gz" upload-time = 2022-08-14 12:40:10.846281+00:00 url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz" size = 8729 [packages.sdist.hashes] sha256 = "bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba" [[packages.wheels]] name = "mdurl-0.1.2-py3-none-any.whl" upload-time = 2022-08-14 12:40:09.779676+00:00 url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl" size = 9979 [packages.wheels.hashes] sha256 = "84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8" [[packages]] name = "more-itertools" version = "11.2.1" marker = "\"pkg-meta\" in dependency_groups" requires-python = ">=3.11" index = "https://pypi.org/simple/" [packages.sdist] name = "more_itertools-11.2.1.tar.gz" upload-time = 2026-10-14 18:50:30.407709+00:00 url = "https://files.pythonhosted.org/packages/52/b8/c63995bebbb9ac9c059350e0fb8c82c2420c85aeca7d34e40aecf3df9281/more_itertools-11.2.1.tar.gz" size = 154133 [packages.sdist.hashes] sha256 = "cbf08fd0af284dc69718b9b76a8e6203df624d70209ea511e4219d350c856f63" [[packages.wheels]] name = "more_itertools-11.2.1-py3-none-any.whl" upload-time = 2026-10-14 18:50:29.116732+00:00 url = "https://files.pythonhosted.org/packages/5d/57/3c4b1c6f2e0cc29ba023802458ea3d2a996f87085559e4a5f2192d549532/more_itertools-11.2.1-py3-none-any.whl" size = 74405 [packages.wheels.hashes] sha256 = "35a7377edd1dd6608dcb2cdf534ded55ea32d49448875ad042cd3f879fb1ded0" [[packages]] name = "nh3" version = "0.3.7" marker = "\"pkg-meta\" in dependency_groups" requires-python = ">=3.8" index = "https://pypi.org/simple/" [packages.sdist] name = "nh3-0.3.7.tar.gz" upload-time = 2026-08-23 14:26:30.728660+00:00 url = "https://files.pythonhosted.org/packages/18/2f/022b27146d52d24b1b353b003359134788ecbcd6fcdf6283adbd57c0fbc8/nh3-0.3.7.tar.gz" size = 25662 [packages.sdist.hashes] sha256 = "71860d01c16f4d8c72e334e0674beb2b0899dbd0bf760de18932ef4390303848" [[packages.wheels]] name = "nh3-0.3.7-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl" upload-time = 2026-08-23 14:26:17.272038+00:00 url = "https://files.pythonhosted.org/packages/a6/ed/c5510c615dce55b6fcc364aa1838142f938beed64f5e4927490dfcaf4405/nh3-0.3.7-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl" size = 832161 [packages.wheels.hashes] sha256 = "70f5ac8626e899a4bab0ef74ca2f5bd602f49c7b739e6e5026b4afc6d63dac42" [[packages]] name = "packaging" version = "26.3" marker = "\"build\" in dependency_groups or \"docs\" in dependency_groups or \"pkg-meta\" in dependency_groups or \"release-news\" in dependency_groups or \"release-notes\" in dependency_groups or \"release-prepare\" in dependency_groups or \"test\" in dependency_groups" requires-python = ">=3.9" index = "https://pypi.org/simple/" [packages.sdist] name = "packaging-26.3.tar.gz" upload-time = 2026-08-04 18:15:28.737071+00:00 url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz" size = 313412 [packages.sdist.hashes] sha256 = "94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79" [[packages.wheels]] name = "packaging-26.3-py3-none-any.whl" upload-time = 2026-08-04 18:15:27.159957+00:00 url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl" size = 129956 [packages.wheels.hashes] sha256 = "d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c" [[packages]] name = "pathspec" version = "1.1.1" marker = "\"build\" in dependency_groups" requires-python = ">=3.9" index = "https://pypi.org/simple/" [packages.sdist] name = "pathspec-1.1.1.tar.gz" upload-time = 2026-04-27 01:46:08.907631+00:00 url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz" size = 135180 [packages.sdist.hashes] sha256 = "17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a" [[packages.wheels]] name = "pathspec-1.1.1-py3-none-any.whl" upload-time = 2026-04-27 01:46:07.060850+00:00 url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl" size = 57328 [packages.wheels.hashes] sha256 = "a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189" [[packages]] name = "pluggy" version = "1.7.0" marker = "\"build\" in dependency_groups or \"test\" in dependency_groups" requires-python = ">=3.10" index = "https://pypi.org/simple/" [packages.sdist] name = "pluggy-1.7.0.tar.gz" upload-time = 2026-10-15 09:50:58.343484+00:00 url = "https://files.pythonhosted.org/packages/bf/db/7fc19e6f2dc92a966727031389fc2e08b558f0f25eb7403c1119ad4713cd/pluggy-1.7.0.tar.gz" size = 123304 [packages.sdist.hashes] sha256 = "d1eaa46ebb595891b860ab086b4d09c8588af65ebd4361b8e8f4bb8920b90ba8" [[packages.wheels]] name = "pluggy-1.7.0-py3-none-any.whl" upload-time = 2026-10-15 09:50:56.808462+00:00 url = "https://files.pythonhosted.org/packages/40/9e/2b38731e0fc536806f16490e1a12d7f0dc2a1235aa8cc07bcc75416a7daa/pluggy-1.7.0-py3-none-any.whl" size = 27082 [packages.wheels.hashes] sha256 = "7dd7b0d8832ba3cb632c306926ded123429211b83641b35dc5c41ad2d34f9bec" [[packages]] name = "prek" version = "0.5.7" marker = "\"release-prepare\" in dependency_groups" requires-python = ">=3.8" index = "https://pypi.org/simple/" [packages.sdist] name = "prek-0.5.7.tar.gz" upload-time = 2026-10-21 00:46:48.231166+00:00 url = "https://files.pythonhosted.org/packages/e0/0c/985c6f0fe1671e639fb97bf5a60f3945369f26d4ccf5a8a1b063d357e6da/prek-0.5.7.tar.gz" size = 580753 [packages.sdist.hashes] sha256 = "91049700f0f13b4575deb44045dd6dd51f7c4a70c53ec5d452b09925962e3b35" [[packages.wheels]] name = "prek-0.5.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl" upload-time = 2026-10-21 00:46:39.290591+00:00 url = "https://files.pythonhosted.org/packages/4b/15/c83456872211706839ff326474aa9f2d870eef7ee5ec2c436446519779d2/prek-0.5.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl" size = 6140221 [packages.wheels.hashes] sha256 = "c139c494fb1d9826688f8107d1514fa2a58f8f4a0f0527b68e7970fa50a8b723" [[packages]] name = "pycparser" version = "3.12" marker = "\"pkg-meta\" in dependency_groups" requires-python = ">=3.11" index = "https://pypi.org/simple/" [packages.sdist] name = "pycparser-3.12.tar.gz" upload-time = 2026-10-22 13:40:03.560254+00:00 url = "https://files.pythonhosted.org/packages/cb/27/eeb9f307bf07060f3d9f9aa0fb8c15b01bdfded0a941e4b2719c7f94cacd/pycparser-3.12.tar.gz" size = 158785 [packages.sdist.hashes] sha256 = "f03387877eab82ce01bbdfd0ba7252ee55a90b1c708d5129e2ce915475cf1db6" [[packages.wheels]] name = "pycparser-3.12-py3-none-any.whl" upload-time = 2026-10-22 13:40:02.021539+00:00 url = "https://files.pythonhosted.org/packages/b6/ae/315bbeee126a769cbde43cf4908acb4b40408d8bf8bbf334dd7368d8dadf/pycparser-3.12-py3-none-any.whl" size = 62600 [packages.wheels.hashes] sha256 = "a076f3f489b39b43ed13563a1dafe42447445bec2e294154ec5c57fcdda4c85e" [[packages]] name = "pydantic" version = "2.14.1" marker = "\"pkg-meta\" in dependency_groups" requires-python = ">=3.10" dependencies = [ { name = "annotated-types" }, { name = "pydantic-core" }, { name = "typing-extensions" }, { name = "typing-inspection" }, ] index = "https://pypi.org/simple/" [packages.sdist] name = "pydantic-2.14.1.tar.gz" upload-time = 2026-10-11 18:37:55.396662+00:00 url = "https://files.pythonhosted.org/packages/7c/0b/8e10b2e693af8ec54346a14caf36221334775975a747c9ceaa3f8371d96d/pydantic-2.14.1.tar.gz" size = 900362 [packages.sdist.hashes] sha256 = "94f478203dd03404682a1ada216965651dd74b1d2d5ffd62e00e0837caab5c26" [[packages.wheels]] name = "pydantic-2.14.1-py3-none-any.whl" upload-time = 2026-10-11 18:37:53.437059+00:00 url = "https://files.pythonhosted.org/packages/35/ea/a56b9fe5066f3537b7882f77e9c5dfb26d8c2eefdaed9b5fc73d57b4dc22/pydantic-2.14.1-py3-none-any.whl" size = 488106 [packages.wheels.hashes] sha256 = "9195d967ec791692a04438115466764fb8b9a27b31f14a760437694f40d6b454" [[packages]] name = "pydantic-core" version = "2.50.1" marker = "\"pkg-meta\" in dependency_groups" requires-python = ">=3.10" dependencies = [ { name = "typing-extensions" }, ] index = "https://pypi.org/simple/" [packages.sdist] name = "pydantic_core-2.50.1.tar.gz" upload-time = 2026-10-11 18:35:44.820993+00:00 url = "https://files.pythonhosted.org/packages/a6/24/af4ec4be49fbc810f35b0bdcacc3d433b56bbfa469fd3bfd4ae116cd9bf1/pydantic_core-2.50.1.tar.gz" size = 513515 [packages.sdist.hashes] sha256 = "e50d7b94baac6c7d09927fa5ca5800a0c7ee5015c7fcff65beb3a1931b5a6e09" [[packages.wheels]] name = "pydantic_core-2.50.1-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl" upload-time = 2026-10-11 18:34:15.560294+00:00 url = "https://files.pythonhosted.org/packages/b8/0d/1b1149f60a00ea21ba5f70e28acbd40feb4598af414f80f53c921fac07c9/pydantic_core-2.50.1-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl" size = 2159961 [packages.wheels.hashes] sha256 = "7689580e72a642ab5ec64d5f55b2e33636fa43b4ebe63c0c2c965ef307c7d1aa" [[packages]] name = "pygments" version = "2.21.0" marker = "\"docs\" in dependency_groups or \"pkg-meta\" in dependency_groups or \"test\" in dependency_groups" requires-python = ">=3.9" index = "https://pypi.org/simple/" [packages.sdist] name = "pygments-2.21.0.tar.gz" upload-time = 2026-08-17 08:02:48.824391+00:00 url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz" size = 5005329 [packages.sdist.hashes] sha256 = "610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c" [[packages.wheels]] name = "pygments-2.21.0-py3-none-any.whl" upload-time = 2026-08-17 08:02:44.912148+00:00 url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl" size = 1250147 [packages.wheels.hashes] sha256 = "2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9" [[packages]] name = "pytest" version = "9.1.1" marker = "\"test\" in dependency_groups" requires-python = ">=3.10" dependencies = [ { name = "iniconfig" }, { name = "packaging" }, { name = "pluggy" }, { name = "pygments" }, ] index = "https://pypi.org/simple/" [packages.sdist] name = "pytest-9.1.1.tar.gz" upload-time = 2026-06-19 10:58:32.857420+00:00 url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz" size = 1636369 [packages.sdist.hashes] sha256 = "1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313" [[packages.wheels]] name = "pytest-9.1.1-py3-none-any.whl" upload-time = 2026-06-19 10:58:31.347074+00:00 url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl" size = 386536 [packages.wheels.hashes] sha256 = "37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c" [[packages]] name = "pytest-mock" version = "3.17.0" marker = "\"test\" in dependency_groups" requires-python = ">=3.10" dependencies = [ { name = "pytest" }, ] index = "https://pypi.org/simple/" [packages.sdist] name = "pytest_mock-3.17.0.tar.gz" upload-time = 2026-10-17 16:21:42.435701+00:00 url = "https://files.pythonhosted.org/packages/97/c2/28b9f5b955ff2bcd4727a6f768e2ca9b586cd7c37b95ea5f7b9b873ac7b3/pytest_mock-3.17.0.tar.gz" size = 43064 [packages.sdist.hashes] sha256 = "e36576e0985e01027e999891e7a966c67822d40c89ef1b4c8b6e22737e3590b4" [[packages.wheels]] name = "pytest_mock-3.17.0-py3-none-any.whl" upload-time = 2026-10-17 16:21:41.265482+00:00 url = "https://files.pythonhosted.org/packages/e8/ea/47fbdc8f37410956fd445720e927589b1e2a47772c5f28109f0ce2fa26d9/pytest_mock-3.17.0-py3-none-any.whl" size = 11248 [packages.wheels.hashes] sha256 = "1b82aa4b37137248bd687055e109723debeb07b489de1565297dc9589c5f2d6b" [[packages]] name = "pyyaml" version = "6.0.3" marker = "\"docs\" in dependency_groups" requires-python = ">=3.8" index = "https://pypi.org/simple/" [packages.sdist] name = "pyyaml-6.0.3.tar.gz" upload-time = 2025-09-25 21:33:16.546886+00:00 url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz" size = 130960 [packages.sdist.hashes] sha256 = "d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f" [[packages]] name = "readme-renderer" version = "46.0" marker = "\"pkg-meta\" in dependency_groups" requires-python = ">=3.10" dependencies = [ { name = "docutils" }, { name = "nh3" }, { name = "pygments" }, ] index = "https://pypi.org/simple/" [packages.sdist] name = "readme_renderer-46.0.tar.gz" upload-time = 2026-08-28 15:18:32.490778+00:00 url = "https://files.pythonhosted.org/packages/25/d7/9309494fad74ee831d4546f69325b5519f37c6dfb2d9ba495db8c6d4f4ca/readme_renderer-46.0.tar.gz" size = 38382 [packages.sdist.hashes] sha256 = "af3e964914f6310a33ff67b72a4bdd940bed8d7c3bdecd2d14f40edf284bfe90" [[packages.wheels]] name = "readme_renderer-46.0-py3-none-any.whl" upload-time = 2026-08-28 15:18:31.132970+00:00 url = "https://files.pythonhosted.org/packages/4b/72/ac5ca81fe9121fcaa9d828d21017cba00a16a98e4ea5fb60c878f93dda4f/readme_renderer-46.0-py3-none-any.whl" size = 14239 [packages.wheels.hashes] sha256 = "d0dae1f74bb273b534770cb4cccb6bb78735540afdb03c2146f4e19dcd412560" [[packages]] name = "release-creator" version = "4.4.0" marker = "\"release-news\" in dependency_groups or \"release-notes\" in dependency_groups or \"release-prepare\" in dependency_groups" requires-python = ">=3.15" dependencies = [ { name = "docutils" }, { name = "markdown-it-py" }, { name = "mdformat" }, { name = "mdformat-gfm" }, { name = "packaging" }, { name = "towncrier" }, ] index = "https://pypi.org/simple/" [packages.sdist] name = "release_creator-4.4.0.tar.gz" upload-time = 2026-11-02 21:44:48.475063+00:00 url = "https://files.pythonhosted.org/packages/d8/cf/8f376cc925c26fa5434d327ba03f4ed709b80d2077edda865fc3b2fe1940/release_creator-4.4.0.tar.gz" size = 171450 [packages.sdist.hashes] sha256 = "263d518ddee6371fa13d25bd07f7b5af0fa7a6e421b16f1e8143b0a9443bc84d" [[packages.wheels]] name = "release_creator-4.4.0-py3-none-any.whl" upload-time = 2026-11-02 21:44:47.195366+00:00 url = "https://files.pythonhosted.org/packages/7b/29/6f2a3158b082b3bf8a94eb93662d80b4db4212abea0fc1fae07ac9a043da/release_creator-4.4.0-py3-none-any.whl" size = 54784 [packages.wheels.hashes] sha256 = "560ed208cca0dc53bc227878e5596e3e26e44fa277658c5d0dc662901d95f8cb" [[packages]] name = "requests" version = "2.34.2" marker = "\"docs\" in dependency_groups or \"pkg-meta\" in dependency_groups" requires-python = ">=3.10" dependencies = [ { name = "certifi" }, { name = "charset-normalizer" }, { name = "idna" }, { name = "urllib3" }, ] index = "https://pypi.org/simple/" [packages.sdist] name = "requests-2.34.2.tar.gz" upload-time = 2026-05-14 19:25:27.735762+00:00 url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz" size = 142856 [packages.sdist.hashes] sha256 = "f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed" [[packages.wheels]] name = "requests-2.34.2-py3-none-any.whl" upload-time = 2026-05-14 19:25:26.443000+00:00 url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl" size = 73075 [packages.wheels.hashes] sha256 = "2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0" [[packages]] name = "requests-toolbelt" version = "1.0.0" marker = "\"pkg-meta\" in dependency_groups" requires-python = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,>=2.7" dependencies = [ { name = "requests" }, ] index = "https://pypi.org/simple/" [packages.sdist] name = "requests-toolbelt-1.0.0.tar.gz" upload-time = 2023-05-01 04:11:33.229998+00:00 url = "https://files.pythonhosted.org/packages/f3/61/d7545dafb7ac2230c70d38d31cbfe4cc64f7144dc41f6e4e4b78ecd9f5bb/requests-toolbelt-1.0.0.tar.gz" size = 206888 [packages.sdist.hashes] sha256 = "7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6" [[packages.wheels]] name = "requests_toolbelt-1.0.0-py2.py3-none-any.whl" upload-time = 2023-05-01 04:11:28.427086+00:00 url = "https://files.pythonhosted.org/packages/3f/51/d4db610ef29373b879047326cbf6fa98b6c1969d6f6dc423279de2b1be2c/requests_toolbelt-1.0.0-py2.py3-none-any.whl" size = 54481 [packages.wheels.hashes] sha256 = "cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06" [[packages]] name = "rfc3986" version = "2.0.0" marker = "\"pkg-meta\" in dependency_groups" requires-python = ">=3.7" index = "https://pypi.org/simple/" [packages.sdist] name = "rfc3986-2.0.0.tar.gz" upload-time = 2022-01-10 00:52:30.832978+00:00 url = "https://files.pythonhosted.org/packages/85/40/1520d68bfa07ab5a6f065a186815fb6610c86fe957bc065754e47f7b0840/rfc3986-2.0.0.tar.gz" size = 49026 [packages.sdist.hashes] sha256 = "97aacf9dbd4bfd829baad6e6309fa6573aaf1be3f6fa735c8ab05e46cecb261c" [[packages.wheels]] name = "rfc3986-2.0.0-py2.py3-none-any.whl" upload-time = 2022-01-10 00:52:29.594625+00:00 url = "https://files.pythonhosted.org/packages/ff/9a/9afaade874b2fa6c752c36f1548f718b5b83af81ed9b76628329dab81c1b/rfc3986-2.0.0-py2.py3-none-any.whl" size = 31326 [packages.wheels.hashes] sha256 = "50b1502b60e289cb37883f3dfd34532b8873c7de9f49bb546641ce9cbd256ebd" [[packages]] name = "rich" version = "15.0.0" marker = "\"pkg-meta\" in dependency_groups" requires-python = ">=3.9.0" dependencies = [ { name = "markdown-it-py" }, { name = "pygments" }, ] index = "https://pypi.org/simple/" [packages.sdist] name = "rich-15.0.0.tar.gz" upload-time = 2026-04-12 08:24:00.750018+00:00 url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz" size = 230680 [packages.sdist.hashes] sha256 = "edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36" [[packages.wheels]] name = "rich-15.0.0-py3-none-any.whl" upload-time = 2026-04-12 08:24:02.830783+00:00 url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl" size = 310654 [packages.wheels.hashes] sha256 = "33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb" [[packages]] name = "roman-numerals" version = "4.1.0" marker = "\"docs\" in dependency_groups" requires-python = ">=3.10" index = "https://pypi.org/simple/" [packages.sdist] name = "roman_numerals-4.1.0.tar.gz" upload-time = 2025-12-17 18:25:34.381818+00:00 url = "https://files.pythonhosted.org/packages/ae/f9/41dc953bbeb056c17d5f7a519f50fdf010bd0553be2d630bc69d1e022703/roman_numerals-4.1.0.tar.gz" size = 9077 [packages.sdist.hashes] sha256 = "1af8b147eb1405d5839e78aeb93131690495fe9da5c91856cb33ad55a7f1e5b2" [[packages.wheels]] name = "roman_numerals-4.1.0-py3-none-any.whl" upload-time = 2025-12-17 18:25:33.098993+00:00 url = "https://files.pythonhosted.org/packages/04/54/6f679c435d28e0a568d8e8a7c0a93a09010818634c3c3907fc98d8983770/roman_numerals-4.1.0-py3-none-any.whl" size = 7676 [packages.wheels.hashes] sha256 = "647ba99caddc2cc1e55a51e4360689115551bf4476d90e8162cf8c345fe233c7" [[packages]] name = "secretstorage" version = "3.5.0" marker = "\"pkg-meta\" in dependency_groups" requires-python = ">=3.10" dependencies = [ { name = "cryptography" }, { name = "jeepney" }, ] index = "https://pypi.org/simple/" [packages.sdist] name = "secretstorage-3.5.0.tar.gz" upload-time = 2025-11-23 19:02:53.191898+00:00 url = "https://files.pythonhosted.org/packages/1c/03/e834bcd866f2f8a49a85eaff47340affa3bfa391ee9912a952a1faa68c7b/secretstorage-3.5.0.tar.gz" size = 19884 [packages.sdist.hashes] sha256 = "f04b8e4689cbce351744d5537bf6b1329c6fc68f91fa666f60a380edddcd11be" [[packages.wheels]] name = "secretstorage-3.5.0-py3-none-any.whl" upload-time = 2025-11-23 19:02:51.545472+00:00 url = "https://files.pythonhosted.org/packages/b7/46/f5af3402b579fd5e11573ce652019a67074317e18c1935cc0b4ba9b35552/secretstorage-3.5.0-py3-none-any.whl" size = 15554 [packages.wheels.hashes] sha256 = "0ce65888c0725fcb2c5bc0fdb8e5438eece02c523557ea40ce0703c266248137" [[packages]] name = "setuptools" version = "84.2.1" marker = "\"build\" in dependency_groups or \"test\" in dependency_groups" requires-python = ">=3.10" index = "https://pypi.org/simple/" [packages.sdist] name = "setuptools-84.2.1.tar.gz" upload-time = 2026-10-20 19:01:37.742699+00:00 url = "https://files.pythonhosted.org/packages/70/b3/c19426f783614d625699a4b247faf628ea9ab1db228674491767b417f9e1/setuptools-84.2.1.tar.gz" size = 1230289 [packages.sdist.hashes] sha256 = "3ff8b3b5492a0aa66960f4419365a86b84e013070d4313e4d4a6894f5d64d6bc" [[packages.wheels]] name = "setuptools-84.2.1-py3-none-any.whl" upload-time = 2026-10-20 19:01:35.678013+00:00 url = "https://files.pythonhosted.org/packages/ef/55/6dc6d6bb953bdfc52519a0059592c52b6e3b8ad51cf8256dffceb55b0610/setuptools-84.2.1-py3-none-any.whl" size = 876263 [packages.wheels.hashes] sha256 = "ca140b45d5325a9478f880e351484f62fef15f336462e449ca0c03c873d589c2" [[packages]] name = "setuptools-scm" version = "10.3.4" marker = "\"build\" in dependency_groups" requires-python = ">=3.8" dependencies = [ { name = "packaging" }, { name = "setuptools" }, { name = "vcs-versioning" }, ] index = "https://pypi.org/simple/" [packages.sdist] name = "setuptools_scm-10.3.4.tar.gz" upload-time = 2026-09-23 19:21:42.950372+00:00 url = "https://files.pythonhosted.org/packages/85/d8/fc143f88819ccf10ba2388ba86732ee2de193e578234e25a783f6cc14bf7/setuptools_scm-10.3.4.tar.gz" size = 78410 [packages.sdist.hashes] sha256 = "a69f28bfc245608781205e912faae437c2b2165773afa4e7b979d77447a69dd2" [[packages.wheels]] name = "setuptools_scm-10.3.4-py3-none-any.whl" upload-time = 2026-09-23 19:21:41.607429+00:00 url = "https://files.pythonhosted.org/packages/da/f5/54538a1f17ea753c42b0928dc2e62986d463f8c393340b9a1e602f2d4dd9/setuptools_scm-10.3.4-py3-none-any.whl" size = 33366 [packages.wheels.hashes] sha256 = "82f34c3e3084fc2b57d397200637cc13f2338004052d2fee47baa5f0e902464e" [[packages]] name = "snowballstemmer" version = "3.1.1" marker = "\"docs\" in dependency_groups" requires-python = ">=3.3" index = "https://pypi.org/simple/" [packages.sdist] name = "snowballstemmer-3.1.1.tar.gz" upload-time = 2026-06-03 00:56:40.194915+00:00 url = "https://files.pythonhosted.org/packages/43/f8/0a71edf031f03c40db17503cb8ca78a69a171254e568e7db241b0ab57ea1/snowballstemmer-3.1.1.tar.gz" size = 123314 [packages.sdist.hashes] sha256 = "e07bbc54a0d798fe6010a12398422e62a8bfbba95c394fd0956ef58cb4d3e260" [[packages.wheels]] name = "snowballstemmer-3.1.1-py3-none-any.whl" upload-time = 2026-06-03 00:56:38.614300+00:00 url = "https://files.pythonhosted.org/packages/4c/07/2ebca9b11fb9be7340a818d8d6f63feaebb146be2c4afbd6061701d6df6e/snowballstemmer-3.1.1-py3-none-any.whl" size = 104164 [packages.wheels.hashes] sha256 = "7e207fa178741da09cdee59d3ecec3827ad5f92b1fc5c9ff3755b639f71f5752" [[packages]] name = "soupsieve" version = "3.0.4" marker = "\"docs\" in dependency_groups" requires-python = ">=3.11.5" index = "https://pypi.org/simple/" [packages.sdist] name = "soupsieve-3.0.4.tar.gz" upload-time = 2026-10-22 00:45:26.362373+00:00 url = "https://files.pythonhosted.org/packages/19/63/0b6bae54546aed98d72f12d09df57b19a287f01c84d22047fa2e23772ab9/soupsieve-3.0.4.tar.gz" size = 119017 [packages.sdist.hashes] sha256 = "924fab84ae21604c19e175e1f39815eb6fa83ba23836e09e9de180925e5bc4c0" [[packages.wheels]] name = "soupsieve-3.0.4-py3-none-any.whl" upload-time = 2026-10-22 00:45:24.843035+00:00 url = "https://files.pythonhosted.org/packages/07/66/473f7ca0be0b06e38ee8c85ee2db184e3981bd769fac2e7c19c8a8865211/soupsieve-3.0.4-py3-none-any.whl" size = 42458 [packages.wheels.hashes] sha256 = "8412826614f9c632227067d0985aeb9f584bd15efc852955430e097a9fe7afbf" [[packages]] name = "sphinx" version = "9.2.0" marker = "\"docs\" in dependency_groups" requires-python = ">=3.13" dependencies = [ { name = "alabaster" }, { name = "babel" }, { name = "docutils" }, { name = "imagesize" }, { name = "jinja2" }, { name = "packaging" }, { name = "pygments" }, { name = "requests" }, { name = "roman-numerals" }, { name = "snowballstemmer" }, { name = "sphinxcontrib-applehelp" }, { name = "sphinxcontrib-devhelp" }, { name = "sphinxcontrib-htmlhelp" }, { name = "sphinxcontrib-jsmath" }, { name = "sphinxcontrib-qthelp" }, { name = "sphinxcontrib-serializinghtml" }, ] index = "https://pypi.org/simple/" [packages.sdist] name = "sphinx-9.2.0.tar.gz" upload-time = 2026-10-21 00:05:15.019079+00:00 url = "https://files.pythonhosted.org/packages/64/d4/413466f0be8b1cb0e3e43893a148b96d7462d0923d82b4166b21afc5f30c/sphinx-9.2.0.tar.gz" size = 8782438 [packages.sdist.hashes] sha256 = "90470459f3cab15d9dfed146c71cf77694b0445df17b2e0e69059cb9f7fc9ee4" [[packages.wheels]] name = "sphinx-9.2.0-py3-none-any.whl" upload-time = 2026-10-21 00:05:12.241734+00:00 url = "https://files.pythonhosted.org/packages/c4/28/a0c50761cd215b7042f4bc617a6bc5c0cd7be62f75bc908e169e28a4d21e/sphinx-9.2.0-py3-none-any.whl" size = 3925388 [packages.wheels.hashes] sha256 = "4c3ec13b612712c0d3ce4cbdd9e3eb91e68577e07a8e31f32f21cf178453578c" [[packages]] name = "sphinx-autodoc-typehints" version = "3.17.3" marker = "\"docs\" in dependency_groups" requires-python = ">=3.12" dependencies = [ { name = "sphinx" }, ] index = "https://pypi.org/simple/" [packages.sdist] name = "sphinx_autodoc_typehints-3.17.3.tar.gz" upload-time = 2026-10-27 07:00:16.582996+00:00 url = "https://files.pythonhosted.org/packages/5b/8c/2ab7bd22aa2988c9e4f4791688d6de24a00395afbd5d7dffa16b35db81fb/sphinx_autodoc_typehints-3.17.3.tar.gz" size = 510825 [packages.sdist.hashes] sha256 = "bb61ba0e2ab46e24631b0cc6ea0818176d6218a35225e0e632586e8dffc4ff4d" [[packages.wheels]] name = "sphinx_autodoc_typehints-3.17.3-py3-none-any.whl" upload-time = 2026-10-27 07:00:14.696463+00:00 url = "https://files.pythonhosted.org/packages/83/9e/ab4db759c0a8dbc37aef2993976db7874bb2bde5f719d3c5b3448022f0d3/sphinx_autodoc_typehints-3.17.3-py3-none-any.whl" size = 140276 [packages.wheels.hashes] sha256 = "a87c08a7fe562a50557d1be6715bd47d403c23a7ead932dfc96de709b8a2b8ba" [[packages]] name = "sphinx-basic-ng" version = "1.0.0b2" marker = "\"docs\" in dependency_groups" requires-python = ">=3.7" dependencies = [ { name = "sphinx" }, ] index = "https://pypi.org/simple/" [packages.sdist] name = "sphinx_basic_ng-1.0.0b2.tar.gz" upload-time = 2023-07-08 18:40:54.166146+00:00 url = "https://files.pythonhosted.org/packages/98/0b/a866924ded68efec7a1759587a4e478aec7559d8165fac8b2ad1c0e774d6/sphinx_basic_ng-1.0.0b2.tar.gz" size = 20736 [packages.sdist.hashes] sha256 = "9ec55a47c90c8c002b5960c57492ec3021f5193cb26cebc2dc4ea226848651c9" [[packages.wheels]] name = "sphinx_basic_ng-1.0.0b2-py3-none-any.whl" upload-time = 2023-07-08 18:40:52.659879+00:00 url = "https://files.pythonhosted.org/packages/3c/dd/018ce05c532a22007ac58d4f45232514cd9d6dd0ee1dc374e309db830983/sphinx_basic_ng-1.0.0b2-py3-none-any.whl" size = 22496 [packages.wheels.hashes] sha256 = "eb09aedbabfb650607e9b4b68c9d240b90b1e1be221d6ad71d61c52e29f7932b" [[packages]] name = "sphinx-llm" version = "1.0.0" marker = "\"docs\" in dependency_groups" requires-python = ">=3.9" dependencies = [ { name = "sphinx" }, { name = "sphinx-markdown-builder" }, ] index = "https://pypi.org/simple/" [packages.sdist] name = "sphinx_llm-1.0.0.tar.gz" upload-time = 2026-08-14 07:48:29.578550+00:00 url = "https://files.pythonhosted.org/packages/33/58/6b1802820a17e7a128b45046d735f934af00f4290704b647b126213bdeb4/sphinx_llm-1.0.0.tar.gz" size = 381976 [packages.sdist.hashes] sha256 = "1cc8284343a0100caa3110010da5933cdad8da37d2b9dda0e2a7d503b8da6b1c" [[packages.wheels]] name = "sphinx_llm-1.0.0-py3-none-any.whl" upload-time = 2026-08-14 07:48:28.161181+00:00 url = "https://files.pythonhosted.org/packages/af/5b/2a338974998bc0fe563635625abdb16a000ee06a63101b361c834cd56ccb/sphinx_llm-1.0.0-py3-none-any.whl" size = 60282 [packages.wheels.hashes] sha256 = "8d45dbe5f9caffb4d78589c35d15fc8399dd41fc5b445ee4292ff83969754e75" [[packages]] name = "sphinx-markdown-builder" version = "0.6.11" marker = "\"docs\" in dependency_groups" requires-python = ">=3.7" dependencies = [ { name = "docutils" }, { name = "sphinx" }, { name = "tabulate" }, ] index = "https://pypi.org/simple/" [packages.sdist] name = "sphinx_markdown_builder-0.6.11.tar.gz" upload-time = 2026-08-16 06:51:38.158799+00:00 url = "https://files.pythonhosted.org/packages/ad/13/102d7134743120db25e636ffb57a30818e95a416583dca83aadac9c5dd87/sphinx_markdown_builder-0.6.11.tar.gz" size = 24020 [packages.sdist.hashes] sha256 = "ba3bf33e0e42ff30ae686ac73957a95e6d045790dcfbaca012dd8ae01794e577" [[packages.wheels]] name = "sphinx_markdown_builder-0.6.11-py3-none-any.whl" upload-time = 2026-08-16 06:51:36.810420+00:00 url = "https://files.pythonhosted.org/packages/c9/71/06b415631c2e494c80546073b46013d1715dceb3abe30bd63b34b4e7d9ca/sphinx_markdown_builder-0.6.11-py3-none-any.whl" size = 18088 [packages.wheels.hashes] sha256 = "acfb94d6ee66c7a47da0ca0420578fa16f115812af25b8fd21755d5909aacf51" [[packages]] name = "sphinxcontrib-applehelp" version = "2.0.0" marker = "\"docs\" in dependency_groups" requires-python = ">=3.9" index = "https://pypi.org/simple/" [packages.sdist] name = "sphinxcontrib_applehelp-2.0.0.tar.gz" upload-time = 2024-07-29 01:09:00.465812+00:00 url = "https://files.pythonhosted.org/packages/ba/6e/b837e84a1a704953c62ef8776d45c3e8d759876b4a84fe14eba2859106fe/sphinxcontrib_applehelp-2.0.0.tar.gz" size = 20053 [packages.sdist.hashes] sha256 = "2f29ef331735ce958efa4734873f084941970894c6090408b079c61b2e1c06d1" [[packages.wheels]] name = "sphinxcontrib_applehelp-2.0.0-py3-none-any.whl" upload-time = 2024-07-29 01:08:58.990722+00:00 url = "https://files.pythonhosted.org/packages/5d/85/9ebeae2f76e9e77b952f4b274c27238156eae7979c5421fba91a28f4970d/sphinxcontrib_applehelp-2.0.0-py3-none-any.whl" size = 119300 [packages.wheels.hashes] sha256 = "4cd3f0ec4ac5dd9c17ec65e9ab272c9b867ea77425228e68ecf08d6b28ddbdb5" [[packages]] name = "sphinxcontrib-devhelp" version = "2.0.0" marker = "\"docs\" in dependency_groups" requires-python = ">=3.9" index = "https://pypi.org/simple/" [packages.sdist] name = "sphinxcontrib_devhelp-2.0.0.tar.gz" upload-time = 2024-07-29 01:09:23.417467+00:00 url = "https://files.pythonhosted.org/packages/f6/d2/5beee64d3e4e747f316bae86b55943f51e82bb86ecd325883ef65741e7da/sphinxcontrib_devhelp-2.0.0.tar.gz" size = 12967 [packages.sdist.hashes] sha256 = "411f5d96d445d1d73bb5d52133377b4248ec79db5c793ce7dbe59e074b4dd1ad" [[packages.wheels]] name = "sphinxcontrib_devhelp-2.0.0-py3-none-any.whl" upload-time = 2024-07-29 01:09:21.945229+00:00 url = "https://files.pythonhosted.org/packages/35/7a/987e583882f985fe4d7323774889ec58049171828b58c2217e7f79cdf44e/sphinxcontrib_devhelp-2.0.0-py3-none-any.whl" size = 82530 [packages.wheels.hashes] sha256 = "aefb8b83854e4b0998877524d1029fd3e6879210422ee3780459e28a1f03a8a2" [[packages]] name = "sphinxcontrib-htmlhelp" version = "2.1.0" marker = "\"docs\" in dependency_groups" requires-python = ">=3.9" index = "https://pypi.org/simple/" [packages.sdist] name = "sphinxcontrib_htmlhelp-2.1.0.tar.gz" upload-time = 2024-07-29 01:09:37.889318+00:00 url = "https://files.pythonhosted.org/packages/43/93/983afd9aa001e5201eab16b5a444ed5b9b0a7a010541e0ddfbbfd0b2470c/sphinxcontrib_htmlhelp-2.1.0.tar.gz" size = 22617 [packages.sdist.hashes] sha256 = "c9e2916ace8aad64cc13a0d233ee22317f2b9025b9cf3295249fa985cc7082e9" [[packages.wheels]] name = "sphinxcontrib_htmlhelp-2.1.0-py3-none-any.whl" upload-time = 2024-07-29 01:09:36.407072+00:00 url = "https://files.pythonhosted.org/packages/0a/7b/18a8c0bcec9182c05a0b3ec2a776bba4ead82750a55ff798e8d406dae604/sphinxcontrib_htmlhelp-2.1.0-py3-none-any.whl" size = 98705 [packages.wheels.hashes] sha256 = "166759820b47002d22914d64a075ce08f4c46818e17cfc9470a9786b759b19f8" [[packages]] name = "sphinxcontrib-jsmath" version = "2.0.0" marker = "\"docs\" in dependency_groups" requires-python = ">=3.9" index = "https://pypi.org/simple/" [packages.sdist] name = "sphinxcontrib_jsmath-2.0.0.tar.gz" upload-time = 2026-10-20 18:22:05.514380+00:00 url = "https://files.pythonhosted.org/packages/59/02/4f334c7fc3e54c51d2364575a41cd2fae6847e7f53001949f0be5f32e18b/sphinxcontrib_jsmath-2.0.0.tar.gz" size = 14511 [packages.sdist.hashes] sha256 = "551f780cf9687f9c3c74f0cf9f18aca06e0d6958853453f3803fe4d357c5b924" [[packages.wheels]] name = "sphinxcontrib_jsmath-2.0.0-py3-none-any.whl" upload-time = 2026-10-20 18:22:04.420584+00:00 url = "https://files.pythonhosted.org/packages/da/c2/2965c35b39587d4b7a397f65a9c7d4b942b031061e7ac1ce91782f394a0d/sphinxcontrib_jsmath-2.0.0-py3-none-any.whl" size = 78095 [packages.wheels.hashes] sha256 = "15ca92fe0eec82f0baf91a7efcc51ffacdb605924be6327683a52ebdacb6744c" [[packages]] name = "sphinxcontrib-mermaid" version = "2.1.1" marker = "\"docs\" in dependency_groups" requires-python = ">=3.10" dependencies = [ { name = "jinja2" }, { name = "pyyaml" }, { name = "sphinx" }, ] index = "https://pypi.org/simple/" [packages.sdist] name = "sphinxcontrib_mermaid-2.1.1.tar.gz" upload-time = 2026-09-01 16:10:23.671939+00:00 url = "https://files.pythonhosted.org/packages/8c/87/06537dff20aa57df2d0f2afa87edff4afa85dec6f57f03f7acc24f83ff88/sphinxcontrib_mermaid-2.1.1.tar.gz" size = 22353 [packages.sdist.hashes] sha256 = "f8d7f1b2f0ad69b7ebdd5cbf532fb4ee107893b5373bc2d0e9634089c0b2cfee" [[packages.wheels]] name = "sphinxcontrib_mermaid-2.1.1-py3-none-any.whl" upload-time = 2026-09-01 16:10:22.377054+00:00 url = "https://files.pythonhosted.org/packages/50/6a/5bf2222b8084c7a7bc3ec23d470c91e3e03e82e4ce0bbacaa61f456ce7c1/sphinxcontrib_mermaid-2.1.1-py3-none-any.whl" size = 16368 [packages.wheels.hashes] sha256 = "3b9ac1a8e0b5154dec3c0acf9b5c54dd9ba58540e416f20858c300814c25648f" [[packages]] name = "sphinxcontrib-qthelp" version = "2.0.0" marker = "\"docs\" in dependency_groups" requires-python = ">=3.9" index = "https://pypi.org/simple/" [packages.sdist] name = "sphinxcontrib_qthelp-2.0.0.tar.gz" upload-time = 2024-07-29 01:09:56.435212+00:00 url = "https://files.pythonhosted.org/packages/68/bc/9104308fc285eb3e0b31b67688235db556cd5b0ef31d96f30e45f2e51cae/sphinxcontrib_qthelp-2.0.0.tar.gz" size = 17165 [packages.sdist.hashes] sha256 = "4fe7d0ac8fc171045be623aba3e2a8f613f8682731f9153bb2e40ece16b9bbab" [[packages.wheels]] name = "sphinxcontrib_qthelp-2.0.0-py3-none-any.whl" upload-time = 2024-07-29 01:09:54.885300+00:00 url = "https://files.pythonhosted.org/packages/27/83/859ecdd180cacc13b1f7e857abf8582a64552ea7a061057a6c716e790fce/sphinxcontrib_qthelp-2.0.0-py3-none-any.whl" size = 88743 [packages.wheels.hashes] sha256 = "b18a828cdba941ccd6ee8445dbe72ffa3ef8cbe7505d8cd1fa0d42d3f2d5f3eb" [[packages]] name = "sphinxcontrib-serializinghtml" version = "2.0.0" marker = "\"docs\" in dependency_groups" requires-python = ">=3.9" index = "https://pypi.org/simple/" [packages.sdist] name = "sphinxcontrib_serializinghtml-2.0.0.tar.gz" upload-time = 2024-07-29 01:10:09.332393+00:00 url = "https://files.pythonhosted.org/packages/3b/44/6716b257b0aa6bfd51a1b31665d1c205fb12cb5ad56de752dfa15657de2f/sphinxcontrib_serializinghtml-2.0.0.tar.gz" size = 16080 [packages.sdist.hashes] sha256 = "e9d912827f872c029017a53f0ef2180b327c3f7fd23c87229f7a8e8b70031d4d" [[packages.wheels]] name = "sphinxcontrib_serializinghtml-2.0.0-py3-none-any.whl" upload-time = 2024-07-29 01:10:08.203690+00:00 url = "https://files.pythonhosted.org/packages/52/a7/d2782e4e3f77c8450f727ba74a8f12756d5ba823d81b941f1b04da9d033a/sphinxcontrib_serializinghtml-2.0.0-py3-none-any.whl" size = 92072 [packages.wheels.hashes] sha256 = "6e2cb0eef194e10c27ec0023bfeb25badbbb5868244cf5bc5bdc04e4464bf331" [[packages]] name = "sphinxcontrib-towncrier" version = "0.5.0a0" marker = "\"docs\" in dependency_groups" requires-python = ">=3.9" dependencies = [ { name = "sphinx" }, { name = "towncrier" }, ] index = "https://pypi.org/simple/" [packages.sdist] name = "sphinxcontrib_towncrier-0.5.0a0.tar.gz" upload-time = 2025-02-28 01:59:16.894775+00:00 url = "https://files.pythonhosted.org/packages/21/fe/72ed57093e28af10595c50839b183c5fdf0952482e9ef0ca6eb90eb85c5d/sphinxcontrib_towncrier-0.5.0a0.tar.gz" size = 62453 [packages.sdist.hashes] sha256 = "294e69df6e275e7a86df7ea6a927cc7c28c2c370a884cd5c45de6ec989858f27" [[packages.wheels]] name = "sphinxcontrib_towncrier-0.5.0a0-py3-none-any.whl" upload-time = 2025-02-28 01:59:15.178594+00:00 url = "https://files.pythonhosted.org/packages/ac/5c/f7e39f243636a5e1894f2f5a72579977bf3968922afdb75175ee45062066/sphinxcontrib_towncrier-0.5.0a0-py3-none-any.whl" size = 12609 [packages.wheels.hashes] sha256 = "11d130c3ad5e4649821d543c4ea7ab64bbe78df4d859ef94f4298e7845dc0f59" [[packages]] name = "tabulate" version = "0.10.0" marker = "\"docs\" in dependency_groups" requires-python = ">=3.10" index = "https://pypi.org/simple/" [packages.sdist] name = "tabulate-0.10.0.tar.gz" upload-time = 2026-03-04 18:55:34.402969+00:00 url = "https://files.pythonhosted.org/packages/46/58/8c37dea7bbf769b20d58e7ace7e5edfe65b849442b00ffcdd56be88697c6/tabulate-0.10.0.tar.gz" size = 91754 [packages.sdist.hashes] sha256 = "e2cfde8f79420f6deeffdeda9aaec3b6bc5abce947655d17ac662b126e48a60d" [[packages.wheels]] name = "tabulate-0.10.0-py3-none-any.whl" upload-time = 2026-03-04 18:55:31.284751+00:00 url = "https://files.pythonhosted.org/packages/99/55/db07de81b5c630da5cbf5c7df646580ca26dfaefa593667fc6f2fe016d2e/tabulate-0.10.0-py3-none-any.whl" size = 39814 [packages.wheels.hashes] sha256 = "f0b0622e567335c8fabaaa659f1b33bcb6ddfe2e496071b743aa113f8774f2d3" [[packages]] name = "tomlkit" version = "0.15.2" marker = "\"build\" in dependency_groups" requires-python = ">=3.9" index = "https://pypi.org/simple/" [packages.sdist] name = "tomlkit-0.15.2.tar.gz" upload-time = 2026-10-16 04:07:08.166827+00:00 url = "https://files.pythonhosted.org/packages/08/72/fc01563df95c189a9f65850096bdc205bc2fe37da6c740cd64afaf18d07e/tomlkit-0.15.2.tar.gz" size = 190522 [packages.sdist.hashes] sha256 = "83e32755ba1d20d9340bd069ee97e9f6232ba2346b63ffcba425de01f7e327a6" [[packages.wheels]] name = "tomlkit-0.15.2-py3-none-any.whl" upload-time = 2026-10-16 04:07:09.216014+00:00 url = "https://files.pythonhosted.org/packages/da/46/7d84138a60a8bd796825b2d38ebe92d0d16501ca57d501b7c5d677401bbc/tomlkit-0.15.2-py3-none-any.whl" size = 52225 [packages.wheels.hashes] sha256 = "05562412163a3037b94497385fb7f9204c637bc605af7a48199e41cf2272f437" [[packages]] name = "towncrier" version = "26.9.0" marker = "\"docs\" in dependency_groups or \"release-news\" in dependency_groups or \"release-notes\" in dependency_groups or \"release-prepare\" in dependency_groups" requires-python = ">=3.9" dependencies = [ { name = "click" }, { name = "jinja2" }, ] index = "https://pypi.org/simple/" [packages.sdist] name = "towncrier-26.9.0.tar.gz" upload-time = 2026-09-04 12:57:25.341701+00:00 url = "https://files.pythonhosted.org/packages/92/d4/e85ce614bf45b1a7fe13b82cc687819c2eaac5a1dd94b84834322095fd98/towncrier-26.9.0.tar.gz" size = 79338 [packages.sdist.hashes] sha256 = "ace9031631c718cc0709107b8755e6e31350eb8acdc877af43df3177addb9a55" [[packages.wheels]] name = "towncrier-26.9.0-py3-none-any.whl" upload-time = 2026-09-04 12:57:23.986160+00:00 url = "https://files.pythonhosted.org/packages/82/88/f9340b545dafa64de053798e760515a2736fbb60092cf9aa6b146ccf3ede/towncrier-26.9.0-py3-none-any.whl" size = 67030 [packages.wheels.hashes] sha256 = "ae4d223e6aadaff98d29b7f09e58d9c9ccf4cd3b455a2d2e0486d432cd8bf660" [[packages]] name = "trove-classifiers" version = "2026.9.21.13" marker = "\"build\" in dependency_groups" index = "https://pypi.org/simple/" [packages.sdist] name = "trove_classifiers-2026.9.21.13.tar.gz" upload-time = 2026-09-21 13:29:07.301512+00:00 url = "https://files.pythonhosted.org/packages/bf/93/af436dfaa845cab5d96f0adbc1e4f3730532d37fa249e4eb796fb1d7fc82/trove_classifiers-2026.9.21.13.tar.gz" size = 17085 [packages.sdist.hashes] sha256 = "0a9ebc8d4e2f3e8a22848c5258033035bec17a3012ac3fea16dbaa764489eb71" [[packages.wheels]] name = "trove_classifiers-2026.9.21.13-py3-none-any.whl" upload-time = 2026-09-21 13:29:06.123578+00:00 url = "https://files.pythonhosted.org/packages/30/81/0da8afb52a71d0a4f2bd3152357b1a441e393b286374802b9d3addab4ab5/trove_classifiers-2026.9.21.13-py3-none-any.whl" size = 14232 [packages.wheels.hashes] sha256 = "8b1ff4f9c191b1040b71c37f1e445ab99732911e3cd91de52838453a854d7a17" [[packages]] name = "twine" version = "7.0.0" marker = "\"pkg-meta\" in dependency_groups" requires-python = ">=3.10" dependencies = [ { name = "id" }, { name = "keyring" }, { name = "packaging" }, { name = "readme-renderer" }, { name = "requests" }, { name = "requests-toolbelt" }, { name = "rfc3986" }, { name = "rich" }, { name = "urllib3" }, ] index = "https://pypi.org/simple/" [packages.sdist] name = "twine-7.0.0.tar.gz" upload-time = 2026-07-27 15:59:00.825945+00:00 url = "https://files.pythonhosted.org/packages/92/3c/58f808a359700f39a967dffede33efeac809262c03303fa3eec6afff8f49/twine-7.0.0.tar.gz" size = 215032 [packages.sdist.hashes] sha256 = "85cdb29c518efef867360ae4acd4b0dfd61c8654a22fca08e6f8539f05022177" [[packages.wheels]] name = "twine-7.0.0-py3-none-any.whl" upload-time = 2026-07-27 15:58:59.260418+00:00 url = "https://files.pythonhosted.org/packages/96/08/ddcdc06225eaad6de0e48e1002b06d919dbde20582d0662c7af51308e5d6/twine-7.0.0-py3-none-any.whl" size = 43204 [packages.wheels.hashes] sha256 = "b854164df26db268af05f49aa5c0344b10e27a494343ff05b1e0bad3b135f5a7" [[packages]] name = "typing-extensions" version = "4.16.0" marker = "\"docs\" in dependency_groups or \"pkg-meta\" in dependency_groups or \"test\" in dependency_groups" requires-python = ">=3.9" index = "https://pypi.org/simple/" [packages.sdist] name = "typing_extensions-4.16.0.tar.gz" upload-time = 2026-07-02 08:40:05.920538+00:00 url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz" size = 113555 [packages.sdist.hashes] sha256 = "dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5" [[packages.wheels]] name = "typing_extensions-4.16.0-py3-none-any.whl" upload-time = 2026-07-02 08:40:04.659120+00:00 url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl" size = 45571 [packages.wheels.hashes] sha256 = "481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8" [[packages]] name = "typing-inspection" version = "0.4.4" marker = "\"pkg-meta\" in dependency_groups" requires-python = ">=3.10" dependencies = [ { name = "typing-extensions" }, ] index = "https://pypi.org/simple/" [packages.sdist] name = "typing_inspection-0.4.4.tar.gz" upload-time = 2026-08-12 12:37:25.997329+00:00 url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz" size = 76928 [packages.sdist.hashes] sha256 = "547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47" [[packages.wheels]] name = "typing_inspection-0.4.4-py3-none-any.whl" upload-time = 2026-08-12 12:37:24.648475+00:00 url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl" size = 14750 [packages.wheels.hashes] sha256 = "65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147" [[packages]] name = "urllib3" version = "2.8.0" marker = "\"docs\" in dependency_groups or \"pkg-meta\" in dependency_groups" requires-python = ">=3.10" index = "https://pypi.org/simple/" [packages.sdist] name = "urllib3-2.8.0.tar.gz" upload-time = 2026-09-15 19:29:36.253420+00:00 url = "https://files.pythonhosted.org/packages/e3/05/b17359e1cefb4f909b5e40b1b90a496d987258916dbbf88e842c729f510e/urllib3-2.8.0.tar.gz" size = 458972 [packages.sdist.hashes] sha256 = "63bf2ead4c879426ebf22ef2a781eeb4aa3b4ae798a0435506f8687fd5bb9b63" [[packages.wheels]] name = "urllib3-2.8.0-py3-none-any.whl" upload-time = 2026-09-15 19:29:34.577402+00:00 url = "https://files.pythonhosted.org/packages/92/9d/c4e665119135114480843e7ab388fa94d8480650450e6f8e26b70d323a4c/urllib3-2.8.0-py3-none-any.whl" size = 135717 [packages.wheels.hashes] sha256 = "0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3" [[packages]] name = "uv" version = "0.13.6" marker = "\"pkg-meta\" in dependency_groups" requires-python = ">=3.8" index = "https://pypi.org/simple/" [packages.sdist] name = "uv-0.13.6.tar.gz" upload-time = 2026-10-27 00:45:57.605614+00:00 url = "https://files.pythonhosted.org/packages/08/d5/88d43310d425f6e3a43997ad771658ded3237d0ed78c16a89b2353eddf56/uv-0.13.6.tar.gz" size = 7545242 [packages.sdist.hashes] sha256 = "d762215478854f6cf20c3faed8f1eb30c8101d568e7bc32a3abafc063929d5c5" [[packages.wheels]] name = "uv-0.13.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl" upload-time = 2026-10-27 00:45:31.291019+00:00 url = "https://files.pythonhosted.org/packages/5c/43/0688ff5142934238f2812e348f4a24e09b57d48bfb38dd8f850325a0d9de/uv-0.13.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl" size = 17333810 [packages.wheels.hashes] sha256 = "22759b8895e1103578de6fb4cd27e94b4399d021023378d29f01333717751fba" [[packages]] name = "vcs-versioning" version = "2.6.0" marker = "\"build\" in dependency_groups" requires-python = ">=3.8" dependencies = [ { name = "packaging" }, ] index = "https://pypi.org/simple/" [packages.sdist] name = "vcs_versioning-2.6.0.tar.gz" upload-time = 2026-10-06 22:21:55.024903+00:00 url = "https://files.pythonhosted.org/packages/ad/e7/2a691db7d076b75d099fdba1a14cd966931f2aeb591e77f6d22f15097326/vcs_versioning-2.6.0.tar.gz" size = 166800 [packages.sdist.hashes] sha256 = "22e9159288e2d8bca2fa6f6c31c34c9c66aec157f60e9e526f5d160d2516d60f" [[packages.wheels]] name = "vcs_versioning-2.6.0-py3-none-any.whl" upload-time = 2026-10-06 22:21:53.267565+00:00 url = "https://files.pythonhosted.org/packages/73/1d/ad4064bf12f4503bf61e65d74c93c523672776d25ee0a06aaf42ae09ef3f/vcs_versioning-2.6.0-py3-none-any.whl" size = 123154 [packages.wheels.hashes] sha256 = "f8bebd1302ebc12e67ceb30fc7dd698ec969414b6fa43b30c3f14ac9b1e72c41" [[packages]] name = "vermin" version = "1.9.2" marker = "\"test\" in dependency_groups" requires-python = ">=3.0" index = "https://pypi.org/simple/" [packages.sdist] name = "vermin-1.9.2.tar.gz" upload-time = 2026-10-19 18:59:36.454397+00:00 url = "https://files.pythonhosted.org/packages/3f/e2/ec751ab6385d29be7a8d7a54379fa7d3267440c55dde0f265a0730417027/vermin-1.9.2.tar.gz" size = 223209 [packages.sdist.hashes] sha256 = "ba4e01ab817c6baadf5f057bd6ea691e1147bcaf00c1ac8db54df0e25c6d2c6d" [[packages.wheels]] name = "vermin-1.9.2-py3-none-any.whl" upload-time = 2026-10-19 18:59:34.861338+00:00 url = "https://files.pythonhosted.org/packages/11/c5/def1f1b124f67d085e219b99c6da4274b569d750558535ef373979fafa26/vermin-1.9.2-py3-none-any.whl" size = 107769 [packages.wheels.hashes] sha256 = "3e3e95d937f7d165eb928e0c268dfec466ccc40e9e1ce825e5d6e5aa027b6f30" [[packages]] name = "wcwidth" version = "0.9.4" marker = "\"release-news\" in dependency_groups or \"release-notes\" in dependency_groups or \"release-prepare\" in dependency_groups" requires-python = ">=3.9" index = "https://pypi.org/simple/" [packages.sdist] name = "wcwidth-0.9.4.tar.gz" upload-time = 2026-10-24 18:11:44.954051+00:00 url = "https://files.pythonhosted.org/packages/ea/01/e5ac23733628a6bbb91913a765fcf55ee8070d8d991bc88fdaaf805052c7/wcwidth-0.9.4.tar.gz" size = 970051 [packages.sdist.hashes] sha256 = "fd8d57d3cdfe570e3265c8fab1885c24e34ef40889c0759c0c304a19e9835954" [[packages.wheels]] name = "wcwidth-0.9.4-cp310-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl" upload-time = 2026-10-24 18:11:18.748328+00:00 url = "https://files.pythonhosted.org/packages/fa/e8/0485850748dfdba9443f4151c7b2442cfa4987ba6cd871038eb5d6b8eca9/wcwidth-0.9.4-cp310-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl" size = 785946 [packages.wheels.hashes] sha256 = "b185c8b927b23af2721be1783499d1cfa5c028b098b0858951b4ae224477f1c2" [[packages.wheels]] name = "wcwidth-0.9.4-py3-none-any.whl" upload-time = 2026-10-24 18:10:52.651030+00:00 url = "https://files.pythonhosted.org/packages/6f/0c/309b1813b230d1dbbd6f2c2ab2bb21ef68992358977605d9ef289bbfedcc/wcwidth-0.9.4-py3-none-any.whl" size = 301806 [packages.wheels.hashes] sha256 = "901eace6d2dd25ebb917d0ce5db18e5c537e1504c905cf32f0d006c12098b05d" [[packages]] name = "wheel-filename" version = "1.4.2" marker = "\"pkg-meta\" in dependency_groups" requires-python = ">=3.8" index = "https://pypi.org/simple/" [packages.sdist] name = "wheel_filename-1.4.2.tar.gz" upload-time = 2024-12-01 13:03:16.012739+00:00 url = "https://files.pythonhosted.org/packages/38/be/726dab762b770d0417e505c58e26d661aac1ec0c831e483cda4817ca2417/wheel_filename-1.4.2.tar.gz" size = 7911 [packages.sdist.hashes] sha256 = "87891c465dcbb40b40394a906f01a93214bdd51aa5d25e3a9a59cae62bc298fd" [[packages.wheels]] name = "wheel_filename-1.4.2-py3-none-any.whl" upload-time = 2024-12-01 13:03:00.536117+00:00 url = "https://files.pythonhosted.org/packages/b4/0f/6e97a3bc38cdde32e3ec49f8c0903fe3559ec9ec9db181782f0bb4417717/wheel_filename-1.4.2-py3-none-any.whl" size = 6195 [packages.wheels.hashes] sha256 = "3fa599046443d4ca830d06e3d180cd0a675d5871af0a68daa5623318bb4d17e3" [tool.nab] nab-version = "0.0.23" created-at = 2026-11-03 15:38:15.600801+00:00 command-line = [ "nab", "lock", "--all-groups", "--all-extras", "--no-cache", "--upgrade", ] input-path = "pyproject.toml" mode = "universal" python-specifier = ">=3.15,<3.16" platforms = [ "linux_x86_64", ] python_discovery-1.9.5/tox.toml0000644000000000000000000000464115273371321013604 0ustar00requires = [ "tox>=4.64.7", "tox-uv>=1.29" ] env_list = [ "3.15", "type-3.15", "3.14", "3.13", "3.12", "3.11", "3.10", "3.9", "type-3.9", "3.15t", "docs", "fix", "pkg_meta" ] skip_missing_interpreters = true [env_run_base] description = "run the tests with pytest" default_base_python = [ "3.15" ] package = "wheel" wheel_build_env = ".pkg" dependency_groups = [ "test" ] pass_env = [ "DIFF_AGAINST", "PYTEST_*" ] set_env.COVERAGE_FILE = "{work_dir}{/}.coverage.{env_name}" commands = [ [ "coverage", "erase" ], [ "coverage", "run", "-m", "pytest", { replace = "posargs", default = [ "--junitxml", "{work_dir}{/}junit.{env_name}.xml", "tests", ], extend = true }, ], [ "coverage", "combine" ], [ "coverage", "report" ], [ "coverage", "html", "-d", "{env_tmp_dir}{/}htmlcov" ], ] [env_pkg_base] default_base_python = [ "3.15" ] [env."type-3.15"] description = "run type check on code base (3.15)" deps = [ "gitpython>=3.2", "packaging>=26.2", "ty==0.0.84" ] commands = [ [ "ty", "check", "--output-format", "concise", "--error-on-warning", "--python-version", "3.15", "." ] ] [env."type-3.9"] description = "run type check on code base (3.9)" deps = [ "gitpython>=3.2", "packaging>=26.2", "ty==0.0.84" ] commands = [ [ "ty", "check", "--output-format", "concise", "--error-on-warning", "--python-version", "3.9", "." ] ] [env.docs] description = "build documentation" dependency_groups = [ "docs" ] commands = [ [ "sphinx-build", "-d", "{env_tmp_dir}{/}doctree", "docs", "--color", "-b", "html", "-W", { replace = "posargs", default = [ "{work_dir}{/}docs_out" ], extend = true }, ], ] [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.pkg_meta] description = "check that the long description is valid" skip_install = true dependency_groups = [ "pkg-meta" ] 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" dependency_groups = [ "test" ] commands = [ [ "uv", "pip", "tree" ], [ "python", "-c", "import sys; print(sys.executable)" ] ] python_discovery-1.9.5/.github/FUNDING.yaml0000644000000000000000000000006115273371321015403 0ustar00github: gaborbernat thanks_dev: u/gh/gaborbernat python_discovery-1.9.5/.github/SECURITY.md0000644000000000000000000000634415273371321015230 0ustar00# Security policy python-discovery is a Python library that finds Python interpreters on a machine and reports their metadata. This policy says how to report a security problem and how we decide whether a report is one. ## Reporting a vulnerability **Do not open a public issue for a security problem.** Use [GitHub's private vulnerability reporting](https://github.com/tox-dev/python-discovery/security/advisories/new). If neither works for you, email [gaborjbernat@gmail.com](mailto:gaborjbernat@gmail.com). Include what the attacker controls and what they gain, the steps or a proof of concept, and the versions and platform you tested. Write the report yourself; unreviewed tool or model output takes longer to triage. ## What to expect Volunteers maintain python-discovery, with no on-call rotation. We aim to acknowledge a report within **5 working days** and assess it within **15 working days**. Fixes are developed in a private fork attached to the draft advisory, ship in a new release, and the advisory, with a CVE requested through GitHub, goes out with that release. The default embargo is **90 days** from the report; we agree any change with the reporter. We credit reporters in the advisory under the name they choose, or not at all. There is no bug bounty. ## Supported versions Only the latest release receives fixes. Confirm the problem reproduces on it before reporting. ## Vulnerability or bug A report is a vulnerability when an attacker who controls only an input that the [threat model](THREAT_MODEL.md) lists as untrusted breaks a guarantee python-discovery makes. A report that first needs control of something the threat model trusts, such as an executable on `PATH` or in a version manager's directory, is a bug: we fix it in the open and publish no advisory. Classify by who supplies the dangerous input, not by how bad the outcome sounds. **In scope** - Executing a binary from a location the documented search order does not include, such as the current directory through an empty `PATH` entry. - A cache entry from one interpreter being reported for another, or surviving a change of the binary. - Interrogation output from one candidate being parsed as another candidate's metadata. - A spec's version, implementation, architecture, free-threading or debug constraint passing for a shim or launcher while the interpreter it resolves to fails it. - A PEP 514 record or a spec making discovery run a program other than the candidate with its registered arguments. - Another local user writing a shared cache directory so a victim accepts an interpreter, or metadata, that discovery would otherwise reject. - A small candidate response exhausting memory or processes. **Out of scope** - A malicious interpreter in a searched location: python-discovery runs candidates to learn about them, so whoever can place one there already runs code as the user. - Values in the process environment (`PATH`, `PYENV_ROOT`, `UV_PYTHON_INSTALL_DIR`, `PY_DISCOVERY_TIMEOUT`) and the Windows registry entries of PEP 514. - A `.python-version` file choosing among interpreters that discovery would find anyway. - Tampering with a cache directory only the caller can write. If you cannot tell which side a finding falls on, report it privately and we will decide. python_discovery-1.9.5/.github/THREAT_MODEL.md0000644000000000000000000002122015273371321015656 0ustar00# Threat model This model says what python-discovery trusts, where untrusted input enters, and which guarantees hold at each boundary, so a report can be classified as a vulnerability or a bug. [SECURITY.md](SECURITY.md) covers reporting; if the two disagree, SECURITY.md wins and we fix this file. ## How python-discovery works A caller passes a requirement such as `python3.12` or `>=3.11,<3.13`. python-discovery walks `try_first_with` paths, the running interpreter, the PEP 514 registry on Windows, `PATH` (resolving pyenv, mise and asdf shims to the binary they select), and uv's install directories. It runs each candidate with a bundled interrogation script and reads a JSON answer framed by two random cookies, then caches that answer in a directory the caller supplies, valid while the binary's path and modification time, the script's hash and any PEP 514 arguments stay the same. ```mermaid flowchart LR caller["Caller
spec, env, cache dir (trusted)"] --> pd["python-discovery"] pd -->|search| locs[("PATH, version managers,
PEP 514 registry (trusted)")] pd -->|"run candidate + script"| py["Candidate interpreter"] py -->|"JSON between cookies"| pd pd <-->|"checked against path, mtime,
script hash, PEP 514 args"| cache[("Cache dir")] classDef trusted fill:#cfe4ff,stroke:#1f6feb,color:#0b1f3a; classDef boundary fill:#ffe3a3,stroke:#d29200,color:#3a2c00; class caller,pd,locs trusted; class py,cache boundary; ``` ## Compared with similar tools We drew the boundary where comparable projects in other ecosystems draw theirs. The shared rule, stated by the [Node.js threat model](https://github.com/nodejs/node/blob/main/SECURITY.md#the-nodejs-threat-model), is that a report which first needs a trusted element compromised is out of scope; the first triage question, as [DOMPurify](https://github.com/cure53/DOMPurify/blob/main/SECURITY.md#what-we-consider-a-vulnerability) puts it, is what the attacker controls. | Project | Language | Where it draws the line | | ---------------------------------------------------------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------- | | [Go `os/exec` path lookup](https://go.dev/blog/path-security) | Go | Refuses to run a binary found through the current directory; python-discovery skips empty `PATH` entries for the same reason. | | [nvm](https://github.com/nvm-sh/nvm/blob/HEAD/.github/THREAT_MODEL.md) | Shell | Trusts what it installs and executes as the user; treats mirror metadata, not payloads, as the untrusted boundary. | | [uv](https://github.com/astral-sh/uv/security/policy) | Rust | Its policy says invoking the Python interpreters it finds, to read their metadata, is not a vulnerability. | ## Trusted An attacker who controls any of these already has what python-discovery could give them: - The caller and the spec, environment mapping, cache directory and `try_first_with` paths it passes. - Every directory searched: `PATH` entries, version-manager roots and install directories, and the PEP 514 registry, including `ExecutableArguments`. - The interpreters found there; running them is how discovery works. - A cache directory that only the caller can write (for example virtualenv's app-data). ## Untrusted inputs | Input | Comes from | What python-discovery guarantees | | ----------------------------- | --------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | | The current working directory | Wherever the caller happens to run | Empty `PATH` components, which shells read as the current directory, are skipped. | | Interrogation stdout | Startup noise or a `sitecustomize` that prints | Only the JSON between two per-run random cookies is parsed; anything else is a failed candidate. | | Cache entries | Earlier runs, possibly of another interpreter build | Reused only when the binary's path and modification time, the script hash and the PEP 514 arguments all match. | | A shared cache directory | Another local user who can write it | Must not make a victim accept an interpreter or metadata that discovery would reject; report any way it does. | ## Threats and controls | STRIDE | Threat | Control | | ---------------------- | ----------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | | Spoofing | Output printed by a candidate's startup code impersonates its metadata. | Random start and end cookies frame the answer. | | Tampering | A stale cache entry describes a binary that has since been replaced. | The entry records the binary's modification time, the script's hash and the PEP 514 arguments. | | Denial of service | A candidate hangs during interrogation. | The interrogation has a timeout, configurable with `PY_DISCOVERY_TIMEOUT`. | | Elevation of privilege | A binary in the current directory is picked up through `PATH`. | Empty `PATH` entries are skipped. | ## Release chain Releases run on the shared [tox-dev/github-workflows](https://github.com/tox-dev/github-workflows) pipeline. A release App, not a person, pushes the release commit and tag; the build installs its tools from a hash-checked `pylock.toml` resolved with a seven-day upload cooldown by default; PyPI receives the files through [trusted publishing](https://docs.pypi.org/trusted-publishers/) with attestations, and the GitHub Release is immutable. Every workflow pins third-party actions to a commit SHA. ## Vulnerability or bug | Report | Verdict | Why | | --------------------------------------------------------------------------- | ------------------- | ---------------------------------------------------------------------------------------------------- | | A `python3` in the working directory runs because `PATH` ends with `:` | Vulnerability | The empty entry must not reach the current directory. | | A `pyenv` shim that is malware runs during discovery | Not a vulnerability | Searched locations are trusted. | | A cached answer for `/usr/bin/python3` is returned after the OS upgraded it | Bug | Cache correctness, crossing no boundary; a vulnerability only if it can point at a different binary. | | `PY_DISCOVERY_TIMEOUT=inf` lets a hung interpreter block forever | Not a vulnerability | The environment is trusted and the value is documented. | ## Severity - **High:** argument handling runs a separate command the attacker chose, or another local user poisons a shared cache so a victim runs an interpreter outside the requested constraints, without control of the victim's `PATH`, registry or interpreters. - **Medium:** cache poisoning changes security-relevant metadata without execution, or a small candidate response causes sustained memory or process exhaustion. - **Low:** the wrong interpreter selected without crossing a privilege boundary, a crash on malformed output, or stale metadata from a cache only the caller writes. ## Accepted risks - Discovery executes every candidate it considers; it cannot learn an interpreter's version without running it. - PEP 514 `ExecutableArguments` are raw command-line text from the registry, passed to the candidate as written, without a shell. ## Keeping this current Revisit this model when python-discovery gains a new input source, network access, a new way to run code, or a new place it writes to, and whenever an advisory is published. python_discovery-1.9.5/.github/dependabot.yaml0000644000000000000000000000110315273371321016414 0ustar00version: 2 multi-ecosystem-groups: dependencies: schedule: interval: weekly day: tuesday labels: - dependencies - skip news updates: - package-ecosystem: github-actions directory: / patterns: - '*' multi-ecosystem-group: dependencies labels: - dependencies - skip news cooldown: default-days: 7 - package-ecosystem: pre-commit directory: / patterns: - '*' multi-ecosystem-group: dependencies labels: - dependencies - skip news cooldown: default-days: 7 python_discovery-1.9.5/.github/release.yaml0000644000000000000000000000016215273371321015733 0ustar00changelog: exclude: authors: - dependabot[bot] - github-actions[bot] - pre-commit-ci[bot] python_discovery-1.9.5/.github/workflows/check.yaml0000644000000000000000000000477015273371321017436 0ustar00name: 🧪 check on: workflow_dispatch: push: branches: ["main"] tags-ignore: ["**"] pull_request: types: [opened, synchronize, reopened] schedule: - cron: "47 8 * * *" concurrency: group: check-${{ github.ref }} cancel-in-progress: true permissions: contents: read jobs: test: name: 🧪 Test uses: tox-dev/github-workflows/.github/workflows/check.yaml@bceb5c005e19823dc8835f536f53f172ae223927 # 6.5.0 permissions: contents: read with: include: | [ {"env": "3.15"}, {"env": "3.15t", "python": "3.15t"}, {"env": "3.14", "python": "3.14"}, {"env": "3.13", "python": "3.13"}, {"env": "3.12", "python": "3.12"}, {"env": "3.11", "python": "3.11"}, {"env": "3.10", "python": "3.10"}, {"env": "3.9", "python": "3.9"}, {"env": "type-3.9"}, {"env": "type-3.15"}, {"env": "dev"}, {"env": "pkg_meta"} ] old-target: name: 🧪 Test on old target runs-on: ubuntu-26.04 steps: - name: 📥 Check out code uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 persist-credentials: false - name: 🐍 Install uv uses: astral-sh/setup-uv@68559e99cc807c1eb7e9238fd43a15bba460eec2 # v10.3.3 with: version: latest-known python-version: "3.15" enable-cache: false cache-dependency-glob: "pyproject.toml" github-token: ${{ secrets.GITHUB_TOKEN }} - name: 🏗️ Build the wheel run: uv build --wheel --out-dir dist . - parallel: - name: 🧪 Discover Python 3.6 run: tasks/old_target_check.sh 3.6 discover - name: 🧪 Discover Python 3.7 run: tasks/old_target_check.sh 3.7 discover - name: 🧪 Reject Python 3.5 run: tasks/old_target_check.sh 3.5 rejected - name: 🧪 Reject Python 2.7 run: tasks/old_target_check.sh 2.7 rejected gate: name: 🚦 ${{ github.event_name == 'pull_request' && 'Required checks pass' || 'Non-PR checks pass' }} if: ${{ !cancelled() }} # codespell:ignore cancelled needs: [test, old-target] runs-on: ubuntu-26.04 permissions: {} steps: - name: 🚦 Require all needed jobs to pass uses: tox-dev/github-workflows/required-checks@bceb5c005e19823dc8835f536f53f172ae223927 # 6.5.0 with: needs: ${{ toJSON(needs) }} python_discovery-1.9.5/.github/workflows/codeql.yaml0000644000000000000000000000103515273371321017617 0ustar00name: 🛡️ CodeQL on: workflow_dispatch: push: branches: ["main"] pull_request: schedule: - cron: "0 8 * * 2" concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true permissions: {} jobs: analyze: name: 🛡️ Analyze uses: tox-dev/github-workflows/.github/workflows/codeql.yaml@bceb5c005e19823dc8835f536f53f172ae223927 # 6.5.0 permissions: contents: read security-events: write with: languages: '["actions","python"]' paths-ignore: '["tests"]' python_discovery-1.9.5/.github/workflows/news.yaml0000644000000000000000000000203315273371321017323 0ustar00name: 📝 Changelog on: pull_request: types: [opened, reopened, synchronize, labeled, unlabeled] permissions: contents: read pull-requests: read jobs: changelog: name: 📝 News fragment runs-on: ubuntu-26.04 steps: - name: 📥 Check out code uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 persist-credentials: false - name: 🐍 Install uv uses: astral-sh/setup-uv@68559e99cc807c1eb7e9238fd43a15bba460eec2 # v10.3.3 with: version: latest-known python-version: "3.15" enable-cache: false - name: 📦 Install locked release tools uses: tox-dev/github-workflows/install-locked-tools@bceb5c005e19823dc8835f536f53f172ae223927 # 6.5.0 with: groups: release-news venv: ${{ runner.temp }}/release-tools - name: 🔍 Validate news fragments uses: tox-dev/github-workflows/validate-news@bceb5c005e19823dc8835f536f53f172ae223927 # 6.5.0 python_discovery-1.9.5/.github/workflows/prepare-release.yaml0000644000000000000000000000121115273371321021420 0ustar00name: 🏷️ Prepare release on: workflow_dispatch: inputs: bump: description: Version bump type required: true type: choice options: [auto, major, minor, patch] default: auto permissions: {} jobs: prepare: name: 🏷️ Prepare Towncrier release uses: tox-dev/github-workflows/.github/workflows/prepare-towncrier-release.yaml@bceb5c005e19823dc8835f536f53f172ae223927 # 6.5.0 permissions: contents: read pull-requests: read with: bump: ${{ inputs.bump }} annotated: true secrets: RELEASE_APP_PRIVATE_KEY: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} python_discovery-1.9.5/.github/workflows/release.yaml0000644000000000000000000000360415273371321017774 0ustar00name: 🚀 Release to PyPI on: push: tags: ["*"] permissions: contents: read concurrency: group: release-${{ github.ref }} cancel-in-progress: false jobs: build: name: 🏗️ Build uses: tox-dev/github-workflows/.github/workflows/validate-python-release.yaml@bceb5c005e19823dc8835f536f53f172ae223927 # 6.5.0 permissions: contents: read notes: name: 📝 Render GitHub Release notes uses: tox-dev/github-workflows/.github/workflows/release-notes.yaml@bceb5c005e19823dc8835f536f53f172ae223927 # 6.5.0 permissions: contents: read with: tag: ${{ github.ref_name }} changelog: docs/changelog.rst artifact-url: https://pypi.org/project/python-discovery/${{ github.ref_name }}/ release: name: 🚀 Publish to PyPI needs: [build, notes] runs-on: ubuntu-26.04 environment: name: release url: https://pypi.org/project/python-discovery/${{ github.ref_name }} permissions: id-token: write steps: - name: 📩 Download distributions uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: artifact-ids: ${{ needs.build.outputs.distributions-artifact-id }} path: dist/ - name: 🚀 Publish to PyPI uses: pypa/gh-action-pypi-publish@7245906fb61fa33a13554f9d5fb856fe61758086 # v1.14.3 with: packages-dir: dist github-release: name: 🚀 Publish GitHub Release needs: [build, notes, release] uses: tox-dev/github-workflows/.github/workflows/github-release.yaml@bceb5c005e19823dc8835f536f53f172ae223927 # 6.5.0 permissions: contents: write with: project: python-discovery version: ${{ github.ref_name }} tag: ${{ github.ref_name }} artifact-pattern: python-package-distributions notes-artifact-id: ${{ needs.notes.outputs.artifact-id }} title-prefix: v python_discovery-1.9.5/.github/workflows/scorecard.yaml0000644000000000000000000000201715273371321020316 0ustar00name: 🛡️ scorecard on: branch_protection_rule: schedule: - cron: "30 7 * * 2" push: branches: ["main"] workflow_dispatch: concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true permissions: {} jobs: analysis: name: 🛡️ Scorecard analysis if: github.repository == 'tox-dev/python-discovery' permissions: actions: read # read workflow runs for the Packaging check checks: read # read check runs for the CI-Tests and SAST checks contents: read # check out and scan the repository id-token: write # authenticate the results published to the scorecard API issues: read # read issue activity for the Maintained check pull-requests: read # read reviews for the Code-Review check security-events: write # upload the SARIF results to code scanning statuses: read # read commit statuses for the CI-Tests check uses: tox-dev/github-workflows/.github/workflows/scorecard.yaml@bceb5c005e19823dc8835f536f53f172ae223927 # 6.5.0 python_discovery-1.9.5/.github/workflows/upgrade-release-tools.yaml0000644000000000000000000000130215273371321022550 0ustar00name: 🔒 Upgrade release tools on: workflow_dispatch: schedule: - cron: "0 8 * * 2" permissions: {} concurrency: group: upgrade-release-tools cancel-in-progress: false jobs: upgrade: name: 🔀 Upgrade release tools uses: tox-dev/github-workflows/.github/workflows/upgrade-release-tools.yaml@bceb5c005e19823dc8835f536f53f172ae223927 # 6.5.0 permissions: contents: read secrets: RELEASE_APP_PRIVATE_KEY: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} with: commit-message: "build(deps): refresh release lock" pull-request-title: "build(deps): refresh release tool lock" pull-request-body: |- Relock `pylock.toml` after the seven-day cutoff. python_discovery-1.9.5/docs/changelog.rst0000644000000000000000000005026315273371321015507 0ustar00################# Release History ################# .. towncrier-draft-entries:: .. towncrier release notes start .. _release-1-9-5: ********************* v1.9.5 (2026-11-06) ********************* .. _release-1-9-5-fixed: Fixed ----- - :func:`~python_discovery.iter_interpreters` with no key no longer probes ``PATH`` directories or subdirectories named like Python executables as interpreters (:pr:`247`) by :user:`qodelicious` and :user:`gaborbernat`. - ``PythonSpec.satisfies`` now checks a version specifier at the precision it was written with, so ``3.11`` no longer satisfies ``>=3.13``. The Windows registry pre-filter skips mismatched releases and interrogates candidates whose prerelease status is unknown (:pr:`246`) by :user:`qodelicious` and :user:`gaborbernat`. .. _release-1-9-4: ********************* v1.9.4 (2026-11-04) ********************* .. _release-1-9-3: ********************* v1.9.3 (2026-11-03) ********************* .. _release-1-9-3-fixed: Fixed ----- - Reject generic Python executables when a requested implementation is unavailable (:pr:`239`) by :user:`gaborbernat`. (:issue:`238`) .. _release-1-9-2: ********************* v1.9.2 (2026-11-03) ********************* .. _release-1-9-2-fixed: Fixed ----- - Match literal interpreter names against the executable found on ``PATH``, including symlink names (:pr:`220`) by :user:`gaborbernat`. (:issue:`188`) - Preserve a virtual environment selected through ``try_first_with`` instead of returning its base interpreter (:pr:`218`) by :user:`gaborbernat`. (:issue:`191`) - Skip interpreter candidates that return JSON without usable interpreter metadata and continue searching (:pr:`231`) by :user:`gaborbernat`. (:issue:`197`) - Reject format widths in interpreter sysconfig paths before they allocate oversized strings (:pr:`229`) by :user:`gaborbernat`. (:issue:`199`) - Check the interpreter's reported implementation when a spec names a known implementation, regardless of its filename on ``PATH`` (:pr:`233`) by :user:`gaborbernat`. (:issue:`201`) - Write interpreter cache entries as compact JSON to keep their size proportional to the interpreter response (:pr:`237`) by :user:`gaborbernat`. (:issue:`204`) - Prevent an earlier virtual-environment lookup from reporting an executable under the wrong Python version (:pr:`215`) by :user:`gaborbernat`. - Retry interpreter discovery without the cache when cache access fails (:pr:`216`) by :user:`gaborbernat`. - Exclude prereleases of the final version named by an exclusive upper bound, such as ``<3.12.0`` rejecting ``3.12.0rc1`` (:pr:`217`) by :user:`gaborbernat`. - Stop repeated interpreter queries when a virtual environment reports the same base prefix with different path spellings (:pr:`219`) by :user:`gaborbernat`. - Limit candidate stdout and stderr during interpreter queries so a noisy candidate cannot exhaust the caller's memory (:pr:`223`) by :user:`gaborbernat`. - Find uv-managed patch releases when a version specifier names only their major and minor version (:pr:`227`) by :user:`gaborbernat`. - Handle interpreter paths containing non-UTF-8 filesystem bytes when using DiskCache (:pr:`230`) by :user:`gaborbernat`. - Preserve interpreter-provided values when answer keys match computed properties during cache serialization (:pr:`234`) by :user:`gaborbernat`. - Return after an interpreter query times out without leaving pipe readers blocked by a child process (:pr:`228`) by :user:`gaborbernat`. - Use the supplied environment for base-interpreter probes when resolving a virtual environment (:pr:`225`) by :user:`gaborbernat`. .. _release-1-9-2-security: Security -------- - When an explicit ``env`` mapping omits ``HOME`` or ``USERPROFILE``, uv-managed interpreter discovery no longer searches the process home directory (:pr:`236`) by :user:`gaborbernat`. (:issue:`200`) - Skip relative ``PATH`` entries, including the working-directory entry in the Windows default search path, when discovering interpreters (:pr:`235`) by :user:`gaborbernat`. (:issue:`202`) - Validate pyenv, mise and asdf version names before using them as directory names. A version file can no longer direct python-discovery's resolver to a binary outside the version manager's installation directory (:pr:`205`) by :user:`gaborbernat`. - When imported from an archive, python-discovery now extracts its interpreter probe into a private temporary directory. This prevents the probe from importing modules supplied in the shared temp directory (:pr:`206`) by :user:`gaborbernat`. - Bare interpreter names now search ``PATH`` without probing a matching file in the working directory. Use ``./name`` when the working directory contains the intended interpreter (:pr:`207`) by :user:`gaborbernat`. - Tcl/Tk library detection no longer executes Tk startup profile files from the home or working directory (:pr:`208`) by :user:`gaborbernat`. - Discovery skips ``.python-version`` files over 4096 characters during shim resolution, limiting memory use and allowing a parent or global version to apply (:pr:`209`) by :user:`gaborbernat`. - Discovery uses the profile default for empty or relative ``APPDATA`` and skips relative profile paths when finding uv-managed Pythons (:pr:`210`) by :user:`gaborbernat`. - Tcl/Tk library detection no longer opens an X display, so an unresponsive display cannot block interpreter discovery. It can also find the Tk library on headless systems (:pr:`211`) by :user:`gaborbernat`. - Skip PEP 514 registry entries with relative or non-string executable paths and continue interpreter discovery (:pr:`212`) by :user:`gaborbernat`. - Skip untrusted disk cache directories and entries on POSIX, and replace cache files without following links (:pr:`213`) by :user:`gaborbernat`. - Place POSIX cache locks in a restricted directory and preserve group sharing under a group-permissive umask (:pr:`214`) by :user:`gaborbernat`. - Interpreter discovery no longer reuses pyenv shim results across projects. A version choice in one checkout cannot leave an unsupported-version verdict that blocks discovery in another project (:pr:`232`) by :user:`gaborbernat`. .. _release-1-9-1: ********************* v1.9.1 (2026-11-02) ********************* .. _release-1-9-1-fixed: Fixed ----- - List free-threaded and debug builds on ``PATH``, such as ``python3.13t``, ``python3.13d`` and ``python3.13-dbg``, when ``iter_interpreters()`` runs with no spec, as it already did for uv's ``+freethreaded`` and ``+debug`` installs (:pr:`181`) by :user:`darrenhuai` and :user:`gaborbernat`. - Reuse preferred interpreter paths supplied as an iterator when trying multiple interpreter specifications (:pr:`180`) by :user:`subotac` and :user:`gaborbernat`. - Stop ``PythonInfo.system_include`` reporting the interpreter's prefix as the include directory when the C headers are not installed, which happened on Python 3.11 and later and on Debian's 3.10; the missing sysconfig include path is returned instead (:pr:`182`) by :user:`darrenhuai` and :user:`gaborbernat`. .. _release-1-9-0: ********************* v1.9.0 (2026-10-30) ********************* .. _release-1-9-0-added: Added ----- - Expose the 11 marker keys through ``MarkerEnvironment`` for callers that type-check ``PythonInfo.marker_environment`` (:pr:`179`) by :user:`gaborbernat`. .. _release-1-8-0: ********************* v1.8.0 (2026-10-30) ********************* .. _release-1-8-0-added: Added ----- - Expose the selected interpreter's dependency marker values as ``PythonInfo.marker_environment`` (:pr:`178`) by :user:`gaborbernat`. .. _release-1-7-0: ********************* v1.7.0 (2026-10-30) ********************* .. _release-1-7-0-added: Added ----- - Accept the free-threaded, debug, architecture and machine suffixes after a version specifier, as in ``>=3.13t`` or ``cpython>=3.11,<3.14-64``, and raise ``ValueError`` for a spec that starts with a comparison operator but does not parse, such as ``>=3.13.``, instead of looking it up as an executable name (:pr:`176`) by :user:`gaborbernat`. (:issue:`175`) .. _release-1-7-0-fixed: Fixed ----- - Stop a ``.python-version`` file that is not valid UTF-8, or that cannot be read, from aborting discovery through a pyenv, mise or asdf shim; a stray byte no longer hides the versions it names, and an unreadable file is skipped like a missing one (:pr:`173`) by :user:`darrenhuai` and :user:`gaborbernat`. .. _release-1-7-0-documentation: Documentation ------------- - Link every documentation page to its source and edit view on GitHub, and add a GitHub icon to the footer (:pr:`177`) by :user:`gaborbernat`. (:issue:`174`) .. _release-1-6-7: ********************* v1.6.7 (2026-10-28) ********************* .. _release-1-6-7-fixed: Fixed ----- - Rank uv's minor version links behind the concrete install on Windows too, where uv creates them as directory junctions that ``Path.is_symlink()`` does not report (:pr:`170`) by :user:`darrenhuai` and :user:`gaborbernat`. - Stop a venv lookup from rewriting the ``executable`` of earlier results, ``PythonInfo.current()`` and ``PythonInfo.current_system()`` when they share its base interpreter (:pr:`171`) by :user:`darrenhuai` and :user:`gaborbernat`. .. _release-1-6-6: ********************* v1.6.6 (2026-10-27) ********************* .. _release-1-6-6-fixed: Fixed ----- - Treat version specifiers with an item that cannot be parsed, such as ``>=3.x``, ``>=3.12 <4`` or ``>=3.12,foo``, as literal executable paths instead of raising a bare ``AssertionError`` or ignoring the item during discovery (:pr:`157`) by :user:`darrenhuai` and :user:`gaborbernat`. - Accept relative interpreter paths that step up a directory, such as ``../venv/bin/python``, which were found and queried but never matched the spec (:pr:`158`) by :user:`darrenhuai` and :user:`gaborbernat`. - Match ``~=X.Y`` compatible-release specifiers against later minor versions, as PEP 440 defines, instead of only the same minor; ``~=3.10`` now accepts 3.11 and newer 3.x (:pr:`159`) by :user:`darrenhuai` and :user:`gaborbernat`. .. _release-1-6-5: ********************* v1.6.5 (2026-10-27) ********************* .. _release-1-6-5-packaging: Packaging --------- - Releases now run on the shared tox-dev/github-workflows pipeline, with a locked release toolchain and generated GitHub Release notes (:pr:`164`) by :user:`gaborbernat`. .. _release-1-6-4: ********************* v1.6.4 (2026-10-19) ********************* .. _release-1-6-4-documentation: Documentation ------------- - Clarify the executable paths accepted by ``try_first_with`` in the standalone usage example. (:pr:`150`) - by :user:`travisfortney`. .. _release-1-6-3: ********************* v1.6.3 (2026-10-16) ********************* .. _release-1-6-3-fixed: Fixed ----- - Treat specs with dot-only version components, such as ``python.``, as literal executable paths instead of raising ``IndexError`` - by :user:`harbinresearcher` and :user:`gaborbernat`. (:pr:`147`) - Pass PEP 514 ``ExecutableArguments`` to registered Windows interpreters when querying them, so a registration that points at a launcher such as ``py.exe -3.11`` reports the interpreter it selects - by :user:`blouirts123` and :user:`gaborbernat`. (:pr:`148`) .. _release-1-6-2: ********************* v1.6.2 (2026-10-10) ********************* .. _release-1-6-2-added: Added ----- - Require Python 3.9 or newer for installation while still discovering Python 3.6 and 3.7 interpreters (:pr:`142`) - by :user:`gaborbernat`. .. _release-1-6-2-fixed: Fixed ----- - Discard malformed interpreter cache entries and query the interpreter again, including entries with missing, non-string or unusable ``system_executable`` paths - by :user:`darrenhuai` and :user:`gaborbernat`. (:pr:`141`) - Use the default interpreter query timeout and warn for invalid ``PY_DISCOVERY_TIMEOUT`` values. Accept ``inf`` or values above the subprocess wait limit to disable the timeout - by :user:`darrenhuai` and :user:`gaborbernat`. (:pr:`143`) .. _release-1-6-1: ********************* v1.6.1 (2026-09-18) ********************* .. _release-1-6-1-fixed: Fixed ----- - Skip empty ``PATH`` entries during interpreter discovery - by :user:`gaborbernat`. (:pr:`132`) .. _release-1-6-1-documentation: Documentation ------------- - Document :attr:`~python_discovery.PythonInfo.system_exe` across the tutorial, the how-to guide and the explanation of how resolution reaches a base interpreter. The class diagram in the how-to guide had :attr:`~python_discovery.PythonInfo.system_executable` typed ``str`` rather than ``str | None`` - by :user:`gaborbernat`. (:pr:`128`) .. _release-1-6-0: ********************* v1.6.0 (2026-08-28) ********************* .. _release-1-6-0-added: Added ----- - :attr:`~python_discovery.PythonInfo.system_exe` returns the executable of the system Python an interpreter is based on, falling back to :attr:`~python_discovery.PythonInfo.executable` the way :meth:`~python_discovery.PythonInfo.resolve_to_system` does. :attr:`~python_discovery.PythonInfo.system_executable` is ``None`` until resolution runs, so every consumer had to narrow a value discovery has already settled - by :user:`gaborbernat`. (:pr:`127`) .. _release-1-5-3: ********************* v1.5.3 (2026-08-24) ********************* .. _release-1-5-3-fixed: Fixed ----- - Prefer the version-matched system executable when CPython reports an existing generic executable for a copied POSIX virtual environment. (:issue:`124`) - by :user:`gaborbernat`. .. _release-1-5-2: ********************* v1.5.2 (2026-08-12) ********************* .. _release-1-5-2-fixed: Fixed ----- - Restore discovery of Python 3.6 and 3.7 interpreters. Earlier interrogation used Python 3.8 syntax and silently skipped these candidates. Interpreters below 3.6 now produce one warning; an absolute-path spec raises ``RuntimeError`` with that message. (:pr:`117`, :issue:`116`) - by :user:`gaborbernat`. .. _release-1-5-2-documentation: Documentation ------------- - Document that the library runs on Python 3.8+ and discovers interpreters down to 3.6. (:pr:`117`, :issue:`116`) - by :user:`gaborbernat`. .. _release-1-5-1: ********************* v1.5.1 (2026-07-31) ********************* .. _release-1-5-1-fixed: Fixed ----- - Locate uv's interpreter store the way uv does, using XDG on macOS (``~/.local/share/uv/python``) and roaming ``%APPDATA%\uv\python`` on Windows, neither of which ``platformdirs`` reported. The uv variables now come from the ``env`` mapping rather than ``os.environ`` - by :user:`gaborbernat`. (:pr:`111`, :issue:`109`) - Pick uv-managed interpreters by install directory name instead of globbing the store. A bare ``3.8`` now means CPython, the way uv resolves it, rather than whichever implementation the filesystem happened to list first, and probing runs newest version first - by :user:`gaborbernat`. (:pr:`112`, :issue:`110`) .. _release-1-5-1-packaging: Packaging --------- - Drop the ``platformdirs`` runtime dependency; locating uv's store was the only thing it did - by :user:`gaborbernat`. (:pr:`111`, :issue:`109`) .. _release-1-5-0: ********************* v1.5.0 (2026-07-21) ********************* .. _release-1-5-0-added: Added ----- - Add Python 3.15 support. (:pr:`104`) - by :user:`gaborbernat`. .. _release-1-4-4: ********************* v1.4.4 (2026-07-08) ********************* .. _release-1-4-4-fixed: Fixed ----- - Parse the debug build flag in interpreter specs - ``python3.13d`` and Debian's ``python3.13-dbg`` / ``python3.13-debug`` now select a ``Py_DEBUG`` interpreter instead of being misread as an ISA named ``dbg``. Resolving a virtualenv to its base interpreter also checks the free-threaded and debug ABI flags, so a debug or free-threaded environment no longer resolves to a release build of the same version - by :user:`ychampion` and :user:`gaborbernat`. (:pr:`96`) .. _release-1-4-3: ********************* v1.4.3 (2026-07-03) ********************* .. _release-1-4-3-packaging: Packaging --------- - Constrain the ``hatchling`` build requirement per Python version so sdist builds resolve a compatible backend on the declared ``>=3.8`` floor - ``hatchling>=1.28`` dropped Python 3.9, leaving 3.8/3.9 unbuildable from source - by :user:`gaborbernat`. (:pr:`94`, :issue:`92`) .. _release-1-4-2: ********************* v1.4.2 (2026-06-11) ********************* .. _release-1-4-2-fixed: Fixed ----- - Stop executable symlink resolution once the stdlib landmark is reachable and keep macOS framework builds untouched, matching ``getpath`` - Homebrew interpreters no longer get version-pinned ``Cellar`` paths recorded and stable aliases such as Debian's ``/usr/bin/python3`` are preserved - by :user:`gaborbernat`. (:pr:`87`, :issue:`86`) .. _release-1-4-1: ********************* v1.4.1 (2026-06-11) ********************* .. _release-1-4-1-fixed: Fixed ----- - Resolve executable-only symlinks when computing ``system_executable``, mirroring CPython's ``getpath.realpath`` (python/cpython#115237): a symlink to the interpreter binary now resolves to the real interpreter, while a fully symlinked interpreter tree is kept as-is - by :user:`gaborbernat`. (:pr:`85`, :issue:`84`) .. _release-1-4-0: ********************* v1.4.0 (2026-05-28) ********************* .. _release-1-4-0-added: Added ----- - Add ``debug_build`` attribute to :class:`PythonInfo` exposing whether the interpreter is a debug build (``Py_DEBUG``) - by :user:`gaborbernat`. (:pr:`79`) .. _release-1-3-2: ********************* v1.3.2 (2026-05-27) ********************* .. _release-1-3-2-fixed: Fixed ----- - Accept dots in machine and ISA specifiers (:pr:`78`) - by :user:`gaborbernat`. .. _release-1-3-1: ********************* v1.3.1 (2026-05-12) ********************* .. _release-1-3-1-fixed: Fixed ----- - Canonicalize GraalVM as GraalPy in interpreter specs and results - by :user:`timfel`. (:pr:`73`) .. _release-1-3-0: ********************* v1.3.0 (2026-05-05) ********************* .. _release-1-3-0-added: Added ----- - Add :func:`~python_discovery.iter_interpreters` for enumerating every discovered interpreter, with PATH and UV-install support for non-CPython implementations listed in :data:`~python_discovery.KNOWN_IMPLEMENTATIONS` (:pull:`71`) - by :user:`gaborbernat`. .. _release-1-2-2: ********************* v1.2.2 (2026-04-06) ********************* .. _release-1-2-2-added: Added ----- - Export ``normalize_isa`` (:pull:`62`) - by :user:`rahuldevikar`. .. _release-1-2-2-deprecated: Deprecated ---------- - Deprecate ``KNOWN_ARCHITECTURES`` (:pull:`62`) - by :user:`rahuldevikar`. .. _release-1-2-1: ********************* v1.2.1 (2026-03-26) ********************* .. _release-1-2-1-added: Added ----- - Expose ``KNOWN_ARCHITECTURES`` as public API (:pull:`56`) - by :user:`rahuldevikar`. .. _release-1-2-0: ********************* v1.2.0 (2026-03-18) ********************* .. _release-1-2-0-added: Added ----- - Increase interpreter query timeout to 15s, with an override (:pull:`53`) - by :user:`gaborbernat`. .. _release-1-1-3: ********************* v1.1.3 (2026-03-10) ********************* .. _release-1-1-2: ********************* v1.1.2 (2026-03-09) ********************* .. _release-1-1-2-fixed: Fixed ----- - Match prerelease versions against ``major.minor`` specs (:pull:`48`) - by :user:`gaborbernat`. - Drain pipes after killing a timed-out interpreter probe (:pull:`49`) - by :user:`gaborbernat`. .. _release-1-1-2-documentation: Documentation ------------- - Add package description and usage to the README (:pull:`46`) - by :user:`gaborbernat`. .. _release-1-1-1: ********************* v1.1.1 (2026-03-06) ********************* .. _release-1-1-1-fixed: Fixed ----- - Add a timeout to interpreter probing (:pull:`42`) - by :user:`gaborbernat`. .. _release-1-1-0: ********************* v1.1.0 (2026-02-26) ********************* .. _release-1-1-0-added: Added ----- - Add a ``predicate`` parameter to :func:`~python_discovery.get_interpreter` (:pull:`31`) - by :user:`gaborbernat`. .. _release-1-1-0-documentation: Documentation ------------- - Add ``:param:`` descriptions to all public APIs (:pull:`32`) - by :user:`gaborbernat`. .. _release-1-0-0: ********************* v1.0.0 (2026-02-25) ********************* .. _release-1-0-0-added: Added ----- - Initial release as a standalone package, extracted from ``virtualenv`` (:pull:`28`) - by :user:`gaborbernat`. python_discovery-1.9.5/docs/conf.py0000644000000000000000000000430615273371321014322 0ustar00"""Sphinx configuration for python-discovery documentation.""" from __future__ import annotations import os from datetime import datetime, timezone from pathlib import Path from python_discovery import __version__ company = "tox-dev" name = "python-discovery" repository = f"https://github.com/{company}/{name}" version = ".".join(__version__.split(".")[:2]) release = __version__ copyright = f"2026-{datetime.now(tz=timezone.utc).year}, {company}" # ruff:ignore[builtin-variable-shadowing] extensions = [ "sphinx_llm.txt", "sphinx.ext.autodoc", "sphinx.ext.autosectionlabel", "sphinx.ext.extlinks", "sphinx.ext.intersphinx", "sphinx_autodoc_typehints", "sphinxcontrib.mermaid", "sphinxcontrib.towncrier.ext", ] towncrier_draft_autoversion_mode = "draft" towncrier_draft_include_empty = True towncrier_draft_working_directory = Path(__file__).parent.parent autosectionlabel_maxdepth = 2 extlinks = { "issue": (f"{repository}/issues/%s", "#%s"), "pr": (f"{repository}/pull/%s", "PR #%s"), "pull": (f"{repository}/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/*"] 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, "source_repository": repository, "source_branch": "main", "source_directory": "docs/", "footer_icons": [ { "name": "GitHub", "url": repository, # footer icons are inlined into every page, so a path to the image would not resolve "html": (Path(__file__).parent / "_static" / "github.svg").read_text(encoding="utf-8"), "class": "", }, ], } html_css_files = ["custom.css"] markdown_http_base = os.environ.get("READTHEDOCS_CANONICAL_URL", "") python_discovery-1.9.5/docs/contributing.rst0000644000000000000000000000260715273371321016266 0ustar00Contributing ============ Changes that affect users need a reStructuredText fragment in ``docs/changelog``. Name it ``+short-description.category.rst``; the release process links the fragment to its merged pull request, so the filename needs no pull request number. Keep one change per fragment and describe the effect on users. Use ``feature`` for a new capability or a Python support change, ``change`` for changed behavior, ``bugfix`` for a fix, and ``doc`` for user documentation. ``security``, ``performance``, ``deprecation``, ``removal``, and ``packaging`` are available when those distinctions help users. A pull request with no user-facing change needs the ``skip news`` label. Bots get that label automatically unless their pull request includes a fragment. For example, a fix to interpreter discovery could use ``docs/changelog/+find-venv.bugfix.rst``: .. code-block:: rst Find a virtual environment's base interpreter when its executable is a symlink. Don't write credits by hand. Each entry credits the merged pull request's commit author and co-authors, computed when the changelog is built, and release preparation runs ``release-creator contributors`` to add a New Contributors subsection. GitHub Release notes are rendered as Markdown from the tagged reStructuredText section. Release preparation runs the repository's ``prek`` checks before creating the release commit and annotated tag. python_discovery-1.9.5/docs/explanation.rst0000644000000000000000000005336715273371321016112 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{"Does spec name a
relative directory?"} RelPath -->|Yes| TryRel["Resolve relative to cwd"] RelPath -->|No| Bare{"Bare executable name?"} Bare -->|Yes| PATH Bare -->|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| Valid{"Valid directory name?"} EnvVar -->|No| File{".python-version
names a version?"} File -->|Yes| Valid File -->|No| Global{"pyenv global
version exists?"} Global -->|Yes| Valid Global -->|No| Skip["Skip shim"] Valid -->|Yes| Use["Use that version"] Valid -->|No| More{"Another version?"} More -->|Yes| Valid More -->|No| Skip style Shim fill:#4a90d9,stroke:#2a5f8f,color:#fff style Valid fill:#4a90d9,stroke:#2a5f8f,color:#fff style More 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. python-discovery skips version files over 4096 characters. It checks parent directories and, for pyenv, the global version when needed. A version must name one directory below the version manager's installation root. Discovery skips ``.`` and ``..`` and version names containing a path separator, then tries the next configured version. How uv-managed Pythons are discovered --------------------------------------- `uv `_ installs Python interpreters under a single root directory. ``UV_PYTHON_INSTALL_DIR`` overrides it; otherwise the store is the ``python`` bucket of uv's state directory, which uv derives as follows: .. list-table:: :header-rows: 1 :widths: 20 40 40 * - Platform - Legacy state directory - Current state directory * - Windows - ``%APPDATA%\uv\data`` - ``%APPDATA%\uv`` (roaming, not local) * - macOS - ``~/Library/Application Support/uv`` - ``$XDG_DATA_HOME/uv`` or ``~/.local/share/uv`` * - Other - same as current - ``$XDG_DATA_HOME/uv`` or ``~/.local/share/uv`` uv follows the XDG convention on macOS too, and falls back to the legacy directory only when that directory already exists. python-discovery searches both, in the same order, so a leftover legacy directory cannot hide the store uv installs into. A relative ``XDG_DATA_HOME`` does not count, as the XDG specification requires, and Windows ignores it outright. An empty or relative ``APPDATA`` uses the profile default when ``USERPROFILE`` is present. Discovery skips relative store roots. Every one of these variables comes from the ``env`` mapping passed to :func:`~python_discovery.get_interpreter`, rather than from the process environment. If the mapping omits ``HOME`` or ``USERPROFILE``, discovery skips the corresponding home-derived roots but still searches an absolute mapped ``APPDATA`` or ``XDG_DATA_HOME``. Each install lives in its own subdirectory, but the actual binary location varies by OS and implementation: .. list-table:: :header-rows: 1 :widths: 25 35 40 * - Implementation - Unix layout - Windows layout * - CPython - ``//bin/python`` - ``//python.exe`` * - PyPy - ``//bin/pypy*`` - ``//pypy*.exe`` * - GraalPy - ``//bin/graalpy`` - ``//bin/graalpy.exe`` GraalPy keeps its ``bin/`` segment on Windows (an upstream choice in uv); PyPy and CPython do not. python-discovery globs all of these patterns regardless of the host OS, because globs that do not match anything are essentially free, and the cross-platform list is short. Symlinked aliases inside an install (``bin/python``, ``bin/python3``, ``bin/python3.14`` all pointing at the same real file) are deduplicated by resolved path before the subprocess interrogation, so each install is interrogated once. python-discovery also follows the ``install`` subdirectory some distributions unpack into, as uv does. Which uv installs get interrogated ------------------------------------ Interrogating an interpreter costs a subprocess, and a uv install directory announces what it holds in its own name: ``-[+]---``, for example ``cpython-3.14.6+freethreaded-macos-aarch64-none``. python-discovery reads that name and skips the installs the spec already rules out, then probes what is left in uv's preference order: .. mermaid:: flowchart LR Dirs[/"store subdirectories"/] --> Parse{"name is a uv key?"} Parse -->|"no"| Last["probe last"] Parse -->|"yes"| Filter["drop what the spec rules out:
implementation, version, free-threaded, debug"] Filter --> Sort["CPython → PyPy → GraalPy,
newest version first,
default build before variants"] Sort --> Interrogate(["subprocess interrogation"]) Last --> Interrogate style Dirs fill:#4a90d9,stroke:#2a5f8f,color:#fff style Parse fill:#d9904a,stroke:#8f5f2a,color:#fff style Filter fill:#9f4ad9,stroke:#5f2a8f,color:#fff style Sort fill:#c2873a,stroke:#7a4c1f,color:#fff style Interrogate fill:#4a9f4a,stroke:#2a6f2a,color:#fff python-discovery reads the variant suffix the same way :class:`~python_discovery.PythonSpec` does: ``3.14t`` wants ``+freethreaded``, ``3.14d`` wants ``+debug``. A spec that says nothing about a variant leaves it unconstrained, so ``3.14`` still reaches a ``+debug`` install, after the default build rather than instead of it. A bare version spec such as ``3.8`` matches CPython only, which is what uv resolves it to and what the equivalent ``python3.8`` probe on ``PATH`` finds in practice. Ask for ``pypy3.8`` or enumerate with :func:`~python_discovery.iter_interpreters` to reach the other implementations - PyPy ships a ``bin/python`` symlink, so a store-wide glob would otherwise hand back PyPy for ``3.8`` whenever the filesystem listed it first. Two names carry less information than they appear to. GraalPy keys named after the GraalVM release (``graalpy-24.1.1``, from older uv versions) do not constrain the version, and uv's minor version links (``cpython-3.15`` pointing at ``cpython-3.15.0b3``) move on the next patch release, so the concrete install ranks ahead of the link. Anything that is not a uv key - a hand-made ``UV_PYTHON_INSTALL_DIR`` layout, say - stays in the running, at the back of the queue. Selecting one interpreter vs. enumerating all of them ------------------------------------------------------- :func:`~python_discovery.get_interpreter` and :func:`~python_discovery.iter_interpreters` walk the same candidate sources, but they answer different questions and behave differently in three ways. .. mermaid:: flowchart LR Sources["candidate sources
(try_first_with → current →
PEP 514 → PATH → uv)"] Sources --> Get["get_interpreter()
first match wins, returns one"] Sources --> Iter["iter_interpreters()
yields every match"] style Get fill:#4a9f4a,stroke:#2a6f2a,color:#fff style Iter fill:#4a90d9,stroke:#2a5f8f,color:#fff **Implementation coverage on PATH.** :func:`~python_discovery.get_interpreter` matches only ``python*`` filenames on PATH unless the spec names another implementation explicitly (``pypy3.12``, ``graalpy3.11``). This keeps backwards compatibility with tools that have always read "no implementation in the spec" as "give me CPython." :func:`~python_discovery.iter_interpreters` with no spec broadens the search to every name in :data:`~python_discovery.KNOWN_IMPLEMENTATIONS` -- otherwise an "all interpreters" call would silently miss every PyPy and GraalPy on the system. It also matches the free-threaded and debug names (``python3.13t``, ``python3.13d``, ``python3.13-dbg``), so PATH lists those builds just as uv's ``+freethreaded`` and ``+debug`` installs are listed. When you pass a spec to :func:`~python_discovery.iter_interpreters`, it falls back to the same narrow regex as :func:`~python_discovery.get_interpreter`, so behavior is consistent across the two APIs whenever a spec is given. **Deduplication.** :func:`~python_discovery.get_interpreter` deduplicates per call so it does not interrogate the same binary twice while searching, and stops as soon as a match is found. :func:`~python_discovery.iter_interpreters` deduplicates by the resolved real path of each candidate's :attr:`~python_discovery.PythonInfo.system_exe`. That means symlinked aliases like ``/bin/python3`` and ``/usr/bin/python3``, or a virtualenv whose ``python`` symlinks to its base interpreter, collapse to a single yield. The semantic is "one entry per distinct install," which is what callers building choosers or version-range pickers usually want. **Iteration order.** Yields come back in *priority order*: ``try_first_with`` first, then the running interpreter, then :pep:`514` entries on Windows, then PATH left-to-right, then UV-managed installs. This matches what :func:`~python_discovery.get_interpreter` would have returned at each step. If your ordering differs (newest version first, smallest install root, etc.), wrap the call in :func:`sorted` -- the API deliberately does not include a ``sort_by`` parameter because keeping discovery order preserves the priority signal for callers who want it. Resolving a virtual environment to its base -------------------------------------------- A :class:`~python_discovery.PythonInfo` reaches you in one of two states, and the difference shows up in a single field. Collection fills in :attr:`~python_discovery.PythonInfo.system_executable` by inspection alone, which works whenever the interpreter is not in a virtual environment or names its base outright. When inspection comes up short the field holds ``None``, and :meth:`~python_discovery.PythonInfo.resolve_to_system` walks the prefix chain a layer at a time, interrogating each prefix until it reaches a real install. Every discovery entry point runs that walk, so an interpreter you got from :func:`~python_discovery.get_interpreter`, :func:`~python_discovery.iter_interpreters` or :meth:`~python_discovery.PythonInfo.from_exe` is resolved before you see it. .. mermaid:: flowchart TD Collect["collection
system_executable = None"] --> Walk["resolve_to_system()
walk prefix chain"] Walk --> Done["system_executable set"] Collect -- "base named outright" --> Done Done --> Read["system_exe"] style Collect fill:#ffe3a3,stroke:#d29200,color:#3a2c00 style Walk fill:#4a90d9,stroke:#2a5f8f,color:#fff style Done fill:#4a9f4a,stroke:#2a6f2a,color:#fff style Read fill:#4a9f4a,stroke:#2a6f2a,color:#fff The annotation cannot describe that ordering, so ``system_executable`` stays ``str | None`` and a type checker asks every reader to narrow a value resolution has settled. :attr:`~python_discovery.PythonInfo.system_exe` closes that gap by falling back to :attr:`~python_discovery.PythonInfo.executable`, which is the value ``resolve_to_system`` writes anyway when a prefix links back to its own interpreter. Reading the raw field still tells the two states apart, which is what :meth:`~python_discovery.PythonInfo.from_exe` with ``resolve_to_host=False`` leaves you holding. How caching works ------------------- Querying an interpreter requires a subprocess call, which is slow. The cache stores the result as a JSON file keyed by the queried path when that path identifies the interpreter that answered. Launchers such as pyenv shims can select different interpreters in different directories, so their results are not cached under the launcher path. .. mermaid:: flowchart TD Lookup["py_info(path)"] --> Exists{"Cache hit?"} Exists -->|Yes| Read["Read JSON"] Exists -->|No| Run["Run subprocess"] Run --> Same{"Queried path is
the interpreter?"} Same -->|Yes| Write["Write JSON
(with filelock)"] Same -->|No| Return["Return query result"] Write --> Return 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 style Same 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`). If cache access raises an OS error, discovery retries the interpreter query without that cache. A failed cache write can therefore cause a second query. On POSIX, ``DiskCache`` uses a cache root only when it and both ``py_info`` directories are real directories owned by the current user or root and cannot be written by other users. Its ancestor directories must also prevent other users from replacing the cache root; a sticky shared ancestor such as ``/tmp`` is allowed. Discovery continues without disk caching and logs a warning if the directory check fails. Cache entries must be regular files owned by the current user or root and not writable by others. Discovery tries to remove a rejected entry and queries the interpreter again. Use a private directory for the cache root, including when callers share a machine. A relative cache root refers to the current directory when ``DiskCache`` is created. On POSIX, lock files live in a restricted directory under the checked cache directory. Older releases use the previous lock files, so processes running different releases do not coordinate cache writes. Use separate cache roots while both run. 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. For invalid values, python-discovery logs a warning and uses the default of 15 seconds. Set ``inf`` to disable the timeout. Discovery skips a candidate that produces more than 1,048,576 characters on either stdout or stderr, even when the timeout is disabled. Supported Python versions ------------------------- Two floors apply: python-discovery itself runs on Python 3.9+ (``requires-python``), while discovery reaches down to Python 3.6 - old enough to cover RHEL 8's system Python. python-discovery skips candidates below 3.6 with a warning naming the executable, the version found, and the floor. The verdict is cached when the candidate itself is the interpreter; launchers such as pyenv shims are queried again because their target can depend on the current directory. Candidates too old to run the version check at all (before Python 2.7) fail as a generic query error. An absolute-path spec for a below-floor interpreter raises :class:`RuntimeError` with the same message. The floor moves only in a major release. Spec format reference ----------------------- A spec string follows the pattern ``[impl][version][t][d][-arch][-machine]``. Every part is optional. .. mermaid:: flowchart TD Spec["Spec string"] --> Impl["impl
(optional)"] Impl --> Version["version
(optional)"] Version --> T["t
(optional)"] T --> D["d / -dbg / -debug
(optional)"] D --> 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 D fill:#4ad9c4,stroke:#2a8f7f,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. - **d** -- require a debug (``Py_DEBUG``) build. Written as ``d`` right after the version (the ABI flag form, as in ``python3.13d``) or as Debian's ``-dbg`` / ``-debug`` suffix (``python3.13-dbg``). When omitted the build type is unconstrained. - **-arch** -- ``-32`` or ``-64`` for 32-bit or 64-bit interpreters. - **-machine** -- the CPU instruction set: ``-arm64``, ``-x86_64``, ``-aarch64``, ``-riscv64``, etc. Order matters: the parts appear in the sequence shown above. Combine the ABI flags as ``python3.13td`` (free-threaded then debug, the order CPython uses in ``sys.abiflags``), and keep ``-arch`` before ``-machine`` as in ``python3.13d-64-arm64``. Mixing ``d`` with ``-dbg`` in one spec, or swapping the arch and machine segments, leaves the string unrecognized, so it falls back to a literal executable-name match on ``PATH``. Only a spec with a directory component, such as ``./python3`` or ``venv/bin/python``, searches the working directory. **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.13-dbg`` - Debug (``Py_DEBUG``) CPython 3.13 (``python3.13d`` also works) * - ``python3.13td`` - Free-threaded debug 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`` - `Version specifier `_ (any Python in range) * - ``cpython>=3.11`` - `Version specifier `_ restricted to CPython * - ``>=3.13t`` - `Version specifier `_ restricted to free-threaded builds `Version specifiers `_ (``>=``, ``<=``, ``~=``, ``!=``, ``==``, ``===``) are supported. Multiple specifiers can be comma-separated, for example ``>=3.11,<3.13``. Each version is a release of up to three numbers with an optional ``a``, ``b`` or ``rc`` pre-release suffix, such as ``3.12`` or ``3.13.0rc1``; ``==`` and ``!=`` also take a trailing ``.*`` wildcard, as in ``==3.12.*``. The ABI flags, ``-arch`` and ``-machine`` parts described above follow the last specifier and apply to the whole set: ``>=3.13t`` asks for a free-threaded 3.13 or newer, ``cpython>=3.11,<3.14-64`` for a 64-bit CPython in that range. Without ``t`` a specifier accepts any build, while a plain ``3.13`` asks for the default (GIL) build. A string that starts with a comparison operator but does not parse this way, such as ``>=3.x``, ``>=3.10.post1``, ``>=3.12 <4`` (missing comma) or ``>=3.13t,<3.15`` (a flag on an inner item), raises :exc:`ValueError` instead of being looked up as an executable name. python_discovery-1.9.5/docs/index.rst0000644000000000000000000000262215273371321014663 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 .. toctree:: :caption: Project :hidden: changelog contributing python_discovery-1.9.5/docs/_static/custom.css0000644000000000000000000000007015273371321016467 0ustar00.sidebar-logo img { max-width: 100%; width: 100%; } python_discovery-1.9.5/docs/_static/github.svg0000644000000000000000000000127315273371321016454 0ustar00 python_discovery-1.9.5/docs/_static/logo.svg0000644000000000000000000000104315273371321016125 0ustar00 python_discovery-1.9.5/docs/changelog/template.jinja20000644000000000000000000000246715273371321017672 0ustar00{% set top_underline = "*" %} {% set underline = "-" %} .. _release-{{ versiondata.version.replace('.', '-') }}: {% if versiondata.name %} {{ top_underline * ((versiondata.version + versiondata.date)|length + 5)}} v{{ versiondata.version }} ({{ versiondata.date }}) {{ top_underline * ((versiondata.version + versiondata.date)|length + 5)}} {% else %} {{ top_underline * ((versiondata.version + versiondata.date)|length + 4)}} {{ versiondata.version }} ({{ versiondata.date }}) {{ top_underline * ((versiondata.version + versiondata.date)|length + 4)}} {% endif %} {% for section, _ in sections.items() %} {% if sections[section] %} {% for category, val in definitions.items() if category in sections[section]%} .. _release-{{ versiondata.version.replace('.', '-') }}-{{ definitions[category]['name'].lower().replace(' ', '-') }}: {{ definitions[category]['name'] }} {{ underline * definitions[category]['name']|length }} {% if definitions[category]['showcontent'] %} {% for text, values in sections[section][category].items() %} {% set missing = values | reject("in", text) | reject("in", text | replace(":pr:", ":issue:")) | list %} - {{ text }}{% if missing %} ({{ missing|join(', ') }}){% endif %} {% endfor %} {% else %} - {{ sections[section][category]['']|join(', ') }} {% endif %} {% endfor %} {% endif %} {% endfor %} python_discovery-1.9.5/docs/how-to/standalone-usage.rst0000644000000000000000000002154215273371321020225 0ustar00How-to guides ============= Try specific executables first ------------------------------ If you know the path to an interpreter executable, pass it via ``try_first_with`` to check it 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/python3.12"]) if info is not None: print(info.executable) Select a debug build ---------------------- To require a debug (``Py_DEBUG``) interpreter, add the debug marker to the spec. Both the ABI-flag form ``python3.13d`` and the Debian package name ``python3.13-dbg`` work, and each resolves to the same interpreter (Debian ships ``/usr/bin/python3.13d`` and ``/usr/bin/python3.13-dbg`` side by side). A spec with no marker keeps matching either build, so reach for the marker only when you need the debug build. .. code-block:: python from python_discovery import get_interpreter info = get_interpreter("python3.13-dbg") if info is not None: assert info.debug_build Combine the marker with the free-threaded ``t`` flag to select a free-threaded debug build, written ``python3.13td`` (ABI-flag order is ``t`` then ``d``) or ``python3.13t-dbg``. 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) Only absolute directories in ``PATH`` are searched. To select an interpreter in the working directory, use ``./python`` as the spec on POSIX or ``./python.exe`` on Windows. 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. List every interpreter on the system -------------------------------------- Use :func:`~python_discovery.iter_interpreters` to enumerate every Python python-discovery can find. With no spec it yields all known implementations (CPython, PyPy, GraalPy -- see :data:`~python_discovery.KNOWN_IMPLEMENTATIONS`). Pass a spec to filter, exactly like :func:`~python_discovery.get_interpreter`. .. code-block:: python from pathlib import Path from python_discovery import DiskCache, iter_interpreters cache = DiskCache(root=Path("~/.cache/python-discovery").expanduser()) # Every interpreter, no filter for info in iter_interpreters(cache=cache): print(info.executable, info.version_str, info.implementation) # Every CPython 3.10 or newer, newest first newest_first = sorted( iter_interpreters("cpython>=3.10", cache=cache), key=lambda info: info.version_info, reverse=True, ) Enumeration interrogates every candidate as a subprocess on a cold cache. Always pass a :class:`~python_discovery.DiskCache` if you call this more than once. Pick an interpreter from a range, preferring newer ---------------------------------------------------- A common need: search a version range and prefer newer interpreters when more than one matches. Sort the result of :func:`~python_discovery.iter_interpreters` and take the first. .. code-block:: python from pathlib import Path from python_discovery import DiskCache, iter_interpreters cache = DiskCache(root=Path("~/.cache/python-discovery").expanduser()) matches = sorted( iter_interpreters("cpython>=3.10,<3.15", cache=cache), key=lambda info: info.version_info, reverse=True, ) info = matches[0] if matches else None Use :func:`get_interpreter` instead when you only need the first PATH-priority hit; use the sort-and-take pattern when *your* ordering differs from PATH order (newest version, smallest install size, preferred install root, etc.). 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_exe: str +system_executable: str | None +implementation: str +version_info: VersionInfo +architecture: int +platform: str +sysconfig_vars: dict +sysconfig_paths: dict +marker_environment: MarkerEnvironment +machine: str +free_threaded: bool +debug_build: 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_exe # 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.debug_build # True if this is a Py_DEBUG build. info.sysconfig_vars # All sysconfig.get_config_vars() values. info.sysconfig_paths # All sysconfig.get_paths() values. info.marker_environment Use ``marker_environment`` to evaluate :pep:`508` dependency markers for the discovered interpreter. The mapping contains all standard marker keys, including its implementation version and operating system. The exported ``MarkerEnvironment`` type names those keys for type checkers. Prefer :attr:`~python_discovery.PythonInfo.system_exe` over the raw :attr:`~python_discovery.PythonInfo.system_executable` field it reads. The field is typed ``str | None`` because it holds nothing until resolution runs, so a type checker makes you narrow it at every use even though discovery has filled it in by the time you hold a :class:`~python_discovery.PythonInfo`. Read the raw field only when you build a :class:`~python_discovery.PythonInfo` yourself, or call :meth:`~python_discovery.PythonInfo.from_exe` with ``resolve_to_host=False``, and want to tell the two states apart. 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.9.5/docs/reference/api.rst0000644000000000000000000000016415273371321016262 0ustar00API reference ============= .. automodule:: python_discovery :members: :undoc-members: :show-inheritance: python_discovery-1.9.5/docs/reference/environment-variables.rst0000644000000000000000000000301415273371321022020 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` - For invalid values, including empty strings, ``nan``, ``0`` and negative numbers, python-discovery logs a warning and uses the default of 15 seconds - ``inf`` or a value above 2,147,483.647 seconds (about 24.9 days) disables the timeout. This limit applies on all platforms because some subprocess waits accept a signed 32-bit count of milliseconds python_discovery-1.9.5/docs/tutorial/getting-started.rst0000644000000000000000000002163315273371321020527 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. - **Resolution** -- following a virtual environment back to the base interpreter it was built from. 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.system_exe) # /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. Run the same snippet from inside a virtual environment and the two paths part company. :attr:`~python_discovery.PythonInfo.executable` stays the environment's own ``python``, while :attr:`~python_discovery.PythonInfo.system_exe` names the base interpreter that environment was built from. Reach for ``system_exe`` whenever you want the real install rather than a link to it. 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) Listing every interpreter --------------------------- When you need *every* interpreter rather than just the first match -- for example, to show the user a chooser, or to apply your own ranking -- use :func:`~python_discovery.iter_interpreters`. Pass no arguments to enumerate every implementation python-discovery knows about, or pass a spec to filter. .. mermaid:: flowchart TD Call["iter_interpreters(spec, cache)"] --> Yield["yields PythonInfo"] Yield --> A["1. try_first_with paths"] Yield --> B["2. running interpreter"] Yield --> C["3. PATH (left to right)"] Yield --> D["4. uv-managed installs"] style Call fill:#4a90d9,stroke:#2a5f8f,color:#fff style Yield fill:#4a9f4a,stroke:#2a6f2a,color:#fff .. code-block:: python from pathlib import Path from python_discovery import DiskCache, iter_interpreters cache = DiskCache(root=Path("~/.cache/python-discovery").expanduser()) for info in iter_interpreters(cache=cache): print(info.executable, info.version_str, info.implementation) The result is an iterator, so :func:`list`, :func:`sorted`, generator expressions and early ``break`` all work as you would expect. Symlinked aliases (``/bin/python3`` and ``/usr/bin/python3``, or a virtualenv and the base it points at) collapse to a single entry, so you do not see the same install twice. To prefer newer interpreters in a range, sort the result by ``version_info`` after filtering: .. code-block:: python newest_first = sorted( iter_interpreters(">=3.10,<3.15", cache=cache), key=lambda info: info.version_info, reverse=True, ) 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 --> D["d / -dbg / -debug
(optional)"] D --> 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 D fill:#4ad9c4,stroke:#2a8f7f,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.13-dbg`` - Debug (``Py_DEBUG``) CPython 3.13 (``python3.13d`` also works) * - ``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 (`version specifier `_ 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.13td-64-arm64"] --> Parse["PythonSpec.from_string_spec()"] Parse --> Spec["PythonSpec"] Spec --> impl["implementation: cpython"] Spec --> ver["major: 3, minor: 13"] Spec --> ft["free_threaded: True"] Spec --> dbg["debug: 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.13td-64-arm64") spec.implementation # "cpython" spec.major # 3 spec.minor # 13 spec.free_threaded # True spec.debug # 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.9.5/src/python_discovery/__init__.py0000644000000000000000000000152415273371321020402 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, iter_interpreters from ._py_info import KNOWN_ARCHITECTURES, MarkerEnvironment, PythonInfo, normalize_isa from ._py_spec import KNOWN_IMPLEMENTATIONS, PythonSpec from ._specifier import SimpleSpecifier, SimpleSpecifierSet, SimpleVersion __version__ = version("python-discovery") __all__ = [ "KNOWN_ARCHITECTURES", "KNOWN_IMPLEMENTATIONS", "ContentStore", "DiskCache", "MarkerEnvironment", "PyInfoCache", "PythonInfo", "PythonSpec", "SimpleSpecifier", "SimpleSpecifierSet", "SimpleVersion", "__version__", "get_interpreter", "iter_interpreters", "normalize_isa", ] python_discovery-1.9.5/src/python_discovery/_cache.py0000644000000000000000000002037615273371321020053 0ustar00"""Cache Protocol and built-in implementations for Python interpreter discovery.""" from __future__ import annotations import json import logging import os import stat from contextlib import contextmanager, suppress from hashlib import sha256 from os import fsencode from pathlib import Path from tempfile import NamedTemporaryFile from typing import TYPE_CHECKING, Final, Protocol, runtime_checkable if TYPE_CHECKING: from collections.abc import Generator _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, bad_format = self._read_entry() except (ValueError, RecursionError): bad_format = True except OSError: _LOGGER.debug("failed to read %s", self._file, exc_info=True) bad_format = self._file.is_symlink() else: if not bad_format: _LOGGER.debug("got python info from %s", self._file) return data if bad_format: with suppress(OSError): self.remove() return None def _read_entry(self) -> tuple[dict | None, bool]: with open(self._file, encoding="utf-8", opener=self._open_entry) as file: status = os.fstat(file.fileno()) if not stat.S_ISREG(status.st_mode) or ( hasattr(os, "geteuid") and (status.st_uid not in {os.geteuid(), 0} or status.st_mode & 0o022) ): _LOGGER.warning("ignoring untrusted cache entry at %s", self._file) return None, True # A 1 MiB query can expand to 12 MiB when supplementary Unicode is escaped. if status.st_size > 16 << 20: _LOGGER.warning("ignoring oversized cache entry at %s", self._file) return None, True return json.load(file), False @staticmethod def _open_entry(path: str, flags: int) -> int: return os.open(path, flags | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_NONBLOCK", 0)) def write(self, content: dict) -> None: self._folder.mkdir(parents=True, exist_ok=True) temporary: Path | None = None try: with NamedTemporaryFile( mode="w", encoding="utf-8", dir=self._folder, prefix=f".{self._key}.", delete=False ) as file: temporary = Path(file.name) # Compact encoding avoids indentation growth and the streaming encoder's recursion limit. file.write(json.dumps(content, sort_keys=True)) temporary.replace(self._file) except OSError: _LOGGER.debug("failed to write %s", self._file, exc_info=True) else: _LOGGER.debug("wrote python info at %s", self._file) finally: if temporary is not None: with suppress(OSError): temporary.unlink(missing_ok=True) def remove(self) -> None: with suppress(OSError): self._file.unlink() _LOGGER.debug("removed python info at %s", self._file) @contextmanager def locked(self) -> Generator[None]: from filelock import FileLock # ruff:ignore[import-outside-top-level] self._folder.mkdir(parents=True, exist_ok=True) if os.name == "posix": group_shared = bool(self._folder.stat().st_mode & stat.S_IWGRP) lock_folder = self._folder / "locks-v2" lock_folder.mkdir(mode=0o770 if group_shared else 0o700, exist_ok=True) lock = FileLock(str(lock_folder / f"{self._key}.lock"), mode=0o660 if group_shared else 0o600) else: lock = FileLock(str(self._folder / f"{self._key}.lock")) with lock: yield class DiskCache: """ File-system based Python interpreter info cache (``/py_info/4/.json``). :param root: root directory for the on-disk cache. """ def __init__(self, root: Path) -> None: self._root = Path(os.path.abspath(root)) # ruff:ignore[os-path-abspath] # resolve() follows untrusted links @property def _py_info_dir(self) -> Path: return self._root / "py_info" / "4" def py_info(self, path: Path) -> ContentStore: """ Return the content store for the interpreter at *path*. :param path: absolute path to a Python executable. """ if not self._trusted_folder(): return NoOpContentStore() key = sha256(fsencode(path)).hexdigest() return DiskContentStore(self._py_info_dir, key) def _trusted_folder(self) -> bool: if not hasattr(os, "geteuid"): return True user_id = os.geteuid() folder = self._py_info_dir private = {self._root, folder.parent, folder} for candidate in (*reversed(folder.parents), folder): try: try: status = candidate.lstat() except FileNotFoundError: candidate.mkdir(exist_ok=True) status = candidate.lstat() except OSError: _LOGGER.warning("not caching in %s", candidate, exc_info=True) return False if ( not stat.S_ISDIR(status.st_mode) or status.st_uid not in {user_id, 0} or (status.st_mode & 0o022 and (candidate in private or not status.st_mode & stat.S_ISVTX)) ): _LOGGER.warning("not caching in untrusted directory %s", candidate) return False return True def py_info_clear(self) -> None: """Remove all cached interpreter information.""" folder = self._py_info_dir if folder.exists() and self._trusted_folder(): for entry in folder.iterdir(): if entry.suffix == ".json": with suppress(OSError): entry.unlink() class NoOpContentStore(ContentStore): """Content store that does nothing -- implements ContentStore protocol.""" def exists(self) -> bool: # ruff:ignore[no-self-use] return False def read(self) -> dict | None: # ruff:ignore[no-self-use] return None def write(self, content: dict) -> None: pass def remove(self) -> None: pass @contextmanager def locked(self) -> Generator[None]: # ruff:ignore[no-self-use] yield class NoOpCache(PyInfoCache): """Cache that does nothing -- implements PyInfoCache protocol.""" def py_info(self, path: Path) -> NoOpContentStore: # ruff:ignore[unused-method-argument, no-self-use] return NoOpContentStore() def py_info_clear(self) -> None: pass __all__ = [ "ContentStore", "DiskCache", "DiskContentStore", "NoOpCache", "NoOpContentStore", "PyInfoCache", ] python_discovery-1.9.5/src/python_discovery/_cached_py_info.py0000644000000000000000000004133515273371321021740 0ustar00"""Acquire Python information via subprocess interrogation with multi-level caching.""" from __future__ import annotations import hashlib import logging import os import pkgutil import secrets import sys import tempfile from codecs import getincrementaldecoder from collections import OrderedDict from contextlib import contextmanager, suppress from dataclasses import dataclass from functools import lru_cache from io import DEFAULT_BUFFER_SIZE, IncrementalNewlineDecoder from pathlib import Path from select import select from shlex import quote from subprocess import DEVNULL, PIPE, Popen, TimeoutExpired, list2cmdline # ruff:ignore[suspicious-subprocess-import] from threading import Event, Thread from time import monotonic from typing import TYPE_CHECKING, Final from ._cache import NoOpCache from ._py_info import PythonInfo from ._py_info_collect import MIN_INTERROGATE_VERSION, UNSUPPORTED_EXIT_CODE, UNSUPPORTED_MARKER if sys.platform == "win32": # pragma: win32 cover from ._windows._cancel_io import stop_query_reader if TYPE_CHECKING: from collections.abc import Generator, Mapping from typing import IO, TypedDict from ._cache import ContentStore, PyInfoCache class CacheEntryMeta(TypedDict): """Identity of a cached interrogation result: the queried binary, its arguments and the script.""" st_mtime: float path: str hash: str | None arguments: str | None _CACHE: OrderedDict[tuple[Path, str | None], PythonInfo | Exception] = OrderedDict() _CACHE[Path(sys.executable), None] = PythonInfo() _LOGGER: Final[logging.Logger] = logging.getLogger(__name__) _PY_INFO_SCRIPT: Final[Path] = Path(__file__).resolve().parent / "_py_info_collect.py" _DEFAULT_QUERY_TIMEOUT: Final[float] = 15.0 # poll(2) limits milliseconds to a signed C int. _MAX_QUERY_TIMEOUT: Final[float] = (2**31 - 1) / 1000 _MAX_QUERY_OUTPUT: Final[int] = 1 << 20 class _UnsupportedInterpreterError(RuntimeError): """The interpreter is older than the interrogation script supports; retrying cannot help.""" def __init__(self, message: str, executable: str | None = None) -> None: super().__init__(message) self.executable = executable def from_exe( # ruff:ignore[too-many-arguments] cls: type[PythonInfo], cache: PyInfoCache | None, exe: str, env: Mapping[str, str] | None = None, *, raise_on_error: bool = True, ignore_cache: bool = False, executable_args: str | None = None, ) -> PythonInfo | None: env = os.environ if env is None else env if cache is None: result = _get_from_cache(cls, cache, exe, env, ignore_cache=ignore_cache, executable_args=executable_args) else: try: result = _get_from_cache(cls, cache, exe, env, ignore_cache=ignore_cache, executable_args=executable_args) except OSError: _LOGGER.debug("failed to use cache for %s", exe, exc_info=True) result = _get_from_cache(cls, None, exe, env, ignore_cache=True, executable_args=executable_args) if isinstance(result, Exception): if raise_on_error: raise result _LOGGER.info("%s", result) result = None return result def _get_from_cache( # ruff:ignore[too-many-arguments] cls: type[PythonInfo], cache: PyInfoCache | None, exe: str, env: Mapping[str, str], *, ignore_cache: bool = True, executable_args: str | None = None, ) -> PythonInfo | Exception: key = Path(exe), executable_args if not ignore_cache and key in _CACHE: result = _CACHE[key] else: result = _get_via_file_cache(cls, cache, key[0], exe, env, executable_args=executable_args) if _cacheable_result(key[0], result): _CACHE[key] = result else: _CACHE.pop(key, None) # with launcher arguments the queried binary is not the interpreter, so keep the one the query reported if isinstance(result, PythonInfo) and executable_args is None: result.executable = exe return result def _cacheable_result(path: Path, result: PythonInfo | Exception) -> bool: if isinstance(result, PythonInfo): executable = result.original_executable elif isinstance(result, _UnsupportedInterpreterError): executable = result.executable else: return False if executable is None: return False try: return path.samefile(executable) except (OSError, TypeError, ValueError): return False def _get_via_file_cache( # ruff:ignore[too-many-arguments] cls: type[PythonInfo], cache: PyInfoCache | None, path: Path, exe: str, env: Mapping[str, str], *, executable_args: str | None = None, ) -> PythonInfo | Exception: path_text = str(path) try: path_modified = path.stat().st_mtime except OSError: path_modified = -1 try: py_info_hash: str | None = _script_hash() except OSError: py_info_hash = None resolved_cache = cache if cache is not None else NoOpCache() py_info_store = resolved_cache.py_info(path) entry_meta: CacheEntryMeta = { "st_mtime": path_modified, "path": path_text, "hash": py_info_hash, "arguments": executable_args, } with py_info_store.locked(): cached = _read_cache_entry(cls, py_info_store, entry_meta) if cached is not None: return cached failure, py_info = _run_subprocess(cls, exe, env, executable_args) if failure is not None and not isinstance(failure, _UnsupportedInterpreterError): _LOGGER.debug("first subprocess attempt failed for %s (%s), retrying", exe, failure) failure, py_info = _run_subprocess(cls, exe, env, executable_args) if isinstance(failure, _UnsupportedInterpreterError): _LOGGER.warning("%s", failure) if _cacheable_result(path, failure): py_info_store.write({**entry_meta, "unsupported": str(failure)}) return failure if failure is not None: return failure if py_info is None: msg = f"{exe} failed to produce interpreter info" return RuntimeError(msg) if _cacheable_result(path, py_info): py_info_store.write({**entry_meta, "content": py_info.to_dict()}) return py_info def _read_cache_entry( cls: type[PythonInfo], py_info_store: ContentStore, entry_meta: CacheEntryMeta, ) -> PythonInfo | _UnsupportedInterpreterError | None: if not py_info_store.exists() or (data := py_info_store.read()) is None: return None if isinstance(data, dict) and all(data.get(key) == value for key, value in entry_meta.items()): if isinstance(unsupported := data.get("unsupported"), str): return _UnsupportedInterpreterError(unsupported, entry_meta["path"]) if isinstance(content := data.get("content"), dict): return _load_cached_py_info(cls, py_info_store, content) py_info_store.remove() return None def _load_cached_py_info( cls: type[PythonInfo], py_info_store: ContentStore, content: dict, ) -> PythonInfo | None: try: py_info = cls.from_dict(content.copy()) stale = (sys_exe := py_info.system_executable) is not None and not Path(sys_exe).is_file() except (AttributeError, KeyError, OSError, TypeError, ValueError): stale = True if stale: py_info_store.remove() return None return py_info @lru_cache(maxsize=1) def _script_hash() -> str: return hashlib.sha256(_PY_INFO_SCRIPT.read_bytes()).hexdigest() COOKIE_LENGTH: Final[int] = 32 def gen_cookie() -> str: return secrets.token_hex(COOKIE_LENGTH // 2) @contextmanager def _resolve_py_info_script() -> Generator[Path]: if _PY_INFO_SCRIPT.is_file(): yield _PY_INFO_SCRIPT else: data = pkgutil.get_data(__package__ or __name__, _PY_INFO_SCRIPT.name) if data is None: msg = f"cannot locate {_PY_INFO_SCRIPT.name} for subprocess interrogation" raise FileNotFoundError(msg) # Python prepends the probe script's directory to sys.path, so keep that directory private. with tempfile.TemporaryDirectory() as private_dir: script: Final[Path] = Path(private_dir) / _PY_INFO_SCRIPT.name script.write_bytes(data) yield script def _extract_between_cookies(out: str, start_cookie: str, end_cookie: str) -> tuple[str, str, int, int]: """Extract payload between reversed cookie markers, forwarding any surrounding output to stdout.""" raw_out = out out_starts = out.find(start_cookie[::-1]) if out_starts > -1: if pre_cookie := out[:out_starts]: sys.stdout.write(pre_cookie) out = out[out_starts + COOKIE_LENGTH :] out_ends = out.find(end_cookie[::-1]) if out_ends > -1: if post_cookie := out[out_ends + COOKIE_LENGTH :]: sys.stdout.write(post_cookie) out = out[:out_ends] return out, raw_out, out_starts, out_ends def _run_subprocess( cls: type[PythonInfo], exe: str, env: Mapping[str, str], executable_args: str | None = None, ) -> tuple[Exception | None, PythonInfo | None]: start_cookie = gen_cookie() end_cookie = gen_cookie() timeout = _query_timeout(env.get("PY_DISCOVERY_TIMEOUT")) with _resolve_py_info_script() as py_info_script: cmd: list[str] | str = [exe, str(py_info_script), start_cookie, end_cookie] if executable_args is not None: # PEP 514 ExecutableArguments is raw command line text, placed before the script per the PEP cmd = f"{list2cmdline(cmd[:1])} {executable_args} {list2cmdline(cmd[1:])}" env = dict(env) env.pop("__PYVENV_LAUNCHER__", None) env["PYTHONUTF8"] = "1" _LOGGER.debug("get interpreter info via cmd: %s", cmd if isinstance(cmd, str) else LogCmd(cmd)) try: process = Popen( # ruff:ignore[subprocess-without-shell-equals-true] cmd, universal_newlines=True, stdin=DEVNULL, stderr=PIPE, stdout=PIPE, env=env, encoding="utf-8", errors="backslashreplace", ) out, err, code = _collect_query_output(process, timeout) except OSError as os_error: out, err, code = "", os_error.strerror, os_error.errno if code != 0: return _query_failure(exe, out, err, code), None out, raw_out, out_starts, out_ends = _extract_between_cookies(out, start_cookie, end_cookie) if out_starts < 0 or out_ends < 0: _LOGGER.warning("subprocess %s omitted query markers; stdout %d chars", exe, len(raw_out)) msg = f"{exe} returned output without query markers" return RuntimeError(msg), None try: result = cls.from_json(out) if executable_args is None: result.executable = exe except (AttributeError, KeyError, TypeError, ValueError) as exc: _LOGGER.warning( "subprocess %s returned invalid interpreter information; " "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 interpreter information (exit code {code}){f', stderr: {err!r}' if err else ''}" failure = RuntimeError(msg) failure.__cause__ = exc return failure, None return None, result def _collect_query_output(process: Popen[str], timeout: float | None) -> tuple[str, str, int]: stdout, stderr = process.stdout, process.stderr if stdout is None or stderr is None: msg = "query output pipes are required" raise ValueError(msg) out_chunks: list[str] = [] err_chunks: list[str] = [] state = _QueryReadState(process=process, overflow=Event(), stop=Event(), read_errors=[]) readers = ( Thread(target=_drain_query_stream, args=(state, stdout, out_chunks), daemon=True), Thread(target=_drain_query_stream, args=(state, stderr, err_chunks), daemon=True), ) for reader in readers: reader.start() if not _query_completed(process, readers, timeout): state.stop.set() with suppress(OSError): process.kill() # A descendant may keep the pipes open after the candidate exits. process.wait() _stop_query_readers(readers) return "", "timed out", -1 if state.read_errors: raise state.read_errors[0] if state.overflow.is_set(): return "", f"produced more than {_MAX_QUERY_OUTPUT} characters of output", -1 return "".join(out_chunks), "".join(err_chunks), process.wait() @dataclass(frozen=True) class _QueryReadState: process: Popen[str] overflow: Event stop: Event read_errors: list[OSError] def _stop_query_readers(readers: tuple[Thread, Thread]) -> None: for reader in readers: if sys.platform == "win32": # pragma: win32 cover stop_query_reader(reader) else: reader.join() def _query_completed(process: Popen[str], readers: tuple[Thread, Thread], timeout: float | None) -> bool: deadline = None if timeout is None else monotonic() + timeout for reader in readers: reader.join(None if deadline is None else max(deadline - monotonic(), 0)) if any(reader.is_alive() for reader in readers): return False try: process.wait(timeout=None if deadline is None else max(deadline - monotonic(), 0)) except TimeoutExpired: return False return True def _drain_query_stream(state: _QueryReadState, stream: IO[str], chunks: list[str]) -> None: size = 0 failed = False decoder = IncrementalNewlineDecoder(getincrementaldecoder("utf-8")("backslashreplace"), translate=True) with stream: while not state.stop.is_set(): try: if sys.platform != "win32" and not select([stream], [], [], 0.05)[0]: # pragma: win32 cover continue data = os.read(stream.fileno(), DEFAULT_BUFFER_SIZE) except OSError as error: if not state.stop.is_set(): # pragma: win32 cover # canceled ReadFile raises on Windows state.read_errors.append(error) failed = True break chunk = decoder.decode(data, final=not data) if chunk: chunks.append(chunk[: _MAX_QUERY_OUTPUT + 1 - size]) size += len(chunk) if size > _MAX_QUERY_OUTPUT: state.overflow.set() failed = True break if not data: break if failed: with suppress(OSError): state.process.kill() @lru_cache(maxsize=128) def _query_timeout(raw: str | None) -> float | None: if raw is None: return _DEFAULT_QUERY_TIMEOUT try: timeout = float(raw) except ValueError: timeout = 0.0 if timeout > 0: return timeout if timeout <= _MAX_QUERY_TIMEOUT else None _LOGGER.warning( "ignoring PY_DISCOVERY_TIMEOUT=%r, not a positive number of seconds; using %s", raw, _DEFAULT_QUERY_TIMEOUT ) return _DEFAULT_QUERY_TIMEOUT def _query_failure(exe: str, out: str, err: str | None, code: int | None) -> RuntimeError: # the gate's exit code and stderr marker must both be present: either alone can collide, an errno of 79 from a # failed exec or a shim that echoes the marker phrase, but only the gate produces the combination if code == UNSUPPORTED_EXIT_CODE and err is not None and (at := err.find(UNSUPPORTED_MARKER)) != -1: details = err[at + len(UNSUPPORTED_MARKER) :].splitlines() version = details[0].split() if details else [] floor = ".".join(str(i) for i in MIN_INTERROGATE_VERSION) found = version[0] if version else "unknown" msg = f"{exe} is Python {found}, older than the minimum {floor} python-discovery can query" return _UnsupportedInterpreterError(msg, details[1].strip() if len(details) > 1 else None) 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}") 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.9.5/src/python_discovery/_compat.py0000644000000000000000000000130515273371321020262 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.9.5/src/python_discovery/_discovery.py0000644000000000000000000006115715273371321021021 0ustar00from __future__ import annotations import logging import os import sys from contextlib import suppress from dataclasses import dataclass from itertools import tee from os.path import basename from pathlib import Path from typing import TYPE_CHECKING, Final from ._compat import fs_path_id from ._py_info import PythonInfo from ._py_spec import KNOWN_IMPLEMENTATIONS, PythonSpec from ._specifier import DC_KW, SimpleVersion if TYPE_CHECKING: from collections.abc import Callable, Generator, Iterable, Iterator, 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 `version specifier `_ (``>=3.10``). When a sequence is given each entry is tried in order. :param try_first_with: executables to probe before the normal discovery search. :param cache: interpreter metadata cache; when ``None`` results are not cached. :param env: environment mapping for ``PATH`` and uv store lookup; defaults to :data:`os.environ`. :param predicate: optional callback applied after an interpreter matches the spec. Return ``True`` to accept the interpreter, ``False`` to skip it and continue searching. :return: the first matching interpreter, or ``None`` if no match is found. """ specs = [key] if isinstance(key, str) else key for spec_str, preferred_paths in zip(specs, tee(try_first_with or (), len(specs))): if result := _find_interpreter(spec_str, preferred_paths, cache, env, predicate): return result return None def iter_interpreters( key: str | Sequence[str] | None = None, try_first_with: Iterable[str] | None = None, cache: PyInfoCache | None = None, env: Mapping[str, str] | None = None, predicate: Callable[[PythonInfo], bool] | None = None, ) -> Iterator[PythonInfo]: """ Yield every interpreter on the system that satisfies *key*. Iteration order is discovery order: ``try_first_with`` paths first, then the running interpreter, then ``PATH`` (left to right), then UV-managed installs. Results are deduplicated by the resolved real path of the underlying system interpreter, so symlinked aliases (``/bin`` vs ``/usr/bin``) and venvs that symlink to a base interpreter collapse to a single entry. Callers that want a different ordering should sort the result. :param key: interpreter specification — same syntax as :func:`get_interpreter`. ``None`` enumerates every Python implementation python-discovery knows about (see :data:`KNOWN_IMPLEMENTATIONS`). :param try_first_with: executables to probe before the normal discovery search. :param cache: interpreter metadata cache; when ``None`` results are not cached. Strongly recommended for enumeration, which interrogates every candidate as a subprocess on a cold cache. :param env: environment mapping for ``PATH`` and uv store lookup; defaults to :data:`os.environ`. :param predicate: optional filter applied after the spec match; return ``True`` to include the interpreter. """ if key is None: keys: tuple[str | None, ...] = (None,) elif isinstance(key, str): keys = (key,) else: keys = tuple(key) first_with = tuple(try_first_with or ()) env_map = os.environ if env is None else env seen: set[str] = set() for spec_str in keys: yield from _iter_for_spec(spec_str, first_with, cache, env_map, predicate, seen) def _iter_for_spec( # ruff:ignore[too-many-arguments, too-many-positional-arguments] spec_str: str | None, try_first_with: tuple[str, ...], cache: PyInfoCache | None, env: Mapping[str, str], predicate: Callable[[PythonInfo], bool] | None, seen: set[str], ) -> Iterator[PythonInfo]: if spec_str is None: spec = PythonSpec("", None, None, None, None, None, None) wide = True else: spec = PythonSpec.from_string_spec(spec_str) wide = False for interpreter, impl_must_match in propose_interpreters( spec, try_first_with, cache, env, all_implementations=wide ): if interpreter is None: continue if (anchor := interpreter.system_executable or interpreter.executable) is None: continue if (real_path := os.path.realpath(anchor)) in seen: continue if not interpreter.satisfies(spec, impl_must_match=impl_must_match): continue if predicate is not None and not predicate(interpreter): continue seen.add(real_path) yield interpreter def _find_interpreter( key: str, try_first_with: Iterable[str], 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) # unlike Path.absolute(), abspath collapses "..", which PythonInfo._satisfies_path relies on return os.path.abspath(path) def _is_new_exe(exe_raw: str, tested_exes: set[str]) -> bool: """Return ``True`` and register *exe_raw* if it hasn't been tested yet.""" exe_id = fs_path_id(exe_raw) if exe_id in tested_exes: return False tested_exes.add(exe_id) return True def propose_interpreters( spec: PythonSpec, try_first_with: Iterable[str], cache: PyInfoCache | None = None, env: Mapping[str, str] | None = None, *, all_implementations: bool = False, ) -> Generator[tuple[PythonInfo | None, bool], None, None]: """ Yield ``(interpreter, impl_must_match)`` candidates for *spec*. :param spec: the parsed interpreter specification to match against. :param try_first_with: executable paths to probe before the standard search. :param cache: interpreter metadata cache; when ``None`` results are not cached. :param env: environment mapping for ``PATH`` and uv store lookup; defaults to :data:`os.environ`. :param all_implementations: when ``True`` and *spec* does not constrain the implementation, also surface non-CPython binaries on ``PATH`` and under UV's install directory. Used by enumeration APIs. """ 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, all_implementations=all_implementations) yield from _propose_from_uv(spec, cache, env, all_implementations=all_implementations) 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 Path(py_exe).exists() and (exe_raw := _check_exe(py_exe, tested_exes)): yield PythonInfo.from_exe(exe_raw, cache, env=env), True if spec.path is not None: # Unparsed bare names reach spec.path; only an explicit directory should search the working directory. if os.path.dirname(spec.path) and (exe_raw := _check_exe(spec.path, tested_exes)): 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 # ruff:ignore[import-outside-top-level] 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], *, all_implementations: bool = False, ) -> Generator[tuple[PythonInfo | None, bool], None, None]: find_candidates = path_exe_finder(spec, all_implementations=all_implementations) for pos, path in enumerate(get_paths(env)): _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( spec: PythonSpec, cache: PyInfoCache | None, env: Mapping[str, str], *, all_implementations: bool = False, ) -> Generator[tuple[PythonInfo | None, bool], None, None]: seen_uv_paths: set[str] = set() for root in _uv_python_roots(env): for install in _uv_installs(root, spec, all_implementations=all_implementations): for exe_path in _uv_executables(install): if (resolved := str(exe_path.resolve())) in seen_uv_paths: continue seen_uv_paths.add(resolved) if interpreter := PathPythonInfo.from_exe(str(exe_path), cache, raise_on_error=False, env=env): yield interpreter, True break # one install directory holds one interpreter def _uv_python_roots(env: Mapping[str, str]) -> Generator[Path, None, None]: """Yield uv's store roots, legacy first; searching both keeps a stale legacy directory from hiding the live one.""" if install_dir := env.get("UV_PYTHON_INSTALL_DIR"): yield Path(install_dir).expanduser() return for state_dir in _uv_state_dirs(env): if state_dir.is_absolute(): # a relative profile path would search the caller's working directory yield state_dir / "python" def _uv_state_dirs(env: Mapping[str, str]) -> Generator[Path, None, None]: """Yield uv's legacy state directory then its current one, the order uv prefers them in.""" windows: Final = sys.platform == "win32" home: Final = Path(profile) if (profile := env.get("USERPROFILE" if windows else "HOME")) else None if windows: app_data = Path(roaming) if (roaming := env.get("APPDATA")) else None if app_data is None or not app_data.is_absolute(): app_data = home / "AppData" / "Roaming" if home is not None else None if app_data is not None: yield app_data / "uv" / "data" yield app_data / "uv" return if sys.platform == "darwin" and home is not None: yield home / "Library" / "Application Support" / "uv" # uv ignores a relative XDG_DATA_HOME, as the XDG specification requires if (xdg_data_home := env.get("XDG_DATA_HOME")) and (xdg := Path(xdg_data_home)).is_absolute(): yield xdg / "uv" elif home is not None: yield home / ".local" / "share" / "uv" def _uv_installs(root: Path, spec: PythonSpec, *, all_implementations: bool) -> list[_UvInstall]: try: matching = [ install for entry in sorted(root.iterdir()) if not entry.name.startswith(".") and entry.is_dir() and _uv_install_matches(install := _parse_uv_install(entry), spec, all_implementations=all_implementations) ] except OSError: return [] matching.sort(key=_uv_sort_key) # stable, so equally ranked installs stay in directory name order return matching def _parse_uv_install(path: Path) -> _UvInstall: implementation, _, remainder = path.name.partition("-") version_str, _, variant = remainder.partition("-")[0].partition("+") try: version = SimpleVersion.from_string(version_str) except ValueError: # not a uv key, but the directory may still hold an interpreter worth probing return _UvInstall(path=path, key=None) variants = variant.split("+") key = _UvKey( implementation=implementation.lower(), version=version, free_threaded="freethreaded" in variants, debug="debug" in variants, ) return _UvInstall(path=path, key=key) @dataclass(**DC_KW) class _UvKey: """The ``-[+]---`` name uv gives an install directory.""" implementation: str version: SimpleVersion free_threaded: bool debug: bool @dataclass(**DC_KW) class _UvInstall: path: Path key: _UvKey | None def _uv_install_matches(install: _UvInstall, spec: PythonSpec, *, all_implementations: bool) -> bool: if (key := install.key) is None: return True if spec.implementation is not None: if spec.implementation.lower() != key.implementation: return False elif not all_implementations and key.implementation != "cpython": # uv resolves a bare `3.8` to `cpython-3.8`; on PATH the equivalent probe finds CPython too return False if spec.free_threaded is not None and spec.free_threaded != key.free_threaded: return False if spec.debug is not None and spec.debug != key.debug: return False return _uv_version_matches(key, spec) def _uv_version_matches(key: _UvKey, spec: PythonSpec) -> bool: # GraalPy keys carry the GraalVM version in older uv releases, so only CPython and PyPy state a Python version if key.implementation not in {"cpython", "pypy"}: return True if spec.version_specifier is not None: for specifier in spec.version_specifier: if specifier.version is None: return False # Match at the requested precision, as PythonInfo.satisfies does after probing. numeric_version = specifier.version_str for prefix in ("rc", "b", "a"): if prefix in numeric_version: numeric_version = numeric_version.split(prefix)[0] break precision = numeric_version.count(".") + 1 release = ".".join(str(part) for part in key.version.release[:precision]) if key.version.pre_type is not None and ( precision == 3 or specifier.version.pre_type is not None # ruff:ignore[magic-value-comparison] ): release = f"{release}{key.version.pre_type}{key.version.pre_num}" if not specifier.contains(release): return False return all(req is None or our == req for our, req in zip(key.version.release, (spec.major, spec.minor, spec.micro))) def _uv_sort_key(install: _UvInstall) -> tuple[int, ...]: """Rank like uv does: CPython first, newest version first, default build ahead of free-threaded and debug.""" impl_order: Final[tuple[str, ...]] = ("cpython", "pypy", "graalpy", "pyodide") if (key := install.key) is None: return (len(impl_order) + 1,) version = key.version return ( impl_order.index(key.implementation) if key.implementation in impl_order else len(impl_order), # uv's minor version links point at a concrete install that moves on upgrade, so report the target instead; # spot them by the name they resolve to, as on Windows they are junctions, which is_symlink() does not report int(install.path.resolve().name != install.path.name), -version.major, -version.minor, -version.micro, -{"a": 0, "b": 1, "rc": 2, None: 3}[version.pre_type], -(version.pre_num or 0), int(key.free_threaded), int(key.debug), ) def _uv_executables(install: _UvInstall) -> Generator[Path, None, None]: # some distributions unpack into an `install` subdirectory, which uv resolves the same way base = nested if (nested := install.path / "install").is_dir() else install.path for pattern in ( "bin/python", "python.exe", "bin/pypy*", "pypy*.exe", "bin/graalpy", "bin/graalpy.exe", "python*t.exe", # free-threaded Windows builds that ship no python.exe (astral-sh/uv#8298) ): yield from sorted(base.glob(pattern)) def get_paths(env: Mapping[str, str]) -> Generator[Path, None, None]: path = env.get("PATH", None) if path is None: try: path = os.confstr("CS_PATH") except (AttributeError, ValueError): # pragma: no cover # Windows only (no confstr) path = os.defpath if path: # Relative entries can execute an interpreter supplied by the working directory. for entry in map(Path, filter(None, path.split(os.pathsep))): with suppress(OSError): if entry.is_absolute() and entry.is_dir() and next(entry.iterdir(), None): yield entry class LazyPathDump: def __init__(self, pos: int, path: Path, env: Mapping[str, str]) -> None: self.pos = pos self.path = path self.env = env def __repr__(self) -> str: content = f"discover PATH[{self.pos}]={self.path}" if self.env.get("_VIRTUALENV_DEBUG"): content += " with =>" for file_path in self.path.iterdir(): try: if not self._is_executable(file_path): continue except OSError: pass content += " " content += file_path.name return content def _is_executable(self, file_path: Path) -> bool: if file_path.is_dir(): return False if IS_WIN: # pragma: win32 cover pathext = self.env.get("PATHEXT", ".COM;.EXE;.BAT;.CMD").split(";") return any(file_path.name.upper().endswith(ext) for ext in pathext) return bool(file_path.stat().st_mode & os.X_OK) def path_exe_finder( spec: PythonSpec, *, all_implementations: bool = False ) -> Callable[[Path], Generator[tuple[Path, bool], None, None]]: """Given a spec, return a function that can be called on a path to find all matching files in it.""" pat = spec.generate_re(windows=sys.platform == "win32", all_implementations=all_implementations) direct = spec.str_spec implementation = spec.implementation.lower() if spec.implementation is not None else None impl_must_match = implementation in KNOWN_IMPLEMENTATIONS and implementation != "python" if sys.platform == "win32": # pragma: win32 cover direct = f"{direct}.exe" def path_exes(path: Path) -> Generator[tuple[Path, bool], None, None]: # an empty spec (enumerating everything) would make the direct path the PATH directory itself direct_path = path / direct if direct_path.is_file(): yield direct_path, impl_must_match for exe in path.iterdir(): if (match := pat.fullmatch(exe.name)) and exe.is_file(): yield exe.absolute(), impl_must_match or match["impl"].lower() == "python" return path_exes def _resolve_shim(exe_path: str, env: Mapping[str, str]) -> str | None: """Resolve a version-manager shim to the actual Python binary.""" for shims_dir_env, versions_path in _VERSION_MANAGER_LAYOUTS: if root := env.get(shims_dir_env): shims_dir = os.path.join(root, "shims") if os.path.dirname(exe_path) == shims_dir: exe_name = os.path.basename(exe_path) versions_dir = os.path.join(root, *versions_path) return _resolve_shim_to_binary(exe_name, versions_dir, env) return None _VERSION_MANAGER_LAYOUTS: list[tuple[str, tuple[str, ...]]] = [ ("PYENV_ROOT", ("versions",)), ("MISE_DATA_DIR", ("installs", "python")), ("ASDF_DATA_DIR", ("installs", "python")), ] def _resolve_shim_to_binary(exe_name: str, versions_dir: str, env: Mapping[str, str]) -> str | None: for version in _active_versions(env): if version in {"", ".", ".."} or basename(version) != version: _LOGGER.debug("ignoring invalid version name %r", version) continue resolved = os.path.join(versions_dir, version, "bin", exe_name) if Path(resolved).is_file() and os.access(resolved, os.X_OK): return resolved return None def _active_versions(env: Mapping[str, str]) -> Generator[str, None, None]: """Yield active Python version strings by reading version-manager configuration.""" if pyenv_version := env.get("PYENV_VERSION"): yield from pyenv_version.split(":") return if versions := _read_python_version_file(Path.cwd()): yield from versions return if (pyenv_root := env.get("PYENV_ROOT")) and ( versions := _read_python_version_file(os.path.join(pyenv_root, "version"), search_parents=False) ): yield from versions def _read_python_version_file(start: str | Path, *, search_parents: bool = True) -> list[str] | None: """Read a ``.python-version`` file, optionally searching parent directories.""" current = start while True: # unlike pathlib before 3.14, os.path answers False when a directory on the way can't be searched candidate = os.path.join(current, ".python-version") if os.path.isdir(current) else current if os.path.isfile(candidate) and (versions := _versions_in_file(candidate)): return versions if not search_parents: return None parent = Path(current).parent if parent == current: return None current = parent def _versions_in_file(path: str | Path) -> list[str]: try: # pyenv reads raw bytes, so a byte that is not UTF-8 (a Latin-1 comment, say) must not hide the versions with Path(path).open(encoding="utf-8", errors="replace") as fh: # Version files can sit in shared parent directories; bound the in-process read. max_chars = 4096 content = fh.read(max_chars + 1) if len(content) > max_chars: _LOGGER.debug("ignored oversized version file %s", path) return [] return [v for line in content.split("\n") if (v := line.strip()) and not v.startswith("#")] except OSError: # skip a file we cannot read, the same as one that names no version _LOGGER.debug("failed to read %s", path, exc_info=True) return [] class PathPythonInfo(PythonInfo): """python info from path.""" __all__ = [ "LazyPathDump", "PathPythonInfo", "get_interpreter", "get_paths", "iter_interpreters", "propose_interpreters", ] python_discovery-1.9.5/src/python_discovery/_py_info.py0000644000000000000000000006677615273371321020471 0ustar00"""Concrete Python interpreter information, collected in-process or via subprocess interrogation.""" from __future__ import annotations import copy import json import logging import os import platform import sys from collections import OrderedDict from itertools import product from os.path import normcase, realpath from string import Formatter, digits from typing import TYPE_CHECKING, ClassVar, Final, NamedTuple, TypedDict from ._py_info_collect import PythonInfoCollector if TYPE_CHECKING: from collections.abc import Generator, Mapping from typing import Union from ._cache import PyInfoCache from ._py_spec import PythonSpec InfoValue = Union[str, int, bool, None, "tuple[int | str, ...]", "list[str]", "dict[str, str | int | None]"] 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() class PythonInfo: # ruff:ignore[too-many-public-methods] """Contains information for a Python interpreter.""" platform: str implementation: str pypy_version_info: tuple[int, ...] version_info: VersionInfo architecture: int sysconfig_platform: str | None version_nodot: str | None version: str os: str free_threaded: bool debug_build: bool marker_environment: MarkerEnvironment prefix: str | None base_prefix: str | None real_prefix: str | None base_exec_prefix: str | None exec_prefix: str | None executable: str original_executable: str system_executable: str | None has_venv: bool path: list[str] file_system_encoding: str stdout_encoding: str | None sysconfig_scheme: str | None sysconfig_paths: dict[str, str] distutils_install: dict[str, str] sysconfig: dict[str, str | None] sysconfig_vars: dict[str, str | int | None] tcl_lib: str | None tk_lib: str | None system_stdlib: str system_stdlib_platform: str max_size: int def __init__(self) -> None: self.__dict__.update(vars(self.from_dict(PythonInfoCollector().to_dict()))) 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 @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 | int | None] | None = None, sep: str = os.sep) -> str: """ Return the sysconfig install path for a scheme *key*, optionally substituting config variables. :param key: sysconfig path key (e.g. ``"purelib"``, ``"include"``). :param config_var: replacement mapping for sysconfig variables; when ``None`` uses the interpreter's own values. :param sep: path separator to use in the result. """ pattern = self.sysconfig_paths.get(key) if pattern is None: return "" if any(format_spec for _, _, format_spec, _ in Formatter().parse(pattern)): msg = f"sysconfig path {key!r} contains format specifiers" raise ValueError(msg) if config_var is None: config_var = self.sysconfig_vars else: base = self.sysconfig_vars.copy() base.update(config_var) config_var = base return pattern.format(**config_var).replace("/", sep) @property def system_include(self) -> str: """The path to the system include directory for C headers.""" path = self.sysconfig_path( "include", { k: (self.system_prefix if isinstance(v, str) and v.startswith(self.prefix) else v) for k, v in self.sysconfig_vars.items() }, ) # An empty headers path would make the fallback resolve to the existing prefix. if not os.path.exists(path) and (headers := os.path.dirname(self.install_path("headers"))): fallback = os.path.join(self.prefix, headers) if os.path.exists(fallback): path = fallback return path @property def system_prefix(self) -> str: """The prefix of the system Python this interpreter is based on.""" return self.real_prefix or self.base_prefix or self.prefix @property def system_exec_prefix(self) -> str: """The exec prefix of the system Python this interpreter is based on.""" return self.real_prefix or self.base_exec_prefix or self.exec_prefix @property def system_exe(self) -> str: """ The executable of the system Python this interpreter is based on. :attr:`system_executable` stays ``None`` until :meth:`resolve_to_system` walks the prefix chain, so reading it forces every caller to narrow a value discovery has already settled. The fallback here matches what resolution itself writes when a prefix links back to its own interpreter. """ return self.system_executable or self.executable def __repr__(self) -> str: return "{}({!r})".format( self.__class__.__name__, {k: v for k, v in self.__dict__.items() if not k.startswith("_")}, ) def __str__(self) -> str: return "{}({})".format( self.__class__.__name__, ", ".join( f"{k}={v}" for k, v in ( ("spec", self.spec), ( "system" if self.system_executable is not None and self.system_executable != self.executable else None, self.system_executable, ), ( "original" if self.original_executable not in {self.system_executable, self.executable} else None, self.original_executable, ), ("exe", self.executable), ("platform", self.platform), ("version", repr(self.version)), ("encoding_fs_io", f"{self.file_system_encoding}-{self.stdout_encoding}"), ) if k is not None ), ) @property def machine(self) -> str: """The instruction set architecture (ISA) derived from :func:`sysconfig.get_platform`.""" plat = self.sysconfig_platform if plat is None: return "unknown" if plat == "win32": return "x86" isa = plat.rsplit("-", 1)[-1] if isa == "universal2": isa = platform.machine().lower() return normalize_isa(isa) @property def spec(self) -> str: """A specification string identifying this interpreter (e.g. ``CPython3.13.2-64-arm64``).""" return "{}{}{}{}-{}-{}".format( self.implementation, ".".join(str(i) for i in self.version_info), "t" if self.free_threaded else "", "d" if self.debug_build else "", self.architecture, self.machine, ) @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 # ruff:ignore[import-outside-top-level] clear(cache) cls._cache_exe_discovery.clear() def satisfies(self, spec: PythonSpec, *, impl_must_match: bool) -> bool: # ruff:ignore[too-many-return-statements] """ 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.debug is not None and spec.debug != self.debug_build: 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.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: if specifier.version is None: # a hand-built spec can carry an unparsable version, which matches nothing return False 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) # ruff:ignore[magic-value-comparison] 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, InfoValue]: """Convert this interpreter information to a plain dictionary.""" data = {name: None if name == "_creators" else value for name, value in vars(self).items()} version_info = data["version_info"] data["version_info"] = version_info._asdict() if hasattr(version_info, "_asdict") else version_info return data @classmethod def from_exe( # ruff:ignore[too-many-arguments] cls, exe: str, cache: PyInfoCache | None = None, *, raise_on_error: bool = True, ignore_cache: bool = False, resolve_to_host: bool = True, env: Mapping[str, str] | None = None, ) -> PythonInfo | None: """ Get the python information for a given executable path. :param exe: path to the Python executable. :param cache: interpreter metadata cache; when ``None`` results are not cached. :param raise_on_error: raise on failure instead of returning ``None``. :param ignore_cache: bypass the cache and re-query the interpreter. :param resolve_to_host: resolve through virtualenv layers to the system interpreter. :param env: environment mapping; defaults to :data:`os.environ`. """ return cls._from_exe_with_args( exe, cache, executable_args=None, raise_on_error=raise_on_error, ignore_cache=ignore_cache, resolve_to_host=resolve_to_host, env=env, ) @classmethod def _from_exe_with_args( # ruff:ignore[too-many-arguments] cls, exe: str, cache: PyInfoCache | None = None, *, executable_args: str | None, raise_on_error: bool = True, ignore_cache: bool = False, resolve_to_host: bool = True, env: Mapping[str, str] | None = None, ) -> PythonInfo | None: """Interrogate an executable with optional platform-specific leading arguments.""" from ._cached_py_info import from_exe # ruff:ignore[import-outside-top-level] env = os.environ if env is None else env proposed = from_exe( cls, cache, exe, env=env, raise_on_error=raise_on_error, ignore_cache=ignore_cache, executable_args=executable_args, ) if isinstance(proposed, PythonInfo) and resolve_to_host: try: proposed = proposed.resolve_to_system(cache, proposed, env=env) except Exception as exception: if raise_on_error: raise _LOGGER.info("ignore %s due cannot resolve system due to %r", proposed.original_executable, exception) proposed = None return proposed @classmethod def from_json(cls, payload: str) -> PythonInfo: """ Deserialize interpreter information from a JSON string. :param payload: JSON produced by :meth:`to_json`. """ raw = json.loads(payload) return cls.from_dict(raw.copy()) @classmethod def from_dict(cls, data: dict[str, InfoValue]) -> PythonInfo: """ Reconstruct a :class:`PythonInfo` from a plain dictionary. :param data: dictionary produced by :meth:`to_dict`. """ if missing := PythonInfo.__annotations__.keys() - data.keys() - {"_cache_exe_discovery", "pypy_version_info"}: msg = f"missing interpreter information: {', '.join(sorted(missing))}" raise ValueError(msg) data["version_info"] = VersionInfo(**data["version_info"]) # restore this to a named tuple structure result = cls.__new__(cls) # skip __init__, data replaces the full state result.__dict__ = data.copy() return result @classmethod def resolve_to_system( cls, cache: PyInfoCache | None, target: PythonInfo, *, env: Mapping[str, str] | None = None ) -> PythonInfo: """ Walk virtualenv/venv prefix chains to find the underlying system interpreter. :param cache: interpreter metadata cache; when ``None`` results are not cached. :param target: the interpreter to resolve. :param env: environment mapping; defaults to :data:`os.environ`. """ start_executable = target.executable prefixes = OrderedDict() while target.system_executable is None: prefix = target.real_prefix or target.base_prefix or target.prefix prefix_key = normcase(realpath(prefix)) if prefix_key in prefixes: if len(prefixes) == 1: _LOGGER.info("%r links back to itself via prefixes", target) target.system_executable = target.executable break for at, (p, t) in enumerate(prefixes.items(), start=1): _LOGGER.error("%d: prefix=%s, info=%r", at, p, t) _LOGGER.error("%d: prefix=%s, info=%r", len(prefixes) + 1, prefix, target) msg = "prefixes are causing a circle {}".format("|".join(prefixes.keys())) raise RuntimeError(msg) prefixes[prefix_key] = target target = target.discover_exe(cache, prefix=prefix, exact=False, env=env) if target.executable != target.system_executable: resolved = cls.from_exe(target.system_executable, cache, env=env) if resolved is not None: target = resolved if target.executable != start_executable: # the cache hands every lookup of this base the same object, so point a copy at the start executable target = copy.copy(target) target.executable = start_executable return target _cache_exe_discovery: ClassVar[dict[tuple[str, bool, VersionInfo], PythonInfo]] = {} def discover_exe( self, cache: PyInfoCache, 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, self.version_info 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( # ruff:ignore[too-many-arguments] 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", "free_threaded", "debug_build"]: 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() mods = ["", "t"] if self.free_threaded else [""] # _d (Windows python_d.exe), -dbg (Debian's own name), d (the abiflags form used by # upstream/Fedora and also shipped by Debian, so pythonX.Yd / pythonX.Ytd resolve too) debug_suffixes = ["_d", "-dbg", "d", ""] if self.debug_build else [""] archs = [f"-{self.architecture}", ""] for name in self._possible_base(): for at in (3, 2, 1, 0): version = ".".join(str(i) for i in self.version_info[:at]) for mod, debug, arch, ext in product(mods, debug_suffixes, archs, EXTENSIONS): name_candidate[f"{name}{version}{mod}{debug}{arch}{ext}"] = None return list(name_candidate.keys()) def _possible_base(self) -> Generator[str, None, None]: possible_base = OrderedDict() basename = os.path.splitext(os.path.basename(self.executable))[0].rstrip(digits) possible_base[basename] = None possible_base[self.implementation] = None # python is always the final option as in practice is used by multiple implementation as exe name if "python" in possible_base: del possible_base["python"] possible_base["python"] = None for base in possible_base: lower = base.lower() yield lower from ._compat import fs_is_case_sensitive # ruff:ignore[import-outside-top-level] if fs_is_case_sensitive(): # pragma: no branch if base != lower: yield base upper = base.upper() if upper != base: yield upper class MarkerEnvironment(TypedDict): implementation_name: str implementation_version: str os_name: str platform_machine: str platform_release: str platform_system: str platform_version: str python_full_version: str platform_python_implementation: str python_version: str sys_platform: str 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) __all__ = [ "KNOWN_ARCHITECTURES", "MarkerEnvironment", "PythonInfo", "VersionInfo", "normalize_isa", ] python_discovery-1.9.5/src/python_discovery/_py_info_collect.py0000644000000000000000000004301015273371321022146 0ustar00""" Collect interpreter information, also run as the subprocess interrogation script (stdlib only). Executed by the interpreter being probed. Collection supports Python 3.6+, but the file must parse on 2.7 so the version gate below can report older interpreters instead of dying with a ``SyntaxError``: no f-strings, no walrus operator, no annotations, no typing imports, no imports from this package. The gate sits above every other import so that 3.0 and 3.1, which lack ``sysconfig``, still reach it. """ import sys MIN_INTERROGATE_VERSION = (3, 6) UNSUPPORTED_EXIT_CODE = 79 # first value past the sysexits.h range, so no stdlib or shell meaning UNSUPPORTED_MARKER = "unsupported Python version " if __name__ == "__main__" and sys.version_info[:2] < MIN_INTERROGATE_VERSION: # pragma: no cover # needs <3.6 sys.stderr.write(UNSUPPORTED_MARKER + ".".join(str(i) for i in sys.version_info[:3]) + "\n" + sys.executable) raise SystemExit(UNSUPPORTED_EXIT_CODE) import json import os import platform import re import struct import sysconfig import warnings from collections import namedtuple # ruff:ignore[collections-named-tuple] # typing.NamedTuple class syntax needs annotations, unavailable on 3.6 VersionInfo = namedtuple("VersionInfo", ["major", "minor", "micro", "releaselevel", "serial"]) _32BIT_POINTER_SIZE = 4 _CONF_VAR_RE = re.compile( r""" \{ \w+ } # sysconfig variable placeholder like {base} """, re.VERBOSE, ) class PythonInfoCollector: """Collects information about the currently running Python interpreter.""" def __init__(self): self._init_identity() self._init_prefixes() self._init_schemes() self._init_sysconfig() def _init_identity(self): self.platform = sys.platform self.implementation = platform.python_implementation() if self.implementation == "GraalVM": self.implementation = "GraalPy" 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 self.debug_build = bool(sysconfig.get_config_var("Py_DEBUG")) # Vermin checks the whole script against Python 2.7 despite the version gate above. interpreter = getattr(sys, "implementation") # ruff:ignore[get-attr-with-constant] interpreter_version = interpreter.version marker_version = "{}.{}.{}{}".format( interpreter_version.major, interpreter_version.minor, interpreter_version.micro, "{}{}".format(interpreter_version.releaselevel[0], interpreter_version.serial) if interpreter_version.releaselevel != "final" else "", ) self.marker_environment = { "implementation_name": interpreter.name, "implementation_version": marker_version, "os_name": os.name, "platform_machine": platform.machine(), "platform_release": platform.release(), "platform_system": platform.system(), "platform_version": platform.version(), "python_full_version": platform.python_version(), "platform_python_implementation": platform.python_implementation(), "python_version": ".".join(platform.python_version_tuple()[:2]), "sys_platform": sys.platform, } def _init_prefixes(self): def abs_path(value): 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): scheme_names = sysconfig.get_scheme_names() if "venv" in scheme_names: # pragma: >=3.11 cover self.sysconfig_scheme = "venv" self.sysconfig_paths = { i: sysconfig.get_path(i, expand=False, scheme=self.sysconfig_scheme) for i in sysconfig.get_path_names() } self.distutils_install = {} # debian / ubuntu python 3.10 without `python3-distutils` will report mangled `local/bin` / etc. names elif sys.version_info[:2] == (3, 10) and "deb_system" in scheme_names: # pragma: no cover # Debian/Ubuntu 3.10 self.sysconfig_scheme = "posix_prefix" self.sysconfig_paths = { i: sysconfig.get_path(i, expand=False, scheme=self.sysconfig_scheme) for i in sysconfig.get_path_names() } self.distutils_install = {} else: # pragma: no cover # "venv" scheme always present on Python 3.12+ self.sysconfig_scheme = None self.sysconfig_paths = {i: sysconfig.get_path(i, expand=False) for i in sysconfig.get_path_names()} self.distutils_install = self._distutils_install().copy() def _init_sysconfig(self): makefile = getattr(sysconfig, "get_makefile_filename", getattr(sysconfig, "_get_makefile_filename", None)) self.sysconfig = { k: v for k, v in [ ("makefile_filename", makefile() if makefile is not None else None), ] if k is not None } config_var_keys = set() for element in self.sysconfig_paths.values(): config_var_keys.update(k[1:-1] for k in _CONF_VAR_RE.findall(element)) config_var_keys.add("PYTHONFRAMEWORK") config_var_keys.update(("Py_ENABLE_SHARED", "INSTSONAME", "LIBDIR")) self.sysconfig_vars = {i: sysconfig.get_config_var(i or "") for i in config_var_keys} if "TCL_LIBRARY" in os.environ: self.tcl_lib, self.tk_lib = self._get_tcl_tk_libs() else: self.tcl_lib, self.tk_lib = None, None system_prefix = self.real_prefix or self.base_prefix or self.prefix confs = { k: (system_prefix if isinstance(v, str) and v.startswith(self.prefix) else v) for k, v in self.sysconfig_vars.items() } self.system_stdlib = self._sysconfig_path("stdlib", confs) self.system_stdlib_platform = self._sysconfig_path("platstdlib", confs) self.max_size = getattr(sys, "maxsize", getattr(sys, "maxint", None)) self._creators = None # virtualenv-specific, set via monkey-patch def _sysconfig_path(self, key, config_var): pattern = self.sysconfig_paths.get(key) if pattern is None: # custom builds may ship schemes without stdlib / platstdlib return "" base = self.sysconfig_vars.copy() base.update(config_var) return pattern.format(**base).replace("/", os.sep) @staticmethod def _get_tcl_tk_libs(): # pragma: no cover # tkinter availability varies; tested indirectly via __init__ """Detect the tcl and tk libraries using tkinter.""" tcl_lib, tk_lib = None, None try: import tkinter as tk # ruff:ignore[import-outside-top-level] # novermin # unreached below the version gate except ImportError: pass else: # Tk.__init__ reads profile scripts from HOME or the working directory; probing needs only library metadata. class _TclWithoutProfile(tk.Tk): def readprofile(self, *_args, **_kwargs): # ruff:ignore[no-self-use] # Tk override must be an instance method. return None try: tcl = _TclWithoutProfile(useTk=False) tcl_lib = tcl.eval("info library") tk_lib = PythonInfoCollector._resolve_tk_lib(tcl, tcl_lib) except tk.TclError: pass return tcl_lib, tk_lib @staticmethod def _query_tk_library(tcl): # pragma: no cover """Try to get the TK library path directly from Tcl.""" import tkinter as tk # ruff:ignore[import-outside-top-level] # novermin # unreached below the version gate try: tk_lib = tcl.eval("set tk_library") if tk_lib and os.path.isdir(tk_lib): return tk_lib except tk.TclError: pass return None @staticmethod def _resolve_tk_lib(tcl, tcl_lib): # pragma: no cover """Resolve the TK library path by direct query or path construction.""" tk_lib = PythonInfoCollector._query_tk_library(tcl) if tk_lib is not None: return tk_lib import _tkinter # ruff:ignore[import-outside-top-level,import-private-name] # novermin # linked Tk version # Loading Tk can block on DISPLAY; the linked version is enough to locate its library. tk_version = _tkinter.TK_VERSION tcl_parent = os.path.dirname(tcl_lib) for version in (tk_version, tk_version.split(".")[0]): tk_lib_path = os.path.join(tcl_parent, "tk{}".format(version)) if os.path.isdir(tk_lib_path) and os.path.exists(os.path.join(tk_lib_path, "tk.tcl")): return tk_lib_path return None def _fast_get_system_executable(self): """Try to get the system executable by just looking at properties.""" # Preserve the invoked path because shims may rely on it. if not (self.real_prefix or (self.base_prefix is not None and self.base_prefix != self.prefix)): return self._resolve_executable_symlink(self.original_executable) if self.real_prefix is not None: return None base_executable = getattr(sys, "_base_executable", None) if base_executable is None: return None if sys.executable == base_executable: return None # CPython accepts pythonX without checking its version when resolving a copied venv. if os.path.exists(base_executable): # pragma: >=3.11 cover major = self.version_info.major ambiguous_names = {"python", "python{}".format(major)} if self.implementation == "PyPy": ambiguous_names.update({"pypy", "pypy3", "pypy{}".format(major)}) if os.path.basename(base_executable) in ambiguous_names: versioned = self._try_posix_versioned_executable(base_executable) if versioned is not None and not os.path.samefile(base_executable, versioned): base_executable = versioned return self._resolve_executable_symlink(base_executable) # Try fallback for POSIX virtual environments return self._try_posix_fallback_executable(base_executable) # pragma: >=3.11 cover def _resolve_executable_symlink(self, path): """ Resolve symlinks of the executable itself, but never of its parent directories. Mirrors CPython's ``getpath.realpath`` (and ``venv`` in python/cpython#115237): an executable-only symlink resolves to the real interpreter so its home can be located, while a fully symlinked interpreter tree is kept as-is. Like ``getpath``, resolution stops as soon as the stdlib landmark is reachable from the current directory - an alias such as Debian's ``/usr/bin/python3`` is a usable home and stays untouched. """ result = os.path.abspath(path) if self.os != "posix": # pragma: win32 cover # CPython only does this where HAVE_READLINK return result if sysconfig.get_config_var("PYTHONFRAMEWORK"): # macOS framework builds self-locate via dyld; e.g. Homebrew return result # resolving would pin the versioned Cellar path into the recorded home real_path = os.path.realpath(result) if not os.path.exists(real_path): # symlink loop or broken symlink return result while os.path.islink(result): if self._stdlib_landmark_exists(os.path.dirname(result)): return result link = os.readlink(result) candidate = link if os.path.isabs(link) else os.path.normpath(os.path.join(os.path.dirname(result), link)) # normpath through a symlinked directory may point at a different file - stop resolving there if not (os.path.exists(candidate) and os.path.samefile(real_path, candidate)): return result result = candidate return result @staticmethod def _stdlib_landmark_exists(dir_path): lib_name = os.path.basename(os.path.dirname(os.__file__)) return any( os.path.exists(os.path.join(dir_path, os.pardir, lib, lib_name, "os.py")) for lib in ("lib", "lib64") ) def _try_posix_fallback_executable(self, base_executable): """Find a versioned Python binary as fallback for POSIX virtual environments.""" versioned = self._try_posix_versioned_executable(base_executable) if versioned is not None: return versioned major, minor = self.version_info.major, self.version_info.minor if self.os != "posix" or (major, minor) < (3, 11): return None base_dir = os.path.dirname(base_executable) candidates = ["python{}".format(major)] if self.implementation == "PyPy": candidates.extend(["pypy", "pypy3", "pypy{}".format(major)]) for candidate in candidates: full_path = os.path.join(base_dir, candidate) if os.path.exists(full_path): return full_path return None def _try_posix_versioned_executable(self, base_executable): major, minor = self.version_info.major, self.version_info.minor if self.os != "posix" or (major, minor) < (3, 11): return None base_dir = os.path.dirname(base_executable) candidates = ["python{}.{}".format(major, minor)] if self.implementation == "PyPy": candidates.insert(0, "pypy{}.{}".format(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 @staticmethod def _distutils_install(): # pragma: <3.11 cover # 3.11+ uses the "venv" scheme instead # use distutils primarily because that's what pip does # https://github.com/pypa/pip/blob/main/src/pip/_internal/locations.py#L95 # Avoid importing Distribution before setuptools can patch it. with warnings.catch_warnings(): # disable warning for PEP-632 warnings.simplefilter("ignore") try: # ruff:ignore[import-outside-top-level] from distutils import dist # ty: ignore[unresolved-import] # ruff:ignore[import-outside-top-level] from distutils.command.install import SCHEME_KEYS # 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 # ty: ignore[invalid-assignment] # ruff:ignore[private-member-access] # 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, "install_{}".format(key))[1:]).lstrip(os.sep) for key in SCHEME_KEYS} def to_dict(self): """Convert the collected information to a plain dictionary.""" data = {var: (getattr(self, var) if var != "_creators" else None) for var in vars(self)} data["version_info"] = self.version_info._asdict() return data def _main(): # pragma: no cover # exercised via subprocess runs 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 = json.dumps(PythonInfoCollector().to_dict(), indent=2) sys.stdout.write("".join((start_cookie[::-1], result, end_cookie[::-1]))) sys.stdout.flush() __all__ = [ "MIN_INTERROGATE_VERSION", "UNSUPPORTED_MARKER", "PythonInfoCollector", ] if __name__ == "__main__": _main() python_discovery-1.9.5/src/python_discovery/_py_spec.py0000644000000000000000000003024415273371321020445 0ustar00"""A Python specification is an abstract requirement definition of an interpreter.""" from __future__ import annotations import pathlib import re from typing import Final from ._py_info import normalize_isa from ._specifier import SimpleSpecifier, SimpleSpecifierSet, SimpleVersion _MODIFIERS: Final[str] = r""" (?Pt)? # free-threaded flag (?Pd|(?:-dbg|-debug)(?=-|$))? # debug build flag (d, -dbg or -debug) (?:-(?P32|64))? # architecture bitness (?:-(?P[a-zA-Z0-9_.]+))? # ISA (e.g. arm64, x86_64, i86pc.64bit) $ """ PATTERN = re.compile( r""" ^ (?P[a-zA-Z]+)? # implementation (e.g. cpython, pypy) (?P[0-9.]+)? # version (e.g. 3.12, 3.12.1) """ + _MODIFIERS, re.VERBOSE, ) SPECIFIER_PATTERN = re.compile( r""" ^ (?:(?P[A-Za-z]+)\s*)? # optional implementation prefix (?P(?:===|==|~=|!=|<=|>=|<|>).*?) # PEP 440 version specifier, lazy so the modifiers can follow it """ + _MODIFIERS, re.VERBOSE, ) _MAX_VERSION_PARTS: Final[int] = 3 _SINGLE_DIGIT_MAX: Final[int] = 9 KNOWN_IMPLEMENTATIONS: Final[tuple[str, ...]] = ("python", "cpython", "pypy", "graalpy") 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 not versions: msg = "version must contain a numeric component" raise ValueError(msg) if len(versions) > _MAX_VERSION_PARTS: msg = "too many version parts" raise ValueError(msg) if len(versions) == _MAX_VERSION_PARTS: return versions[0], versions[1], versions[2] if len(versions) == 2: # ruff:ignore[magic-value-comparison] return versions[0], versions[1], None 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 release, threaded = (None, None, None), None if (version := match["version"]) is not None: try: release = _parse_version_parts(version) except ValueError: return None threaded = bool(match["threaded"]) return _spec_from_match(string_spec, match, release, threaded=threaded, version_specifier=None) def _spec_from_match( string_spec: str, match: re.Match[str], release: tuple[int | None, int | None, int | None], *, threaded: bool | None, version_specifier: SpecifierSet | None, ) -> PythonSpec: impl = match["impl"] if impl in {"py", "python"}: impl = None if impl == "graalvm": impl = "graalpy" return PythonSpec( string_spec, impl, *release, _int_or_none(match["arch"]), None, free_threaded=threaded, machine=None if (machine := match["machine"]) is None else normalize_isa(machine), debug=True if match["debug"] else None, # unconstrained unless an explicit d/-dbg/-debug marker is present version_specifier=version_specifier, ) def _parse_specifier(string_spec: str) -> PythonSpec | None: if not (match := SPECIFIER_PATTERN.match(string_spec.strip())): return None spec_text = match["spec"].strip() version_specifier = SpecifierSet.from_string(spec_text) # the set never raises: it keeps an unparsable version as None and drops items without an operator items = [item for item in spec_text.split(",") if item.strip()] if len(items) != len(version_specifier.specifiers) or any(spec.version is None for spec in version_specifier): msg = f"invalid version specifier {string_spec!r}" raise ValueError(msg) return _spec_from_match( string_spec, match, (None, None, None), # the version bounds say nothing about the build, so without a trailing t any build qualifies threaded=True if match["threaded"] else 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 debug: whether a debug (``Py_DEBUG``) build is required, or ``None`` for any. :param version_specifier: `version specifier `_ constraints, or ``None``. """ def __init__( # ruff:ignore[too-many-arguments, too-many-positional-arguments] 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, debug: bool | 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.debug = debug 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 `version specifier `_. :raises ValueError: if the spec starts with a version specifier operator but does not parse as one. """ 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, all_implementations: bool = False) -> re.Pattern: """ Generate a regular expression for matching interpreter filenames. :param windows: if ``True``, require a ``.exe`` suffix. :param all_implementations: when ``True`` and the spec does not constrain the implementation, match every filename in :data:`KNOWN_IMPLEMENTATIONS` instead of only ``python``. A free-threaded or debug build the spec leaves open also matches its marked name, such as ``python3.13t`` or ``python3.13-dbg``. Used by enumeration APIs. """ version = r"{}(\.{}(\.{})?)?".format( *(r"\d+" if v is None else v for v in (self.major, self.minor, self.micro)), ) if self.implementation is not None: impl = f"python|{re.escape(self.implementation)}" elif all_implementations: impl = "|".join(re.escape(i) for i in KNOWN_IMPLEMENTATIONS) else: impl = "python" # An unconstrained single lookup must not depend on directory order to choose a variant. mod = "t?" if self.free_threaded or (all_implementations and self.free_threaded is None) else "" dbg = "(?:d|-dbg|-debug)?" if self.debug or (all_implementations and self.debug is None) 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}{dbg}{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 if spec.version_specifier is None: return True for item in spec.version_specifier: required_precision = self._get_required_precision(item) if required_precision is None or len(components) < required_precision: continue # Registry versions omit prerelease status, so a matching release still needs interrogation. if ( (version := item.version) is not None and version.pre_type is not None and tuple(components[:required_precision]) == version.release[:required_precision] ): continue # compare at the precision the specifier was written with, as PythonInfo.satisfies does if not item.contains(".".join(str(part) for part in components[:required_precision])): return False return True @staticmethod def _get_required_precision(item: SimpleSpecifier) -> int | None: if item.version is None: return None if item.wildcard_precision is not None: return item.wildcard_precision return re.split(r"a|b|rc", item.version_str, maxsplit=1)[0].count(".") + 1 def satisfies(self, spec: PythonSpec) -> bool: # ruff:ignore[too-many-return-statements] """ Check if this spec is compatible with the given *spec* (e.g. PEP-514 on Windows). :param spec: the requirement to check against. """ if spec.is_abs and self.is_abs and self.path != spec.path: return False if ( spec.implementation is not None and self.implementation is not None and spec.implementation.lower() != self.implementation.lower() ): return False if spec.architecture is not None and spec.architecture != self.architecture: return False if spec.machine is not None and self.machine is not None and spec.machine != self.machine: return False if spec.free_threaded is not None and spec.free_threaded != self.free_threaded: return False if spec.version_specifier is not None and not self._check_version_specifier(spec): return False return all( req is None or our is None or our == req for our, req in zip((self.major, self.minor, self.micro), (spec.major, spec.minor, spec.micro)) ) def __repr__(self) -> str: name = type(self).__name__ params = ( "implementation", "major", "minor", "micro", "architecture", "machine", "path", "free_threaded", "debug", "version_specifier", ) return f"{name}({', '.join(f'{k}={getattr(self, k)}' for k in params if getattr(self, k) is not None)})" __all__ = [ "KNOWN_IMPLEMENTATIONS", "InvalidSpecifier", "InvalidVersion", "PythonSpec", "SpecifierSet", "Version", ] python_discovery-1.9.5/src/python_discovery/_specifier.py0000644000000000000000000002470215273371321020756 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: Final[dict[str, bool]] = ( {"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: # ruff:ignore[too-many-return-statements] 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) # PEP 440 excludes prereleases of a final release named by an exclusive upper bound. if ( self.operator == "<" and candidate.pre_type is not None and self.version.pre_type is None and candidate.release == self.version.release ): return False 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 # ~=X.Y is >=X.Y, ==X.*: compare all but the last part the spec wrote (release is padded to three). # A bare ~=X still compares its one part, so it stays ==X.*. prefix = max(self.version_str.count("."), 1) return candidate.release[:prefix] == self.version.release[:prefix] def __eq__(self, other: object) -> bool: if not isinstance(other, SimpleSpecifier): return NotImplemented return self.spec_str == other.spec_str def __hash__(self) -> int: return hash(self.spec_str) def __str__(self) -> str: return self.spec_str def __repr__(self) -> str: return f"SimpleSpecifier('{self.spec_str}')" @dataclass(**DC_KW) class SimpleSpecifierSet: """ 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__ = [ "DC_KW", "SimpleSpecifier", "SimpleSpecifierSet", "SimpleVersion", ] python_discovery-1.9.5/src/python_discovery/py.typed0000644000000000000000000000000015273371321017754 0ustar00python_discovery-1.9.5/src/python_discovery/_windows/__init__.py0000644000000000000000000000047315273371321022235 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.9.5/src/python_discovery/_windows/_cancel_io.py0000644000000000000000000000323115273371321022544 0ustar00"""Cancel a blocked query pipe read on Windows.""" from __future__ import annotations from ctypes import WinDLL, WinError, get_last_error, wintypes # ty: ignore[unresolved-import] # Windows-only APIs from time import monotonic from typing import TYPE_CHECKING, Final if TYPE_CHECKING: from threading import Thread def stop_query_reader(reader: Thread) -> None: deadline = monotonic() + 1 while reader.is_alive() and monotonic() < deadline: if (thread_id := reader.native_id) is not None: try: _cancel_synchronous_io(thread_id) except OSError: if reader.is_alive(): raise reader.join(0.05) if reader.is_alive(): msg = "query output reader did not stop" raise TimeoutError(msg) def _cancel_synchronous_io(thread_id: int) -> None: error_not_found: Final[int] = 1168 kernel = WinDLL("kernel32", use_last_error=True) open_thread = kernel.OpenThread open_thread.argtypes = (wintypes.DWORD, wintypes.BOOL, wintypes.DWORD) open_thread.restype = wintypes.HANDLE cancel = kernel.CancelSynchronousIo cancel.argtypes = (wintypes.HANDLE,) cancel.restype = wintypes.BOOL close = kernel.CloseHandle close.argtypes = (wintypes.HANDLE,) close.restype = wintypes.BOOL if not (handle := open_thread(0x0001, wintypes.BOOL(0), thread_id)): raise WinError(get_last_error()) try: if not cancel(handle) and (error := get_last_error()) != error_not_found: raise WinError(error) finally: if not close(handle): raise WinError(get_last_error()) __all__ = ["stop_query_reader"] python_discovery-1.9.5/src/python_discovery/_windows/_pep514.py0000644000000000000000000002012715273371321021651 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 pathlib import PureWindowsPath 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]: # ruff:ignore[any-type] 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: # ruff:ignore[any-type] 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, # ruff:ignore[any-type] default_arch: int, ) -> Generator[_RegistrySpec, None, None]: with winreg.OpenKeyEx(root_key, company) as company_key: # ty: ignore[unresolved-attribute] for tag in enum_keys(company_key): spec = process_tag(hive_name, company, company_key, tag, default_arch) if spec is not None: yield spec def process_tag(hive_name: str, company: str, company_key: Any, tag: str, default_arch: int) -> _RegistrySpec | None: # ruff:ignore[any-type] with winreg.OpenKeyEx(company_key, tag) as tag_key: # ty: ignore[unresolved-attribute] version = load_version_data(hive_name, company, tag, tag_key) if version is not None: # if failed to get version bail major, minor, _ = version arch = load_arch_data(hive_name, company, tag, tag_key, default_arch) if arch is not None: exe_data = load_exe(hive_name, company, company_key, tag) if exe_data is not None: exe, args = exe_data threaded = load_threaded(hive_name, company, tag, tag_key) return company, major, minor, arch, threaded, exe, args return None return None return None def load_exe(hive_name: str, company: str, company_key: Any, tag: str) -> tuple[str, str | None] | None: # ruff:ignore[any-type] 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] if (exe := _resolve_exe(ip_key, key_path)) is not None and os.path.exists(exe): if (args := get_value(ip_key, "ExecutableArguments")) is None or isinstance(args, str): return exe, args if args and args.strip() else None msg(f"{key_path}/ExecutableArguments", f"expected a string, got {type(args).__name__}") return None msg(key_path, f"could not load exe with value {exe}") except OSError: msg(f"{key_path}/InstallPath", "missing") return None def _resolve_exe(ip_key: Any, key_path: str) -> str | None: # ruff:ignore[any-type] if (exe := get_value(ip_key, "ExecutablePath")) is None: if (exe := get_value(ip_key, None)) is None: msg(key_path, "no ExecutablePath or default for it") return None if isinstance(exe, str): exe = os.path.join(exe, "python.exe") if not isinstance(exe, str) or not PureWindowsPath(exe).is_absolute(): msg(key_path, f"expected an absolute executable path, got {exe!r}") return None return exe def load_arch_data(hive_name: str, company: str, tag: str, tag_key: Any, default_arch: int) -> int | None: # ruff:ignore[any-type] arch_str = get_value(tag_key, "SysArchitecture") if arch_str is not None: key_path = f"{hive_name}/{company}/{tag}/SysArchitecture" try: return parse_arch(arch_str) except ValueError as sys_arch: msg(key_path, sys_arch) return default_arch def parse_arch(arch_str: Any) -> int: # ruff:ignore[any-type] 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, # ruff:ignore[any-type] ) -> tuple[int | None, int | None, int | None] | None: for candidate, key_path in [ (get_value(tag_key, "SysVersion"), f"{hive_name}/{company}/{tag}/SysVersion"), (tag, f"{hive_name}/{company}/{tag}"), ]: if candidate is not None: try: return parse_version(candidate) except ValueError as sys_version: msg(key_path, sys_version) return None def parse_version(version_str: Any) -> tuple[int | None, int | None, int | None]: # ruff:ignore[any-type] 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: # ruff:ignore[any-type] display_name = get_value(tag_key, "DisplayName") if display_name is not None: if isinstance(display_name, str): if "freethreaded" in display_name.lower(): return True else: key_path = f"{hive_name}/{company}/{tag}/DisplayName" msg(key_path, f"display name is not string: {display_name!r}") return bool(_THREADED_TAG_RE.match(tag)) def msg(path: str, what: object) -> None: _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.9.5/src/python_discovery/_windows/_propose.py0000644000000000000000000000333415273371321022323 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, executable_args 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_with_args( # ruff:ignore[private-member-access] exe, cache, env=env, executable_args=executable_args, 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.9.5/tasks/old_target_check.py0000644000000000000000000000320715273371321017052 0ustar00"""Verify target-interpreter handling end to end; run inside a CI container with the target on PATH.""" from __future__ import annotations import logging import sys from python_discovery import get_interpreter def main(version: str, mode: str) -> None: handler = _RecordingHandler() logging.getLogger("python_discovery").addHandler(handler) info = get_interpreter(version) if mode == "discover": if info is None: msg = f"failed to discover Python {version}" raise SystemExit(msg) found = f"{info.version_info.major}.{info.version_info.minor}" if found != version: msg = f"discovered {found} at {info.executable} instead of {version}" raise SystemExit(msg) sys.stdout.write(f"discovered Python {version} at {info.executable}\n") else: if info is not None: msg = f"expected Python {version} to be rejected, discovered {info.executable}" raise SystemExit(msg) warnings = [record for record in handler.records if record.levelno == logging.WARNING] if not any("older than the minimum" in record.getMessage() for record in warnings): msg = f"no warning recorded while rejecting Python {version}" raise SystemExit(msg) sys.stdout.write(f"rejected Python {version} with a warning\n") class _RecordingHandler(logging.Handler): def __init__(self) -> None: super().__init__() self.records: list[logging.LogRecord] = [] def emit(self, record: logging.LogRecord) -> None: self.records.append(record) if __name__ == "__main__": main(sys.argv[1], sys.argv[2]) python_discovery-1.9.5/tasks/old_target_check.sh0000755000000000000000000000067415273371321017044 0ustar00#!/bin/sh # Run old_target_check.py against a python:-slim container with the built wheel; args: . set -eu TARGET="$1" MODE="$2" docker run --rm -e TARGET="$TARGET" -e MODE="$MODE" -v "$PWD:/repo:ro" -v "$(dirname "$(command -v uv)"):/uv-bin:ro" \ "python:$TARGET-slim" \ sh -c '/uv-bin/uv run -q --python 3.13 --isolated --no-project --with /repo/dist/*.whl python /repo/tasks/old_target_check.py "$TARGET" "$MODE"' python_discovery-1.9.5/tests/conftest.py0000644000000000000000000000154515273371321015436 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.9.5/tests/test_cache.py0000644000000000000000000003274315273371321015717 0ustar00from __future__ import annotations import json import os import stat import sys from os import fsdecode from pathlib import Path from threading import Event, Thread from typing import TYPE_CHECKING import pytest from filelock import FileLock, Timeout from python_discovery import get_interpreter from python_discovery._cache import DiskCache, DiskContentStore, NoOpCache, NoOpContentStore if TYPE_CHECKING: from pytest_mock import MockerFixture 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_reads_near_limit_escaped_json(tmp_path: Path) -> None: content = {"padding": "😀" * ((1 << 20) - 4_096)} store = DiskContentStore(tmp_path, "test") store.write(content) assert store.read() == content def test_disk_content_store_discards_oversized_entry(tmp_path: Path) -> None: entry = tmp_path / "test.json" entry.write_text(json.dumps({"padding": "x" * (16 << 20)}), encoding="utf-8") store = DiskContentStore(tmp_path, "test") assert store.read() is None assert not entry.exists() @pytest.mark.parametrize( "source", [ pytest.param(json.dumps("x" * 398), id="flat"), pytest.param("[" * 200 + "]" * 200, id="nested"), ], ) def test_disk_content_store_write_size_tracks_input(tmp_path: Path, source: str) -> None: content = {"padding": json.loads(source)} DiskContentStore(tmp_path, "test").write(content) assert (tmp_path / "test.json").stat().st_size < 2 * len(json.dumps(content)) def test_disk_content_store_write_deep_json(tmp_path: Path) -> None: depth = 2_000 if sys.version_info >= (3, 15) else 900 DiskContentStore(tmp_path, "test").write({"padding": json.loads("[" * depth + "]" * depth)}) assert (tmp_path / "test.json").stat().st_size < 10_000 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"} @pytest.mark.skipif(sys.platform == "win32", reason="POSIX lock permissions") @pytest.mark.parametrize( ("folder_mode", "umask", "lock_folder_mode", "lock_mode"), [ pytest.param(0o755, 0o022, 0o700, 0o600, id="private"), pytest.param(0o770, 0o002, 0o770, 0o660, id="group-shared"), ], ) def test_disk_content_store_lock_permissions( tmp_path: Path, folder_mode: int, umask: int, lock_folder_mode: int, lock_mode: int ) -> None: tmp_path.chmod(folder_mode) previous = os.umask(umask) try: with DiskContentStore(tmp_path, "test").locked(): pass finally: os.umask(previous) assert ( stat.S_IMODE((tmp_path / "locks-v2").stat().st_mode), stat.S_IMODE((tmp_path / "locks-v2" / "test.lock").stat().st_mode), ) == (lock_folder_mode, lock_mode) @pytest.mark.skipif(sys.platform == "win32", reason="POSIX lock migration") def test_disk_content_store_ignores_legacy_lock(tmp_path: Path) -> None: entered = Event() store = DiskContentStore(tmp_path, "test") def acquire() -> None: with store.locked(): entered.set() thread = Thread(target=acquire) with FileLock(str(tmp_path / "test.lock")): thread.start() acquired = entered.wait(3) thread.join(timeout=3) assert acquired def test_disk_content_store_locks_legacy_path_without_posix(tmp_path: Path, mocker: MockerFixture) -> None: platform_os = mocker.patch("python_discovery._cache.os", autospec=True) platform_os.name = "nt" with DiskContentStore(tmp_path, "test").locked(), pytest.raises(Timeout): FileLock(str(tmp_path / "test.lock")).acquire(timeout=0) assert not (tmp_path / "locks-v2").exists() 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) @pytest.mark.skipif(os.name == "nt", reason="Windows paths do not use surrogateescape") def test_disk_cache_non_utf8_interpreter_path(tmp_path: Path) -> None: directory = tmp_path / fsdecode(b"interpreter-\xff") directory.mkdir() executable = directory / "python3" executable.symlink_to(sys.executable) info = get_interpreter(str(executable), cache=DiskCache(tmp_path / "cache")) assert info is not None assert info.executable == str(executable) 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 @pytest.mark.skipif(sys.platform == "win32", reason="POSIX ownership and modes") @pytest.mark.parametrize("unsafe", [pytest.param("", id="root"), pytest.param("py_info/4", id="entry-dir")]) def test_disk_cache_skips_writable_directory(tmp_path: Path, unsafe: str) -> None: folder = tmp_path / unsafe folder.mkdir(parents=True, exist_ok=True) folder.chmod(0o777) assert isinstance(DiskCache(tmp_path).py_info(Path("/some/path")), NoOpContentStore) @pytest.mark.skipif(sys.platform == "win32", reason="POSIX ownership and modes") def test_disk_cache_skips_linked_directory(tmp_path: Path) -> None: target = tmp_path / "target" target.mkdir() (tmp_path / "py_info").symlink_to(target, target_is_directory=True) assert isinstance(DiskCache(tmp_path).py_info(Path("/some/path")), NoOpContentStore) assert not (target / "4").exists() @pytest.mark.skipif(sys.platform == "win32", reason="POSIX ownership and modes") def test_disk_cache_skips_writable_ancestor(tmp_path: Path) -> None: ancestor = tmp_path / "shared" ancestor.mkdir() ancestor.chmod(0o777) assert isinstance(DiskCache(ancestor / "cache").py_info(Path("/some/path")), NoOpContentStore) @pytest.mark.skipif(sys.platform == "win32", reason="POSIX ownership and modes") def test_disk_cache_skips_file_as_root(tmp_path: Path) -> None: root = tmp_path / "file" root.touch() assert isinstance(DiskCache(root).py_info(Path("/some/path")), NoOpContentStore) @pytest.mark.skipif(sys.platform == "win32", reason="POSIX ownership and modes") def test_disk_cache_skips_uncreatable_directory(tmp_path: Path, mocker: MockerFixture) -> None: mocker.patch.object(Path, "mkdir", side_effect=PermissionError) assert isinstance(DiskCache(tmp_path / "cache").py_info(Path("/some/path")), NoOpContentStore) @pytest.mark.skipif(sys.platform == "win32", reason="POSIX ownership and modes") def test_disk_cache_skips_foreign_directory(tmp_path: Path, mocker: MockerFixture) -> None: status = list(tmp_path.stat()) status[4] = os.geteuid() + 1 mocker.patch.object(Path, "lstat", return_value=os.stat_result(status)) assert isinstance(DiskCache(tmp_path).py_info(Path("/some/path")), NoOpContentStore) def test_disk_cache_works_without_posix_ownership(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.delattr(os, "geteuid", raising=False) assert isinstance(DiskCache(tmp_path).py_info(Path("/some/path")), DiskContentStore) @pytest.mark.skipif(sys.platform == "win32", reason="POSIX ownership and modes") def test_disk_cache_keeps_validated_relative_root(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: safe = tmp_path / "safe" unsafe = tmp_path / "unsafe" safe.mkdir() unsafe.mkdir() unsafe.chmod(0o777) monkeypatch.chdir(safe) store = DiskCache(Path("cache")).py_info(Path("/some/path")) monkeypatch.chdir(unsafe) store.write({"key": "value"}) assert (len(list(safe.rglob("*.json"))), list(unsafe.rglob("*.json"))) == (1, []) @pytest.mark.skipif(sys.platform == "win32", reason="POSIX ownership and modes") def test_disk_cache_clear_skips_writable_directory(tmp_path: Path) -> None: folder = tmp_path / "py_info" / "4" folder.mkdir(parents=True) entry = folder / "entry.json" entry.write_text("{}", encoding="utf-8") tmp_path.chmod(0o777) DiskCache(tmp_path).py_info_clear() assert entry.exists() @pytest.mark.skipif(sys.platform == "win32", reason="POSIX symbolic links") def test_disk_cache_clear_does_not_follow_linked_directory(tmp_path: Path) -> None: target = tmp_path / "target" / "4" target.mkdir(parents=True) entry = target / "credentials.json" entry.write_text("{}", encoding="utf-8") (tmp_path / "py_info").symlink_to(target.parent, target_is_directory=True) DiskCache(tmp_path).py_info_clear() assert entry.exists() @pytest.mark.skipif(sys.platform == "win32", reason="POSIX ownership and modes") @pytest.mark.parametrize( ("owner_offset", "mode"), [pytest.param(1, 0o600, id="foreign-owner"), pytest.param(0, 0o660, id="group-writable")], ) def test_disk_content_store_rejects_untrusted_entry( tmp_path: Path, mocker: MockerFixture, owner_offset: int, mode: int ) -> None: store = DiskContentStore(tmp_path, "entry") store.write({"key": "value"}) status = list((tmp_path / "entry.json").stat()) status[0], status[4] = stat.S_IFREG | mode, os.geteuid() + owner_offset mocker.patch("python_discovery._cache.os.fstat", return_value=os.stat_result(status)) assert store.read() is None assert not store.exists() @pytest.mark.skipif(sys.platform == "win32", reason="POSIX symbolic links") def test_disk_content_store_does_not_follow_entry_link(tmp_path: Path) -> None: target = tmp_path / "target.json" target.write_text('{"key": "target"}', encoding="utf-8") entry = tmp_path / "entry.json" entry.symlink_to(target) store = DiskContentStore(tmp_path, "entry") assert store.read() is None @pytest.mark.skipif(sys.platform == "win32", reason="POSIX FIFOs") def test_disk_content_store_skips_fifo(tmp_path: Path) -> None: os.mkfifo(tmp_path / "entry.json") store = DiskContentStore(tmp_path, "entry") assert store.read() is None assert not store.exists() @pytest.mark.skipif(sys.platform == "win32", reason="POSIX symbolic links") def test_disk_content_store_write_replaces_entry_link(tmp_path: Path) -> None: target = tmp_path / "target.json" target.write_text('{"key": "target"}', encoding="utf-8") (tmp_path / "entry.json").symlink_to(target) store = DiskContentStore(tmp_path, "entry") store.write({"key": "value"}) assert (target.read_text(encoding="utf-8"), store.read()) == ('{"key": "target"}', {"key": "value"}) @pytest.mark.skipif(sys.platform == "win32", reason="POSIX file modes") def test_disk_content_store_write_is_private_with_lax_umask(tmp_path: Path) -> None: previous = os.umask(0) try: DiskContentStore(tmp_path, "entry").write({"key": "value"}) finally: os.umask(previous) assert stat.S_IMODE((tmp_path / "entry.json").stat().st_mode) == 0o600 @pytest.mark.parametrize( "target", [ pytest.param("python_discovery._cache.NamedTemporaryFile", id="create"), pytest.param("pathlib.Path.replace", id="replace"), ], ) def test_disk_content_store_write_skips_io_failure(tmp_path: Path, mocker: MockerFixture, target: str) -> None: mocker.patch(target, side_effect=PermissionError) DiskContentStore(tmp_path, "entry").write({"key": "value"}) assert list(tmp_path.iterdir()) == [] 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.9.5/tests/test_cache_recovery.py0000644000000000000000000000505715273371321017633 0ustar00from __future__ import annotations import json import sys from typing import TYPE_CHECKING import pytest from python_discovery import DiskCache, PythonInfo if TYPE_CHECKING: from pathlib import Path @pytest.mark.parametrize( "payload", [ pytest.param("[]", id="list"), pytest.param('"python"', id="string"), pytest.param("3", id="number"), pytest.param("true", id="bool"), pytest.param("null", id="null"), ], ) def test_from_exe_recovers_non_object_cache(cached_interpreter: tuple[DiskCache, Path, dict], payload: str) -> None: cache, cache_file, expected = cached_interpreter cache_file.write_text(payload, encoding="utf-8") result = PythonInfo.from_exe(sys.executable, cache, ignore_cache=True, resolve_to_host=False) assert result is not None assert (json.loads(result.to_json()), json.loads(cache_file.read_text(encoding="utf-8"))) == ( expected["content"], expected, ) @pytest.mark.parametrize( "payload", [ pytest.param(None, id="missing"), pytest.param("5", id="int"), pytest.param('["python"]', id="list"), pytest.param("{}", id="dict"), pytest.param("true", id="bool"), pytest.param('""', id="empty"), pytest.param('"."', id="directory"), pytest.param('"missing-python"', id="nonexistent"), pytest.param('"python\\u0000"', id="nul"), pytest.param(json.dumps("a" * 1024), id="path-too-long"), ], ) def test_from_exe_recovers_invalid_system_executable( cached_interpreter: tuple[DiskCache, Path, dict], payload: str | None ) -> None: cache, cache_file, expected = cached_interpreter data = json.loads(cache_file.read_text(encoding="utf-8")) if payload is None: del data["content"]["system_executable"] else: data["content"]["system_executable"] = json.loads(payload) cache_file.write_text(json.dumps(data), encoding="utf-8") result = PythonInfo.from_exe(sys.executable, cache, ignore_cache=True, resolve_to_host=False) assert result is not None assert (json.loads(result.to_json()), json.loads(cache_file.read_text(encoding="utf-8"))) == ( expected["content"], expected, ) @pytest.fixture def cached_interpreter(tmp_path: Path) -> tuple[DiskCache, Path, dict]: cache = DiskCache(tmp_path) assert PythonInfo.from_exe(sys.executable, cache, ignore_cache=True, resolve_to_host=False) is not None cache_file = next(tmp_path.rglob("*.json")) return cache, cache_file, json.loads(cache_file.read_text(encoding="utf-8")) python_discovery-1.9.5/tests/test_cached_py_info.py0000644000000000000000000005637115273371321017611 0ustar00from __future__ import annotations import json import logging import os import sys import tempfile from io import StringIO from os import read from pathlib import Path from subprocess import DEVNULL, PIPE, Popen, TimeoutExpired, list2cmdline from threading import enumerate as active_threads from time import monotonic from typing import TYPE_CHECKING, Final from unittest.mock import MagicMock, patch import pytest from python_discovery import DiskCache, PythonInfo from python_discovery._cached_py_info import ( _MAX_QUERY_OUTPUT, LogCmd, _collect_query_output, _get_from_cache, _get_via_file_cache, _load_cached_py_info, _resolve_py_info_script, _run_subprocess, _script_hash, gen_cookie, ) if TYPE_CHECKING: from typing import IO 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_collect.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 script.parent != Path(tempfile.gettempdir()) assert list(script.parent.iterdir()) == [script] assert not script.parent.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: mocker.patch("python_discovery._cached_py_info.Popen", return_value=_mock_process("not json", "", 0)) failure, result = _run_subprocess(PythonInfo, sys.executable, dict(os.environ)) assert failure is not None assert result is None @pytest.mark.parametrize( "output", [ pytest.param("{}", id="empty-object"), pytest.param( '{"version_info": {"major": 3, "minor": 11, "micro": 0, "releaselevel": "final", "serial": 0}}', id="version-only", ), pytest.param("[]", id="array"), ], ) def test_from_exe_rejects_wrong_shape_json(tmp_path: Path, mocker: MockerFixture, output: str) -> None: start_cookie, end_cookie = "a" * 32, "b" * 32 wrapped = f"{start_cookie}{output}{end_cookie}" mocker.patch( "python_discovery._cached_py_info.Popen", side_effect=[_mock_process(wrapped, "", 0), _mock_process(wrapped, "", 0)], ) mocker.patch("python_discovery._cached_py_info.gen_cookie", side_effect=[start_cookie, end_cookie] * 2) assert PythonInfo.from_exe(str(tmp_path / "python"), raise_on_error=False, ignore_cache=True) is None def test_from_exe_rejects_json_without_cookies(tmp_path: Path, mocker: MockerFixture) -> None: output = PythonInfo().to_json() mocker.patch( "python_discovery._cached_py_info.Popen", side_effect=[_mock_process(output, "", 0), _mock_process(output, "", 0)], ) assert PythonInfo.from_exe(str(tmp_path / "python"), raise_on_error=False, ignore_cache=True) is None def _mock_process(out: str, err: str, code: int) -> MagicMock: streams: list[IO[str]] = [] for value in (out, err): read_fd, write_fd = os.pipe() with os.fdopen(write_fd, "wb") as writer: writer.write(value.encode()) streams.append(os.fdopen(read_fd, "r", encoding="utf-8")) return MagicMock(stdout=streams[0], stderr=streams[1], returncode=code, **{"wait.return_value": code}) def test_run_subprocess_with_cookies(mocker: MockerFixture) -> None: start_cookie = "a" * 32 end_cookie = "b" * 32 payload = json.dumps(PythonInfo().to_dict()) out = f"pre{start_cookie[::-1]}{payload}{end_cookie[::-1]}post" mocker.patch("python_discovery._cached_py_info.Popen", return_value=_mock_process(out, "", 0)) mocker.patch("python_discovery._cached_py_info.gen_cookie", side_effect=[start_cookie, end_cookie]) 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: mocker.patch("python_discovery._cached_py_info._collect_query_output", return_value=("", "timed out", -1)) 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 def test_run_subprocess_nonzero_exit(mocker: MockerFixture) -> None: mocker.patch("python_discovery._cached_py_info.Popen", return_value=_mock_process("some output", "some error", 1)) 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 @pytest.mark.skipif(sys.platform == "win32", reason="POSIX executable script") @pytest.mark.parametrize("stream", [pytest.param("stdout", id="stdout"), pytest.param("stderr", id="stderr")]) def test_run_subprocess_rejects_large_output(tmp_path: Path, stream: str) -> None: exe = tmp_path / "python" exe.write_text( f"#!{sys.executable}\nimport sys\nsys.{stream}.write('x' * {_MAX_QUERY_OUTPUT + 1})\n", encoding="utf-8", ) exe.chmod(0o755) failure, result = _run_subprocess(PythonInfo, str(exe), dict(os.environ)) assert result is None assert failure is not None assert "produced more than" in str(failure) @pytest.mark.parametrize("stream", [pytest.param("stdout", id="stdout"), pytest.param("stderr", id="stderr")]) @pytest.mark.parametrize("timeout", [pytest.param(15, id="finite-timeout"), pytest.param(None, id="no-timeout")]) def test_collect_query_output_limit(stream: str, timeout: float | None) -> None: code = f"import sys; sys.{stream}.write('x' * {_MAX_QUERY_OUTPUT + 1})" process = Popen([sys.executable, "-c", code], stdin=DEVNULL, stdout=PIPE, stderr=PIPE, text=True) assert _collect_query_output(process, timeout) == ( "", f"produced more than {_MAX_QUERY_OUTPUT} characters of output", -1, ) def test_collect_query_output_normalizes_trailing_newline() -> None: assert _collect_query_output(_mock_process("\r", "", 0), 1) == ("\n", "", 0) def test_collect_query_output_decodes_split_utf8() -> None: with patch("python_discovery._cached_py_info.os.read", side_effect=lambda fd, _: read(fd, 1)): assert _collect_query_output(_mock_process("é", "", 0), 1) == ("é", "", 0) @pytest.mark.parametrize( "code", [ pytest.param("import time; time.sleep(2)", id="pipes-open"), pytest.param("import sys,time; sys.stdout.close(); sys.stderr.close(); time.sleep(2)", id="pipes-closed"), ], ) def test_collect_query_output_timeout(code: str) -> None: process = Popen([sys.executable, "-c", code], stdin=DEVNULL, stdout=PIPE, stderr=PIPE, text=True) assert _collect_query_output(process, 0.1) == ("", "timed out", -1) def test_collect_query_output_timeout_reaps_readers() -> None: code = ( "import subprocess, sys\n" "subprocess.Popen([sys.executable, '-c', 'import time; time.sleep(10)'], " "stdin=subprocess.DEVNULL, stdout=sys.stdout, stderr=sys.stderr)\n" ) process = Popen([sys.executable, "-c", code], stdin=DEVNULL, stdout=PIPE, stderr=PIPE, text=True) process.wait(timeout=5) before = set(active_threads()) started = monotonic() assert _collect_query_output(process, 0.1) == ("", "timed out", -1) assert monotonic() - started < 2.5 assert not set(active_threads()) - before def test_collect_query_output_timeout_after_eof(mocker: MockerFixture) -> None: process = Popen([sys.executable, "-c", ""], stdin=DEVNULL, stdout=PIPE, stderr=PIPE, text=True) process.wait(timeout=5) mocker.patch.object(process, "wait", side_effect=[TimeoutExpired("python", 0), 0]) mocker.patch.object(process, "kill") assert _collect_query_output(process, 1) == ("", "timed out", -1) process.kill.assert_called_once() def test_collect_query_output_read_error(mocker: MockerFixture) -> None: process = Popen([sys.executable, "-c", ""], stdin=DEVNULL, stdout=PIPE, stderr=PIPE, text=True) process.wait(timeout=5) mocker.patch("python_discovery._cached_py_info.os.read", side_effect=OSError("broken pipe")) mocker.patch.object(process, "kill") with pytest.raises(OSError, match="broken pipe"): _collect_query_output(process, 1) process.kill.assert_called() @pytest.mark.parametrize( ("stdout", "stderr"), [ pytest.param(None, StringIO(""), id="stdout"), pytest.param(StringIO(""), None, id="stderr"), ], ) def test_collect_query_output_requires_pipes(stdout: StringIO | None, stderr: StringIO | None) -> None: process = MagicMock(stdout=stdout, stderr=stderr) with pytest.raises(ValueError, match="query output pipes are required"): _collect_query_output(process, 1) def test_load_cached_py_info_valid() -> None: store = MagicMock() content = PythonInfo().to_dict() result = _load_cached_py_info(PythonInfo, store, content) 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) _script_hash.cache_clear() 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) def _write_fake_python(tmp_path: Path, stderr: str, code: int = 79) -> Path: """A stand-in interpreter: logs every invocation, then fails interrogation with the given stderr and exit code.""" exe = tmp_path / "python" exe.write_text(f'#!/bin/sh\necho "$@" >> "$0.log"\necho "{stderr}" >&2\nexit {code}\n') exe.chmod(0o755) return exe def _interrogations(exe: Path) -> list[str]: return Path(f"{exe}.log").read_text(encoding="utf-8").splitlines() @pytest.mark.skipif(sys.platform == "win32", reason="POSIX shell fake interpreter") def test_from_exe_too_old_python_warns(tmp_path: Path, caplog: pytest.LogCaptureFixture) -> None: caplog.set_level(logging.INFO) exe = _write_fake_python(tmp_path, stderr="unsupported Python version 3.5.10") assert PythonInfo.from_exe(str(exe), raise_on_error=False, ignore_cache=True) is None record = next(iter(caplog.records)) assert record.levelno == logging.WARNING assert record.getMessage() == f"{exe} is Python 3.5.10, older than the minimum 3.6 python-discovery can query" @pytest.mark.skipif(sys.platform == "win32", reason="POSIX shell fake interpreter") def test_from_exe_too_old_python_skips_retry(tmp_path: Path) -> None: exe = _write_fake_python(tmp_path, stderr="unsupported Python version 3.5.10") PythonInfo.from_exe(str(exe), raise_on_error=False, ignore_cache=True) assert len(_interrogations(exe)) == 1 @pytest.mark.skipif(sys.platform == "win32", reason="POSIX shell fake interpreter") def test_from_exe_too_old_python_raises_by_default(tmp_path: Path) -> None: exe = _write_fake_python(tmp_path, stderr="unsupported Python version 3.5.10") with pytest.raises(RuntimeError, match=r"older than the minimum 3\.6 python-discovery can query"): PythonInfo.from_exe(str(exe), ignore_cache=True) @pytest.mark.skipif(sys.platform == "win32", reason="POSIX shell fake interpreter") def test_from_exe_too_old_python_verdict_cached(tmp_path: Path, caplog: pytest.LogCaptureFixture) -> None: caplog.set_level(logging.INFO) cache = DiskCache(tmp_path / "cache") exe = _write_fake_python(tmp_path, stderr="unsupported Python version 3.5.10\n$0") assert PythonInfo.from_exe(str(exe), cache, raise_on_error=False, ignore_cache=True) is None assert PythonInfo.from_exe(str(exe), cache, raise_on_error=False, ignore_cache=True) is None assert len(_interrogations(exe)) == 1 assert len([record for record in caplog.records if record.levelno == logging.WARNING]) == 1 @pytest.mark.skipif(sys.platform == "win32", reason="POSIX shell fake interpreter") def test_from_exe_too_old_python_shim_verdict_not_cached(tmp_path: Path) -> None: cache = DiskCache(tmp_path / "cache") exe = _write_fake_python(tmp_path, stderr=f"unsupported Python version 3.5.10\n{sys.executable}") assert PythonInfo.from_exe(str(exe), cache, raise_on_error=False, ignore_cache=True) is None assert PythonInfo.from_exe(str(exe), cache, raise_on_error=False, ignore_cache=True) is None assert len(_interrogations(exe)) == 2 @pytest.mark.skipif(sys.platform == "win32", reason="POSIX executable script") @pytest.mark.parametrize("ignore_cache", [pytest.param(False, id="memory"), pytest.param(True, id="disk")]) def test_from_exe_shim_result_changes_with_directory( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ignore_cache: bool ) -> None: shim = tmp_path / "shim" shim.write_text( f"#!{sys.executable}\n" "from runpy import run_path\nimport sys\nfrom pathlib import Path\n" "sys.prefix = (Path.cwd() / '.python-version').read_text(encoding='utf-8').strip()\n" "run_path(sys.argv.pop(1), run_name='__main__')\n", encoding="utf-8", ) shim.chmod(0o755) first, second = tmp_path / "first", tmp_path / "second" first.mkdir() second.mkdir() (first / ".python-version").write_text(str(first), encoding="utf-8") (second / ".python-version").write_text(str(second), encoding="utf-8") cache = DiskCache(tmp_path / "cache") monkeypatch.chdir(first) earlier = PythonInfo.from_exe(str(shim), cache, ignore_cache=ignore_cache, resolve_to_host=False) monkeypatch.chdir(second) later = PythonInfo.from_exe(str(shim), cache, ignore_cache=ignore_cache, resolve_to_host=False) assert earlier is not None assert later is not None assert (earlier.prefix, later.prefix) == (str(first), str(second)) @pytest.mark.skipif(sys.platform == "win32", reason="POSIX shell fake interpreter") def test_from_exe_marker_without_version(tmp_path: Path, caplog: pytest.LogCaptureFixture) -> None: caplog.set_level(logging.INFO) exe = _write_fake_python(tmp_path, stderr="unsupported Python version ") assert PythonInfo.from_exe(str(exe), raise_on_error=False, ignore_cache=True) is None record = next(iter(caplog.records)) assert record.getMessage() == f"{exe} is Python unknown, older than the minimum 3.6 python-discovery can query" @pytest.mark.skipif(sys.platform == "win32", reason="POSIX shell fake interpreter") @pytest.mark.parametrize( ("stderr", "code"), [ pytest.param("SyntaxError: invalid syntax", 1, id="syntax-error"), pytest.param("unsupported Python version 3.5.10", 1, id="marker-wrong-exit-code"), pytest.param("something crashed", 79, id="exit-code-without-marker"), ], ) def test_from_exe_query_failure_retries( tmp_path: Path, caplog: pytest.LogCaptureFixture, stderr: str, code: int ) -> None: caplog.set_level(logging.INFO) exe = _write_fake_python(tmp_path, stderr=stderr, code=code) assert PythonInfo.from_exe(str(exe), raise_on_error=False, ignore_cache=True) is None assert not [record for record in caplog.records if record.levelno == logging.WARNING] assert any("failed to query" in record.getMessage() for record in caplog.records) assert len(_interrogations(exe)) == 2 def test_from_exe_cache_entry_with_bad_content(tmp_path: Path) -> None: cache = DiskCache(tmp_path) path = Path(sys.executable) env = dict(os.environ) result = _get_via_file_cache(PythonInfo, cache, path, sys.executable, env) assert isinstance(result, PythonInfo) store = cache.py_info(path) data = store.read() assert data is not None data["content"] = "garbage" store.write(data) required = _get_via_file_cache(PythonInfo, cache, path, sys.executable, env) assert isinstance(required, PythonInfo) assert isinstance(store.read(), dict) @pytest.mark.parametrize( ("folder", "suffix"), [ pytest.param(".", ".json", id="entry"), pytest.param("locks-v2" if os.name == "posix" else ".", ".lock", id="lock"), ], ) def test_from_exe_recovers_unusable_cache_path(tmp_path: Path, folder: str, suffix: str) -> None: cache = DiskCache(tmp_path) expected = PythonInfo.from_exe(sys.executable, cache, ignore_cache=True, resolve_to_host=False) assert expected is not None entry = next(tmp_path.rglob("*.json")) blocked = entry.parent / folder / f"{entry.stem}{suffix}" blocked.unlink(missing_ok=True) blocked.mkdir() result = PythonInfo.from_exe(sys.executable, cache, ignore_cache=True, resolve_to_host=False) assert result is not None assert result.version_info == expected.version_info _LAUNCHER: Final[str] = r"C:\Program Files\Python Launcher\py.exe" _ARGS: Final[str] = '-3.11 --config "settings folder\\python.json"' _SELECTED: Final[str] = r"C:\Python311\python.exe" @pytest.fixture def popen(mocker: MockerFixture) -> MagicMock: start, end = "a" * 32, "b" * 32 mocker.patch("python_discovery._cached_py_info.gen_cookie", side_effect=[start, end]) info = PythonInfo() info.executable = _SELECTED out = f"{start[::-1]}{info.to_json()}{end[::-1]}" process = _mock_process(out, "", 0) return mocker.patch("python_discovery._cached_py_info.Popen", return_value=process) def test_executable_args_precede_query_script(popen: MagicMock) -> None: _run_subprocess(PythonInfo, _LAUNCHER, dict(os.environ), _ARGS) cmd = popen.call_args.args[0] assert cmd.startswith(f"{list2cmdline([_LAUNCHER])} {_ARGS} ") assert cmd.endswith(f" {'a' * 32} {'b' * 32}") @pytest.mark.usefixtures("popen") def test_executable_args_report_the_selected_interpreter() -> None: failure, result = _run_subprocess(PythonInfo, _LAUNCHER, dict(os.environ), _ARGS) assert failure is None assert result is not None assert result.executable == _SELECTED def test_executable_args_disk_entry_not_reused_without_args(tmp_path: Path, mocker: MockerFixture) -> None: with_args, without_args = PythonInfo(), PythonInfo() with_args.executable = "selected" query = mocker.patch( "python_discovery._cached_py_info._run_subprocess", side_effect=[(None, with_args), (None, without_args)], ) cache, path, env = DiskCache(tmp_path), Path(sys.executable), dict(os.environ) _get_via_file_cache(PythonInfo, cache, path, sys.executable, env, executable_args=_ARGS) result = _get_via_file_cache(PythonInfo, cache, path, sys.executable, env) assert result is without_args assert query.call_count == 2 def test_executable_args_memory_cache_keyed_by_args(tmp_path: Path, mocker: MockerFixture) -> None: mocker.patch.dict("python_discovery._cached_py_info._CACHE", clear=True) first, second = PythonInfo(), PythonInfo() query = mocker.patch("python_discovery._cached_py_info._get_via_file_cache", side_effect=[first, second]) cache, env = DiskCache(tmp_path), dict(os.environ) results = [ _get_from_cache(PythonInfo, cache, sys.executable, env, ignore_cache=False, executable_args=args) for args in ("-3.11", "-3.12", "-3.11") ] assert results == [first, second, first] assert query.call_count == 2 python_discovery-1.9.5/tests/test_discovery.py0000644000000000000000000012316715273371321016664 0ustar00from __future__ import annotations import logging import os import stat import sys from copy import deepcopy from pathlib import Path from subprocess import check_call, check_output from typing import TYPE_CHECKING, Final from unittest.mock import patch from uuid import uuid4 import pytest from python_discovery import DiskCache, PythonInfo, PythonSpec, SimpleSpecifierSet, get_interpreter, iter_interpreters from python_discovery._discovery import IS_WIN, LazyPathDump, get_paths, propose_interpreters if TYPE_CHECKING: from collections.abc import Callable 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_unknown_implementation_rejects_python_on_path(tmp_path: Path, session_cache: DiskCache) -> None: env = {"PATH": str(Path(sys.executable).parent), "UV_PYTHON_INSTALL_DIR": str(tmp_path / "missing")} assert get_interpreter("missing", [], cache=session_cache, env=env) is None assert list(iter_interpreters("missing", cache=session_cache, env=env)) == [] @pytest.mark.skipif(sys.platform == "win32", reason="POSIX symlink names") def test_discovery_via_path_literal_symlink(tmp_path: Path) -> None: link = tmp_path / "python_custom" link.symlink_to(sys.executable) interpreter = get_interpreter("python_custom", [], env={"PATH": str(tmp_path)}) assert interpreter is not None assert interpreter.executable == str(link) @pytest.mark.skipif(sys.implementation.name != "cpython", reason="requires CPython") @pytest.mark.parametrize("name", [pytest.param("pypy", id="pypy"), pytest.param("Python", id="mixed-case-python")]) def test_pypy_spec_rejects_cpython_alias(name: str, tmp_path: Path, session_cache: DiskCache) -> None: threaded = "t" if PythonInfo.current_system(session_cache).free_threaded else "" version = f"{sys.version_info.major}.{sys.version_info.minor}{threaded}" (tmp_path / f"{name}{version}{'.exe' if IS_WIN else ''}").symlink_to(sys.executable) env = {"PATH": str(tmp_path), "UV_PYTHON_INSTALL_DIR": str(tmp_path / "missing")} selected = get_interpreter(f"pypy{version}", [], cache=session_cache, env=env) assert selected is None or selected.implementation == "PyPy" assert all( info.implementation == "PyPy" for info in iter_interpreters(f"pypy{version}", cache=session_cache, env=env) ) @pytest.mark.skipif(sys.implementation.name != "cpython", reason="requires CPython") def test_generic_python_spec_accepts_mixed_case_alias(tmp_path: Path, session_cache: DiskCache) -> None: threaded = "t" if PythonInfo.current_system(session_cache).free_threaded else "" version = f"{sys.version_info.major}.{sys.version_info.minor}{threaded}" alias = tmp_path / f"Python{version}{'.exe' if IS_WIN else ''}" alias.symlink_to(sys.executable) selected = get_interpreter( f"Python{version}", [], cache=session_cache, env={"PATH": str(tmp_path), "UV_PYTHON_INSTALL_DIR": str(tmp_path / "missing")}, ) assert selected is not None assert (selected.executable, selected.implementation) == (str(alias), "CPython") @pytest.mark.skipif(sys.platform == "win32", reason="POSIX shell script") @pytest.mark.parametrize( "output", [ pytest.param("{}", id="empty-object"), pytest.param( '{"version_info": {"major": 3, "minor": 11, "micro": 0, "releaselevel": "final", "serial": 0}}', id="version-only", ), pytest.param("[]", id="array"), ], ) def test_discovery_skips_candidate_with_wrong_shape_json(tmp_path: Path, output: str) -> None: first, second = tmp_path / "first", tmp_path / "second" first.mkdir() second.mkdir() name = "python_custom" fake = first / name fake.write_text(f"#!/bin/sh\nprintf '%s\\n' '{output}'\n", encoding="utf-8") fake.chmod(0o755) real = second / name real.symlink_to(sys.executable) interpreter = get_interpreter( name, cache=DiskCache(tmp_path / "cache"), env={"PATH": os.pathsep.join([str(first), str(second)]), "HOME": "/nonexistent"}, ) assert interpreter is not None assert interpreter.executable == str(real) 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 @pytest.mark.parametrize( "name", [ pytest.param("3.14.0rc1", id="prerelease"), pytest.param("python3.13-arm64-64", id="swapped-platform"), pytest.param("python_d", id="executable-name"), ], ) def test_bare_name_spec_does_not_probe_cwd( name: str, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, mocker: MockerFixture ) -> None: (tmp_path / name).touch(mode=0o755) monkeypatch.chdir(tmp_path) from_exe = mocker.patch("python_discovery._discovery.PythonInfo.from_exe", return_value=None) assert get_interpreter(name, [], env={"PATH": "", "UV_PYTHON_INSTALL_DIR": str(tmp_path / "uv")}) is None from_exe.assert_not_called() @pytest.mark.parametrize( "sep", [ pytest.param("/", id="slash"), pytest.param("\\", id="backslash", marks=pytest.mark.skipif(not IS_WIN, reason="Windows separator")), ], ) def test_relative_path_with_parent_reference( session_cache: DiskCache, monkeypatch: pytest.MonkeyPatch, sep: str ) -> None: sys_executable = Path(PythonInfo.current_system(session_cache).system_executable) monkeypatch.chdir(str(sys_executable.parent)) relative = sep.join((os.pardir, sys_executable.parent.name, sys_executable.name)) result = get_interpreter(relative, [], session_cache) assert result is not None assert os.pardir not in Path(result.executable).parts assert Path(result.executable).samefile(sys_executable) 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 @pytest.mark.parametrize("missing", ["magic-one", "python."]) def test_discovery_fallback_ok(missing: str, session_cache: DiskCache, caplog: pytest.LogCaptureFixture) -> None: caplog.set_level(logging.DEBUG) result = get_interpreter([missing, sys.executable], cache=session_cache) assert result is not None, caplog.text assert result.executable == sys.executable, caplog.text assert "accepted" in caplog.text def test_discovery_invalid_specifier_raises(session_cache: DiskCache) -> None: with pytest.raises(ValueError, match=r"^invalid version specifier '>=3\.12 <4'$"): get_interpreter([">=3.12 <4", sys.executable], cache=session_cache) @pytest.mark.parametrize( "use_iterator", [pytest.param(False, id="list"), pytest.param(True, id="iterator")], ) def test_discovery_fallback_reuses_preferred_paths( tmp_path: Path, session_cache: DiskCache, mocker: MockerFixture, *, use_iterator: bool ) -> None: executable = tmp_path / "preferred" / ("Scripts/python.exe" if IS_WIN else "bin/python") executable.parent.mkdir(parents=True) executable.touch() preferred_info = deepcopy(PythonInfo.current_system(session_cache)) preferred_info.executable = str(executable) mocker.patch.object(PythonInfo, "from_exe", return_value=preferred_info) paths = [str(executable)] result = get_interpreter( ["missing-interpreter", ">=3"], try_first_with=iter(paths) if use_iterator else paths, cache=session_cache, env={**os.environ, "PATH": str(tmp_path), "UV_PYTHON_INSTALL_DIR": str(tmp_path / "missing")}, predicate=lambda info: Path(info.executable) == executable, ) assert result is not None assert Path(result.executable) == executable def test_discovery_stops_reading_preferred_paths_after_match(session_cache: DiskCache) -> None: preferred_paths = iter([sys.executable, "unneeded"]) result = get_interpreter(">=3", try_first_with=preferred_paths, cache=session_cache) assert result is not None assert next(preferred_paths) == "unneeded" @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" check_call([sys.executable, "-m", "venv", str(good_env)]) check_call([sys.executable, "-m", "venv", str(bad_env)]) scripts_dir = "Scripts" if IS_WIN else "bin" exe_name = "python.exe" if IS_WIN else "python" 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_venvs_on_one_base_keep_their_executable(tmp_path: Path, session_cache: DiskCache) -> None: scripts_dir = "Scripts" if IS_WIN else "bin" exe_name = "python.exe" if IS_WIN else "python" exes = [tmp_path / name / scripts_dir / exe_name for name in ("a", "b")] for exe in exes: check_call([sys.executable, "-m", "venv", "--without-pip", str(exe.parents[1])]) found = [get_interpreter(str(exe), cache=session_cache) for exe in exes] assert [Path(info.executable) for info in found if info is not None] == exes def test_discovery_try_first_with_keeps_venv(tmp_path: Path, session_cache: DiskCache) -> None: venv = tmp_path / "preferred" check_call([sys.executable, "-m", "venv", "--without-pip", str(venv)]) exe = venv / ("Scripts/python.exe" if IS_WIN else "bin/python") info = get_interpreter( ">=3", try_first_with=[str(exe)], cache=session_cache, env={**os.environ, "PATH": "", "UV_PYTHON_INSTALL_DIR": str(tmp_path / "missing")}, ) assert info is not None assert ( info.executable, check_output([info.executable, "-c", "import sys; print(sys.prefix)"], text=True).strip(), ) == (str(exe), str(venv)) def test_discovery_try_first_with_skips_broken_symlink(tmp_path: Path, session_cache: DiskCache) -> None: broken = tmp_path / "missing-python" broken.symlink_to(tmp_path / "missing") info = get_interpreter( ">=3", try_first_with=[str(broken)], cache=session_cache, env={**os.environ, "PATH": "", "UV_PYTHON_INSTALL_DIR": str(tmp_path / "missing")}, ) assert info is not None assert info.executable == PythonInfo.current_system(session_cache).executable 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) assert all(path.is_absolute() for path in get_paths({})) @pytest.mark.parametrize("relative", [".", "bin", "~/.local/bin"]) def test_get_paths_skips_relative_entries(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, relative: str) -> None: monkeypatch.chdir(tmp_path) relative_dir = tmp_path / relative relative_dir.mkdir(parents=True, exist_ok=True) (relative_dir / "python3").touch() absolute_dir = tmp_path / "absolute" absolute_dir.mkdir() (absolute_dir / "python3").touch() assert list(get_paths({"PATH": f"{relative}{os.pathsep}{absolute_dir}"})) == [absolute_dir] def test_get_paths_skips_relative_default(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.chdir(tmp_path) (tmp_path / "python3").touch() absolute_dir = tmp_path / "absolute" absolute_dir.mkdir() (absolute_dir / "python3").touch() with ( patch("python_discovery._discovery.os.confstr", side_effect=AttributeError, create=True), patch("python_discovery._discovery.os.defpath", f".{os.pathsep}{absolute_dir}"), ): assert list(get_paths({})) == [absolute_dir] @pytest.mark.parametrize( ("raw_path", "expected"), [ pytest.param("{first}{sep}", ["first"], id="trailing"), pytest.param("{sep}{first}", ["first"], id="leading"), pytest.param("{first}{sep}{sep}{second}", ["first", "second"], id="doubled"), ], ) def test_get_paths_skips_empty_entries(tmp_path: Path, raw_path: str, expected: list[str]) -> None: """An empty PATH entry means "current directory" - it must never be searched for interpreters.""" dirs = {} for name in ("first", "second"): dirs[name] = tmp_path / name dirs[name].mkdir() (dirs[name] / "dummy").touch() paths = list(get_paths({"PATH": raw_path.format(first=dirs["first"], second=dirs["second"], sep=os.pathsep)})) assert paths == [dirs[name] for name in expected] def test_get_paths_empty_entry_does_not_yield_cwd(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """Reproduces the exploit: an empty PATH entry used to resolve to the caller's CWD.""" cwd_marker = tmp_path / "python3.11" cwd_marker.touch(mode=0o755) monkeypatch.chdir(tmp_path) paths = list(get_paths({"PATH": f"{os.pathsep}"})) assert not any(path.resolve() == tmp_path.resolve() for path in 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" _POSIX_PERMISSIONS: Final[pytest.MarkDecorator] = pytest.mark.skipif( IS_WIN or os.geteuid() == 0, reason="POSIX file permissions, which root bypasses" ) 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) @pytest.mark.parametrize( ("version", "target"), [ pytest.param("{outside}", "outside", id="absolute"), pytest.param("../../outside", "outside", id="traversal"), pytest.param("..", "root", id="parent"), pytest.param(".", "versions", id="current"), pytest.param("2.7.18/subdir", "nested", id="nested"), ], ) def test_shim_skips_path_like_version( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, version: str, target: str ) -> None: root = _create_version_manager(tmp_path, "PYENV_ROOT") real_binary = _create_versioned_binary(root, ("versions",), "2.7.18", "python2.7") outside = tmp_path / "outside" attack_root = { "outside": outside, "root": root, "versions": root / "versions", "nested": root / "versions" / "2.7.18" / "subdir", }[target] _create_versioned_binary(attack_root, (), "", "python2.7") shim = root / "shims" / ("python2.7.exe" if IS_WIN else "python2.7") shim.touch(mode=0o755) (tmp_path / ".python-version").write_text(f"{version.format(outside=outside)}\n2.7.18\n", encoding="utf-8") 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_skips_empty_environment_version(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") _create_versioned_binary(root / "versions", (), "", "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", ":2.7.18") with patch("python_discovery._discovery.PathPythonInfo.from_exe") as mock_from_exe: mock_from_exe.return_value = None get_interpreter("python2.7", []) assert mock_from_exe.call_args_list[0][0][0] == str(real_binary) @pytest.mark.parametrize( ("length", "version"), [pytest.param(4096, "2.7.18", id="at-limit"), pytest.param(4097, "2.7.15", id="over-limit")], ) def test_shim_oversized_python_version_file_is_skipped( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, pyenv_probed: Callable[[], tuple[str, ...]], length: int, version: str, ) -> None: root = Path(os.environ["PYENV_ROOT"]) _create_versioned_binary(root, ("versions",), "2.7.15", "python2.7") (root / "version").write_text("2.7.15\n", encoding="utf-8") (tmp_path / ".python-version").write_text("2.7.18\n#" + "x" * (length - 8), encoding="utf-8") monkeypatch.chdir(tmp_path) assert pyenv_probed() == ("versions", version) def test_shim_python_version_file_keeps_unicode_separator_in_version( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, pyenv_probed: Callable[[], tuple[str, ...]] ) -> None: (tmp_path / ".python-version").write_text("2.7.18\u2028ignored\n", encoding="utf-8") monkeypatch.chdir(tmp_path) assert pyenv_probed() == ("shims", "python2.7.exe" if IS_WIN else "python2.7") def test_shim_python_version_file_not_utf8( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, pyenv_probed: Callable[[], tuple[str, ...]] ) -> None: (tmp_path / ".python-version").write_bytes(b"# pinned by M\xfcller\n2.7.18\n") monkeypatch.chdir(tmp_path) assert pyenv_probed() == ("versions", "2.7.18") @_POSIX_PERMISSIONS def test_shim_unreadable_python_version_file_is_skipped( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, pyenv_probed: Callable[[], tuple[str, ...]] ) -> None: (tmp_path / ".python-version").write_text("2.7.18\n", encoding="utf-8") (unreadable := tmp_path / "child" / ".python-version").parent.mkdir() unreadable.write_text("3.12.0\n", encoding="utf-8") unreadable.chmod(0) monkeypatch.chdir(unreadable.parent) assert pyenv_probed() == ("versions", "2.7.18") @_POSIX_PERMISSIONS def test_shim_unsearchable_directory_is_skipped( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, pyenv_probed: Callable[[], tuple[str, ...]] ) -> None: (tmp_path / ".python-version").write_text("2.7.18\n", encoding="utf-8") (work := tmp_path / "locked" / "work").mkdir(parents=True) monkeypatch.chdir(work) work.parent.chmod(0o600) try: assert pyenv_probed() == ("versions", "2.7.18") finally: work.parent.chmod(0o700) @pytest.fixture def pyenv_probed( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, mocker: MockerFixture ) -> Callable[[], tuple[str, ...]]: root = _create_version_manager(tmp_path, "PYENV_ROOT") _create_versioned_binary(root, ("versions",), "2.7.18", "python2.7") (root / "shims" / ("python2.7.exe" if IS_WIN else "python2.7")).touch(mode=0o755) monkeypatch.setenv("PATH", str(root / "shims")) monkeypatch.setenv("PYENV_ROOT", str(root)) monkeypatch.delenv("PYENV_VERSION", raising=False) from_exe = mocker.patch("python_discovery._discovery.PathPythonInfo.from_exe", return_value=None) def _probed() -> tuple[str, ...]: get_interpreter("python2.7", []) return Path(from_exe.call_args_list[0].args[0]).relative_to(root).parts[:2] return _probed 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 proposed: list[str] = [] def reject_first(info: PythonInfo) -> bool: proposed.append(str(info.executable)) return len(proposed) > 1 threaded = "t" if current.free_threaded else "" result = get_interpreter([f"{major}.{minor}{threaded}", sys.executable], [], session_cache, predicate=reject_first) # rejecting the first proposal must not abort discovery; a later proposal (same or fallback spec) is accepted - # on hosts where every alias resolves to one interpreter the fallback re-proposes it, so executables may match assert result is not None assert len(proposed) > 1 assert str(result.executable) == proposed[-1] # no interpreter answers to this, so discovery walks every source instead of stopping at the running one _NO_UV_MATCH: Final[str] = "3.99" @pytest.fixture def uv_home(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Path: """A throwaway home, with every directory variable uv consults pointed into it or unset.""" for var in ("UV_PYTHON_INSTALL_DIR", "XDG_DATA_HOME", "APPDATA"): monkeypatch.delenv(var, raising=False) for var in ("HOME", "USERPROFILE"): monkeypatch.setenv(var, str(tmp_path)) monkeypatch.setenv("PATH", "") return tmp_path @pytest.fixture def uv_plant() -> Callable[[Path], str]: """Create a uv install under a store root and return the executable discovery should reach for.""" def _plant(root: Path) -> str: (bin_dir := root / "some-py-impl" / "bin").mkdir(parents=True) (executable := bin_dir / "python").touch() return str(executable) return _plant @pytest.fixture def uv_probed(mocker: MockerFixture) -> Callable[..., list[str]]: """Run a discovery with the interrogation subprocess stubbed out, reporting the executables it reached for.""" from_exe = mocker.patch("python_discovery._discovery.PathPythonInfo.from_exe", return_value=None) def _probed(**kwargs: object) -> list[str]: get_interpreter(_NO_UV_MATCH, [], **kwargs) return [call.args[0] for call in from_exe.call_args_list] return _probed @pytest.mark.parametrize("expand_user", [True, False], ids=["expands-user", "absolute"]) def test_discovery_uv_store_install_dir_overrides_the_platform_default( uv_home: Path, monkeypatch: pytest.MonkeyPatch, uv_plant: Callable[[Path], str], uv_probed: Callable[..., list[str]], expand_user: bool, ) -> None: monkeypatch.setenv("UV_PYTHON_INSTALL_DIR", "~/store" if expand_user else str(uv_home / "store")) uv_plant(uv_home / ".local" / "share" / "uv" / "python") executable = uv_plant(uv_home / "store") assert uv_probed() == [executable] @pytest.mark.parametrize( ("platform", "extra_env", "expected"), [ pytest.param("win32", {"APPDATA": "~/roaming"}, ["~/roaming/uv/data", "~/roaming/uv"], id="windows"), pytest.param("win32", {}, ["~/AppData/Roaming/uv/data", "~/AppData/Roaming/uv"], id="windows-without-appdata"), pytest.param("darwin", {}, ["~/Library/Application Support/uv", "~/.local/share/uv"], id="macos"), pytest.param( "darwin", {"XDG_DATA_HOME": "~/xdg"}, ["~/Library/Application Support/uv", "~/xdg/uv"], id="macos-with-xdg", ), pytest.param("linux", {}, ["~/.local/share/uv"], id="linux"), pytest.param("linux", {"XDG_DATA_HOME": "~/xdg"}, ["~/xdg/uv"], id="linux-with-xdg"), pytest.param("linux", {"XDG_DATA_HOME": "relative"}, ["~/.local/share/uv"], id="linux-ignores-relative-xdg"), ], ) @pytest.mark.usefixtures("uv_home") def test_discovery_uv_store_roots_per_platform( monkeypatch: pytest.MonkeyPatch, uv_plant: Callable[[Path], str], uv_probed: Callable[..., list[str]], platform: str, extra_env: dict[str, str], expected: list[str], ) -> None: monkeypatch.setattr(sys, "platform", platform) for name, value in extra_env.items(): monkeypatch.setenv(name, str(Path(value).expanduser())) executables = [uv_plant(Path(state_dir).expanduser() / "python") for state_dir in expected] assert uv_probed() == executables @pytest.mark.parametrize( ("appdata", "store"), [ pytest.param("", "uv/data", id="empty"), pytest.param("relative", "relative/uv/data", id="relative"), ], ) def test_discovery_uv_store_invalid_appdata_uses_profile_default( uv_home: Path, monkeypatch: pytest.MonkeyPatch, uv_plant: Callable[[Path], str], uv_probed: Callable[..., list[str]], appdata: str, store: str, ) -> None: monkeypatch.setattr(sys, "platform", "win32") monkeypatch.setenv("APPDATA", appdata) (checkout := uv_home / "checkout").mkdir() monkeypatch.chdir(checkout) uv_plant(checkout / store / "python") profile_executable = uv_plant(uv_home / "AppData" / "Roaming" / "uv" / "data" / "python") assert uv_probed() == [profile_executable] @pytest.mark.parametrize( ("platform", "profile_var", "store"), [ pytest.param("win32", "USERPROFILE", "relative/AppData/Roaming/uv/data", id="windows"), pytest.param("linux", "HOME", "relative/.local/share/uv", id="linux"), pytest.param("darwin", "HOME", "relative/Library/Application Support/uv", id="macos"), ], ) def test_discovery_uv_store_skips_relative_profile( uv_home: Path, monkeypatch: pytest.MonkeyPatch, uv_plant: Callable[[Path], str], uv_probed: Callable[..., list[str]], platform: str, profile_var: str, store: str, ) -> None: monkeypatch.setattr(sys, "platform", platform) monkeypatch.setenv(profile_var, "relative") (checkout := uv_home / "checkout").mkdir() monkeypatch.chdir(checkout) uv_plant(checkout / store / "python") assert uv_probed() == [] def test_discovery_uv_store_windows_ignores_xdg_data_home( uv_home: Path, monkeypatch: pytest.MonkeyPatch, uv_plant: Callable[[Path], str], uv_probed: Callable[..., list[str]], ) -> None: monkeypatch.setattr(sys, "platform", "win32") monkeypatch.setenv("XDG_DATA_HOME", str(uv_home / "xdg")) uv_plant(uv_home / "xdg" / "uv" / "python") executable = uv_plant(uv_home / "AppData" / "Roaming" / "uv" / "python") assert uv_probed() == [executable] def test_discovery_uv_store_reads_the_env_argument( uv_home: Path, uv_plant: Callable[[Path], str], uv_probed: Callable[..., list[str]] ) -> None: executable = uv_plant(uv_home / "store") assert uv_probed(env={"PATH": "", "UV_PYTHON_INSTALL_DIR": str(uv_home / "store")}) == [executable] @pytest.mark.parametrize( ("platform", "store"), [ pytest.param("win32", "AppData/Roaming/uv/data", id="windows"), pytest.param("darwin", "Library/Application Support/uv", id="macos"), pytest.param("linux", ".local/share/uv", id="linux"), ], ) def test_discovery_uv_store_ignores_process_home_without_mapped_home( uv_home: Path, monkeypatch: pytest.MonkeyPatch, uv_plant: Callable[[Path], str], uv_probed: Callable[..., list[str]], platform: str, store: str, ) -> None: monkeypatch.setattr(sys, "platform", platform) uv_plant(uv_home / store / "python") assert uv_probed(env={"PATH": ""}) == [] @pytest.mark.parametrize( ("platform", "variable", "store"), [ pytest.param("win32", "APPDATA", "uv/data", id="windows-appdata"), pytest.param("darwin", "XDG_DATA_HOME", "uv", id="macos-xdg"), pytest.param("linux", "XDG_DATA_HOME", "uv", id="linux-xdg"), ], ) def test_discovery_uv_store_uses_mapped_directory_without_home( uv_home: Path, monkeypatch: pytest.MonkeyPatch, uv_plant: Callable[[Path], str], uv_probed: Callable[..., list[str]], platform: str, variable: str, store: str, ) -> None: monkeypatch.setattr(sys, "platform", platform) mapped = uv_home / "mapped" executable = uv_plant(mapped / store / "python") assert uv_probed(env={"PATH": "", variable: str(mapped)}) == [executable] @pytest.mark.usefixtures("uv_home") def test_discovery_uv_store_missing_root_is_not_an_error(uv_probed: Callable[..., list[str]]) -> None: assert uv_probed() == [] def test_discovery_uv_store_is_searched_after_path( uv_home: Path, monkeypatch: pytest.MonkeyPatch, uv_plant: Callable[[Path], str], uv_probed: Callable[..., list[str]], ) -> None: store_executable = uv_plant(uv_home / ("AppData/Roaming/uv/data" if IS_WIN else ".local/share/uv") / "python") (path_dir := uv_home / "path-bin").mkdir() (path_executable := path_dir / f"python{_NO_UV_MATCH}{'.exe' if IS_WIN else ''}").touch() monkeypatch.setenv("PATH", str(path_dir)) assert uv_probed() == [str(path_executable), store_executable] @pytest.mark.parametrize( ("spec", "install_version", "releaselevel", "expected"), [ pytest.param("==3.99", "3.99.2", "final", True, id="minor-equality"), pytest.param(">=3.99,<3.100", "3.99.2", "final", True, id="minor-range"), pytest.param("==3.98", "3.99.2", "final", False, id="different-minor"), pytest.param(">=3.99a1", "3.99a2", "alpha", True, id="alpha"), pytest.param(">=3.99", "3.99a2", "alpha", True, id="alpha-minor-bound"), pytest.param(">=3.99b1", "3.99b2", "beta", True, id="beta"), pytest.param(">=3.99rc1", "3.99rc2", "candidate", True, id="candidate"), ], ) def test_discovery_uv_patch_release_matches_minor_spec( tmp_path: Path, mocker: MockerFixture, spec: str, install_version: str, releaselevel: str, expected: bool ) -> None: store = tmp_path / "uv" executable = store / f"cpython-{install_version}-linux-x86_64-gnu" / "bin" / "python" executable.parent.mkdir(parents=True) executable.touch() info = deepcopy(PythonInfo.current_system()) info.executable = str(executable) info.system_executable = str(executable) info.version_info = info.version_info._replace( major=3, minor=99, micro=2 if releaselevel == "final" else 0, releaselevel=releaselevel, serial=2 ) mocker.patch("python_discovery._discovery.PathPythonInfo.from_exe", return_value=info) found = get_interpreter(spec, [], env={"PATH": "", "UV_PYTHON_INSTALL_DIR": str(store)}) assert (found is info) is expected def test_discovery_uv_invalid_hand_built_spec_skips_install(tmp_path: Path, mocker: MockerFixture) -> None: store = tmp_path / "uv" executable = store / "cpython-3.99.2-linux-x86_64-gnu" / "bin" / "python" executable.parent.mkdir(parents=True) executable.touch() spec = PythonSpec.from_string_spec("==3.99") spec.version_specifier = SimpleSpecifierSet.from_string("==invalid") info = deepcopy(PythonInfo.current_system()) info.executable = str(executable) mocker.patch("python_discovery._discovery.PathPythonInfo.from_exe", return_value=info) candidates = list(propose_interpreters(spec, [], env={"PATH": "", "UV_PYTHON_INSTALL_DIR": str(store)})) assert all(candidate is not info for candidate, _ in candidates) python_discovery-1.9.5/tests/test_discovery_extra.py0000644000000000000000000002033215273371321020055 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.9.5/tests/test_iter_interpreters.py0000644000000000000000000004711315273371321020422 0ustar00from __future__ import annotations import contextlib import os import sys from pathlib import Path from typing import TYPE_CHECKING from uuid import uuid4 import pytest from python_discovery import DiskCache, PythonInfo, iter_interpreters from python_discovery._discovery import IS_WIN if TYPE_CHECKING: from collections.abc import Callable, Iterator from pytest_mock import MockerFixture @pytest.fixture def uv_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: """Empty uv install root with environment variables pointing at it.""" monkeypatch.delenv("XDG_DATA_HOME", raising=False) root = tmp_path / "uv-python" root.mkdir() monkeypatch.setenv("UV_PYTHON_INSTALL_DIR", str(root)) return root @pytest.fixture def uv_install(uv_dir: Path) -> Callable[..., None]: def _install(*relative: str) -> None: for entry in relative: (path := uv_dir / entry).parent.mkdir(parents=True, exist_ok=True) path.touch() return _install @pytest.fixture def uv_probed( uv_dir: Path, monkeypatch: pytest.MonkeyPatch, mocker: MockerFixture ) -> Callable[[str | None], list[str]]: """Stub out the interrogation subprocess, so a run reports the executables discovery reached for.""" monkeypatch.setenv("PATH", "") from_exe = mocker.patch("python_discovery._discovery.PathPythonInfo.from_exe", return_value=None) def _probed(key: str | None) -> list[str]: list(iter_interpreters(key)) return [Path(call.args[0]).relative_to(uv_dir).as_posix() for call in from_exe.call_args_list] return _probed def test_iter_interpreters_returns_iterator(session_cache: DiskCache) -> None: result: Iterator[PythonInfo] = iter_interpreters(sys.executable, cache=session_cache) assert iter(result) is result def test_iter_interpreters_with_abs_path_yields_one(session_cache: DiskCache) -> None: results = list(iter_interpreters(sys.executable, cache=session_cache)) assert len(results) == 1 assert results[0].executable == sys.executable @pytest.mark.usefixtures("uv_dir") def test_iter_interpreters_relative_path_with_parent_reference( session_cache: DiskCache, monkeypatch: pytest.MonkeyPatch ) -> None: sys_executable = Path(PythonInfo.current_system(session_cache).system_executable) monkeypatch.chdir(str(sys_executable.parent)) monkeypatch.setenv("PATH", "") relative = str(Path(os.pardir) / sys_executable.parent.name / sys_executable.name) results = list(iter_interpreters(relative, cache=session_cache)) assert len(results) == 1 assert os.pardir not in Path(results[0].executable).parts assert Path(results[0].executable).samefile(sys_executable) def test_iter_interpreters_sequence_dedups_across_keys(session_cache: DiskCache) -> None: results = list(iter_interpreters([sys.executable, sys.executable], cache=session_cache)) assert len(results) == 1 @pytest.mark.parametrize( ("predicate", "expected_count"), [ pytest.param(None, 1, id="none-is-noop"), pytest.param(lambda _: True, 1, id="accepts-all"), pytest.param(lambda _: False, 0, id="rejects-all"), ], ) def test_iter_interpreters_predicate( session_cache: DiskCache, predicate: Callable[[PythonInfo], bool] | None, expected_count: int, ) -> None: results = list(iter_interpreters(sys.executable, cache=session_cache, predicate=predicate)) assert len(results) == expected_count @pytest.mark.parametrize( "key_factory", [ pytest.param(lambda: uuid4().hex, id="filename-cannot-match"), pytest.param(lambda: ">=999", id="version-cannot-match"), ], ) def test_iter_interpreters_filters_to_empty( session_cache: DiskCache, key_factory: Callable[[], str], ) -> None: assert list(iter_interpreters(key_factory(), cache=session_cache)) == [] @pytest.mark.usefixtures("uv_dir") def test_iter_interpreters_no_key_includes_running_interpreter(session_cache: DiskCache) -> None: real_self = os.path.realpath(sys.executable) results = list(iter_interpreters(cache=session_cache)) assert any(os.path.realpath(info.executable) == real_self for info in results) @pytest.mark.usefixtures("uv_dir") @pytest.mark.parametrize( "name", [ pytest.param("python3.99t", id="free-threaded"), pytest.param("python3.99d", id="debug-abi"), pytest.param("python3.99-dbg", id="debug-debian"), ], ) def test_iter_interpreters_no_key_probes_variant_builds_on_path( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, mocker: MockerFixture, name: str ) -> None: exe = tmp_path / "bin" / f"{name}{'.exe' if IS_WIN else ''}" exe.parent.mkdir() exe.touch() monkeypatch.setenv("PATH", str(exe.parent)) from_exe = mocker.patch("python_discovery._discovery.PathPythonInfo.from_exe", return_value=None) list(iter_interpreters()) assert str(exe) in [call.args[0] for call in from_exe.call_args_list] @pytest.mark.usefixtures("uv_dir") @pytest.mark.parametrize( "directory_name", [ pytest.param(".", id="path-directory"), pytest.param(f"python3.99{'.exe' if IS_WIN else ''}", id="named-directory"), ], ) def test_iter_interpreters_skips_path_directories( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, mocker: MockerFixture, directory_name: str ) -> None: bin_dir = tmp_path / "bin" bin_dir.mkdir() (bin_dir / directory_name).mkdir(exist_ok=True) executable = bin_dir / f"python3.98{'.exe' if IS_WIN else ''}" executable.touch() monkeypatch.setenv("PATH", str(bin_dir)) from_exe = mocker.patch("python_discovery._discovery.PathPythonInfo.from_exe", return_value=None) list(iter_interpreters()) assert [call.args[0] for call in from_exe.call_args_list] == [str(executable)] @pytest.mark.usefixtures("uv_dir") def test_iter_interpreters_dedups_symlinks( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, session_cache: DiskCache, ) -> None: suffix = ".exe" if IS_WIN else "" for name in ("python3", "python3.99"): Path(str(tmp_path / f"{name}{suffix}")).symlink_to(sys.executable) pyvenv_cfg = Path(sys.executable).parents[1] / "pyvenv.cfg" with contextlib.suppress(FileNotFoundError): (tmp_path / pyvenv_cfg.name).write_bytes(pyvenv_cfg.read_bytes()) monkeypatch.setenv("PATH", os.pathsep.join([str(tmp_path), os.environ.get("PATH", "")])) real = os.path.realpath(sys.executable) matches = [info for info in iter_interpreters(cache=session_cache) if os.path.realpath(info.executable) == real] assert len(matches) == 1 @pytest.mark.usefixtures("uv_dir") def test_iter_interpreters_try_first_with_yields_first( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, session_cache: DiskCache, ) -> None: monkeypatch.setenv("PATH", str(tmp_path)) results = list(iter_interpreters(try_first_with=[sys.executable], cache=session_cache)) assert results assert os.path.realpath(results[0].executable) == os.path.realpath(sys.executable) def test_iter_interpreters_skips_none_from_propose( session_cache: DiskCache, mocker: MockerFixture, ) -> None: real_info = PythonInfo.current(session_cache) mocker.patch( "python_discovery._discovery.propose_interpreters", return_value=iter([(None, True), (real_info, True)]), ) results = list(iter_interpreters(cache=session_cache)) assert results == [real_info] def test_iter_interpreters_skips_when_no_executable( session_cache: DiskCache, mocker: MockerFixture, ) -> None: bogus = mocker.MagicMock(spec=PythonInfo) bogus.system_executable = None bogus.executable = None mocker.patch( "python_discovery._discovery.propose_interpreters", return_value=iter([(bogus, True)]), ) assert list(iter_interpreters(cache=session_cache)) == [] def test_iter_interpreters_uv_yields_when_interrogation_succeeds( uv_dir: Path, session_cache: DiskCache, monkeypatch: pytest.MonkeyPatch, mocker: MockerFixture, ) -> None: monkeypatch.setenv("PATH", "") bin_path = uv_dir / "cpython-3.99-fake/bin" bin_path.mkdir(parents=True) (bin_path / "python").touch() fake_info = mocker.MagicMock(spec=PythonInfo) fake_info.system_executable = str(uv_dir / "fake-unique" / "python") fake_info.executable = fake_info.system_executable fake_info.satisfies.return_value = True mocker.patch("python_discovery._discovery.PathPythonInfo.from_exe", return_value=fake_info) assert fake_info in list(iter_interpreters(cache=session_cache)) @pytest.mark.parametrize( ("real_files", "symlinks", "expected_real"), [ pytest.param( ("pypy-3.10-linux-x86_64-gnu/bin/pypy3.10",), ( ("pypy-3.10-linux-x86_64-gnu/bin/python", "pypy-3.10-linux-x86_64-gnu/bin/pypy3.10"), ("pypy-3.10-linux-x86_64-gnu/bin/pypy", "pypy-3.10-linux-x86_64-gnu/bin/pypy3.10"), ), ("pypy-3.10-linux-x86_64-gnu/bin/pypy3.10",), id="pypy-unix-with-python-symlink", ), pytest.param( ("graalpy-24.1.1-linux-x86_64-gnu/bin/graalpy",), (), ("graalpy-24.1.1-linux-x86_64-gnu/bin/graalpy",), id="graalpy-unix", ), pytest.param( ( "cpython-3.14.4-windows-x86_64-none/python.exe", "pypy-3.11.15-windows-x86_64-none/pypy3.11.exe", "graalpy-3.11-windows-x86_64-none/bin/graalpy.exe", ), (), ( "cpython-3.14.4-windows-x86_64-none/python.exe", "pypy-3.11.15-windows-x86_64-none/pypy3.11.exe", "graalpy-3.11-windows-x86_64-none/bin/graalpy.exe", ), id="windows-all-impls", ), ], ) def test_iter_interpreters_uv_layout( uv_dir: Path, session_cache: DiskCache, monkeypatch: pytest.MonkeyPatch, mocker: MockerFixture, real_files: tuple[str, ...], symlinks: tuple[tuple[str, str], ...], expected_real: tuple[str, ...], ) -> None: monkeypatch.setenv("PATH", "") for rel in real_files: path = uv_dir / rel path.parent.mkdir(parents=True, exist_ok=True) path.touch() for link_rel, target_rel in symlinks: Path(str(uv_dir / link_rel)).symlink_to(uv_dir / target_rel) mock_from_exe = mocker.patch("python_discovery._discovery.PathPythonInfo.from_exe", return_value=None) list(iter_interpreters(cache=session_cache)) interrogated_real = {os.path.realpath(call.args[0]) for call in mock_from_exe.call_args_list} assert interrogated_real == {os.path.realpath(uv_dir / r) for r in expected_real} @pytest.mark.parametrize( ("planted", "key", "expected"), [ pytest.param( ( "pypy-3.8.16-macos-aarch64-none/bin/python", "graalpy-3.8.0-macos-aarch64-none/bin/python", "cpython-3.8.20-macos-aarch64-none/bin/python", ), "3.8", ["cpython-3.8.20-macos-aarch64-none/bin/python"], id="bare-version-skips-alternative-implementations", ), pytest.param( ("cpython-3.8.20-macos-aarch64-none/bin/python", "pypy-3.8.16-macos-aarch64-none/bin/pypy3.8"), "pypy3.8", ["pypy-3.8.16-macos-aarch64-none/bin/pypy3.8"], id="implementation-spec-reaches-pypy", ), pytest.param( ( "cpython-3.8.2-macos-aarch64-none/bin/python", "cpython-3.8.20-macos-aarch64-none/bin/python", "cpython-3.8.10-macos-aarch64-none/bin/python", ), "3.8", [ "cpython-3.8.20-macos-aarch64-none/bin/python", "cpython-3.8.10-macos-aarch64-none/bin/python", "cpython-3.8.2-macos-aarch64-none/bin/python", ], id="newest-version-first", ), pytest.param( ("cpython-3.15.0b3-macos-aarch64-none/bin/python", "cpython-3.15.0-macos-aarch64-none/bin/python"), "3.15", [ "cpython-3.15.0-macos-aarch64-none/bin/python", "cpython-3.15.0b3-macos-aarch64-none/bin/python", ], id="final-release-before-prerelease", ), pytest.param( ("cpython-3.8.20-macos-aarch64-none/bin/python", "cpython-3.12.13-macos-aarch64-none/bin/python"), "3.12", ["cpython-3.12.13-macos-aarch64-none/bin/python"], id="version-rules-out-the-rest", ), pytest.param( ("cpython-3.8.20-macos-aarch64-none/bin/python", "cpython-3.12.13-macos-aarch64-none/bin/python"), ">=3.12", ["cpython-3.12.13-macos-aarch64-none/bin/python"], id="specifier-rules-out-the-rest", ), pytest.param( ( "cpython-3.9.25-macos-aarch64-none/bin/python", "cpython-3.10.19-macos-aarch64-none/bin/python", "cpython-3.13.7-macos-aarch64-none/bin/python", ), "~=3.10", [ "cpython-3.13.7-macos-aarch64-none/bin/python", "cpython-3.10.19-macos-aarch64-none/bin/python", ], id="compatible-release-keeps-later-minors", ), pytest.param( ( "cpython-3.14.6-macos-aarch64-none/bin/python", "cpython-3.14.6+freethreaded-macos-aarch64-none/bin/python", ), "3.14t", ["cpython-3.14.6+freethreaded-macos-aarch64-none/bin/python"], id="t-suffix-wants-freethreaded", ), pytest.param( ( "cpython-3.14.6-macos-aarch64-none/bin/python", "cpython-3.14.6+debug-macos-aarch64-none/bin/python", "cpython-3.14.6+freethreaded+debug-macos-aarch64-none/bin/python", ), "3.14d", ["cpython-3.14.6+debug-macos-aarch64-none/bin/python"], id="d-suffix-wants-debug", ), pytest.param( ( "cpython-3.14.6-macos-aarch64-none/bin/python", "cpython-3.14.6+debug-macos-aarch64-none/bin/python", "cpython-3.14.6+freethreaded+debug-macos-aarch64-none/bin/python", ), "3.14-dbg", ["cpython-3.14.6+debug-macos-aarch64-none/bin/python"], id="dbg-suffix-wants-debug", ), # a spec silent about `Py_DEBUG` leaves debug builds reachable, ranked behind the default build pytest.param( ( "cpython-3.14.6+debug-macos-aarch64-none/bin/python", "cpython-3.14.6-macos-aarch64-none/bin/python", ), "3.14", [ "cpython-3.14.6-macos-aarch64-none/bin/python", "cpython-3.14.6+debug-macos-aarch64-none/bin/python", ], id="unconstrained-debug-ranks-second", ), # older uv releases name GraalPy installs after the GraalVM version, not the Python one pytest.param( ("graalpy-24.1.1-linux-x86_64-gnu/bin/graalpy",), "graalpy3.11", ["graalpy-24.1.1-linux-x86_64-gnu/bin/graalpy"], id="graalpy-key-version-does-not-filter", ), pytest.param( ("a-hand-made-store/bin/python", "cpython-3.8.20-macos-aarch64-none/bin/python"), "3.8", ["cpython-3.8.20-macos-aarch64-none/bin/python", "a-hand-made-store/bin/python"], id="unrecognized-directory-probed-last", ), pytest.param( (".temp/bin/python", ".lock", "README", "cpython-3.8.20-macos-aarch64-none/bin/python"), "3.8", ["cpython-3.8.20-macos-aarch64-none/bin/python"], id="bookkeeping-entries-skipped", ), pytest.param( ("cpython-3.8.20-macos-aarch64-none/install/bin/python",), "3.8", ["cpython-3.8.20-macos-aarch64-none/install/bin/python"], id="install-subdirectory-layout", ), pytest.param( ( "pyston-3.8.12-linux-x86_64-gnu/bin/python", "graalpy-24.1.1-linux-x86_64-gnu/bin/graalpy", "pypy-3.11.15-linux-x86_64-gnu/bin/pypy3.11", "cpython-3.12.13-linux-x86_64-gnu/bin/python", ), None, [ "cpython-3.12.13-linux-x86_64-gnu/bin/python", "pypy-3.11.15-linux-x86_64-gnu/bin/pypy3.11", "graalpy-24.1.1-linux-x86_64-gnu/bin/graalpy", "pyston-3.8.12-linux-x86_64-gnu/bin/python", ], id="enumeration-orders-implementations", ), ], ) def test_iter_interpreters_uv_probe_order( uv_install: Callable[..., None], uv_probed: Callable[[str | None], list[str]], planted: tuple[str, ...], key: str | None, expected: list[str], ) -> None: uv_install(*planted) assert uv_probed(key) == expected def test_iter_interpreters_uv_minor_version_link_loses_to_its_target( uv_dir: Path, uv_install: Callable[..., None], uv_probed: Callable[[str | None], list[str]] ) -> None: """uv links `cpython-3.15` at the concrete install, and repoints it on the next patch release.""" uv_install("cpython-3.15.0b3-macos-aarch64-none/bin/python") Path(uv_dir / "cpython-3.15-macos-aarch64-none").symlink_to(uv_dir / "cpython-3.15.0b3-macos-aarch64-none") assert uv_probed("3.15") == ["cpython-3.15.0b3-macos-aarch64-none/bin/python"] @pytest.mark.parametrize( ("concrete", "link"), [ pytest.param("cpython-3.15.0b3-windows-x86_64-none", "cpython-3.15-windows-x86_64-none", id="prerelease"), # the link ties with its target on version, so directory name order decides, and "-" sorts before "." pytest.param("cpython-3.14.0-windows-x86_64-none", "cpython-3.14-windows-x86_64-none", id="first-release"), ], ) def test_iter_interpreters_uv_minor_version_junction_loses_to_its_target( uv_install: Callable[..., None], uv_probed: Callable[[str | None], list[str]], uv_junction: Callable[[str, str], None], concrete: str, link: str, ) -> None: uv_install(f"{concrete}/python.exe") uv_junction(concrete, link) assert uv_probed("3") == [f"{concrete}/python.exe"] @pytest.fixture def uv_junction(uv_dir: Path, mocker: MockerFixture) -> Callable[[str, str], None]: def _junction(target: str, link: str) -> None: if IS_WIN: # pragma: win32 cover import _winapi _winapi.CreateJunction(str(uv_dir / target), str(uv_dir / link)) else: # pragma: win32 no cover # stand in for the junction with a symlink that does not report as one (uv_dir / link).symlink_to(uv_dir / target) mocker.patch.object(Path, "is_symlink", return_value=False) return _junction def test_iter_interpreters_uv_probes_one_executable_per_install( uv_dir: Path, uv_install: Callable[..., None], monkeypatch: pytest.MonkeyPatch, mocker: MockerFixture ) -> None: """GraalPy ships bin/python as a copy of bin/graalpy, so the realpath dedup alone would spawn it twice.""" monkeypatch.setenv("PATH", "") uv_install("graalpy-3.11.0-macos-aarch64-none/bin/python", "graalpy-3.11.0-macos-aarch64-none/bin/graalpy") executable = str(uv_dir / "graalpy-3.11.0-macos-aarch64-none" / "bin" / "python") from_exe = mocker.patch( "python_discovery._discovery.PathPythonInfo.from_exe", return_value=mocker.create_autospec( PythonInfo, instance=True, system_executable=executable, executable=executable ), ) list(iter_interpreters("graalpy3.11")) assert [call.args[0] for call in from_exe.call_args_list] == [executable] python_discovery-1.9.5/tests/test_py_info_collect.py0000644000000000000000000003424715273371321020025 0ustar00from __future__ import annotations import ast import json import os import sys import sysconfig from itertools import takewhile from pathlib import Path from subprocess import run from typing import TYPE_CHECKING import pytest import vermin import python_discovery from python_discovery import PythonInfo from python_discovery._py_info import VersionInfo if TYPE_CHECKING: from collections.abc import Callable from unittest.mock import Mock from pytest_mock import MockerFixture SCRIPT = Path(python_discovery.__file__).parent / "_py_info_collect.py" def test_script_parses_down_to_python27() -> None: py2, py3 = vermin.detect(SCRIPT.read_text(encoding="utf-8")) assert py2 is not None, "script no longer parses on Python 2.7, the version gate cannot run there" assert py2 <= (2, 7) assert py3 is not None assert py3 <= (3, 6), "script grew a requirement newer than the collection floor" def test_script_version_gate_precedes_imports() -> None: body = ast.parse(SCRIPT.read_text(encoding="utf-8")).body before_gate = takewhile(lambda node: not isinstance(node, ast.If), body) imported = [ alias.name for node in before_gate if isinstance(node, (ast.Import, ast.ImportFrom)) for alias in node.names ] assert imported == ["sys"], "only sys may load before the version gate; Python 3.0/3.1 lack sysconfig" def _run_script(*args: str) -> str: return run([sys.executable, str(SCRIPT), *args], capture_output=True, text=True, check=True).stdout def test_script_version_gate_reports_executable() -> None: result = run( [ sys.executable, "-c", ( "from runpy import run_path; import sys; sys.version_info = (3, 5, 10, 'final', 0); " "run_path(sys.argv[1], run_name='__main__')" ), str(SCRIPT), ], capture_output=True, text=True, check=False, ) assert result.returncode == 79 assert result.stderr == f"unsupported Python version 3.5.10\n{sys.executable}" @pytest.mark.parametrize( ("args", "start", "end"), [ pytest.param((), "", "", id="no-cookies"), pytest.param(("startcookie",), "startcookie", "", id="start-only"), pytest.param(("startcookie", "endcookie"), "startcookie", "endcookie", id="both-cookies"), ], ) def test_script_wraps_payload_in_reversed_cookies(args: tuple[str, ...], start: str, end: str) -> None: out = _run_script(*args) prefix, suffix = start[::-1], end[::-1] assert out.startswith(prefix) assert out.endswith(suffix) payload = json.loads(out[len(prefix) : len(out) - len(suffix)]) assert payload["version_info"]["major"] == sys.version_info.major def test_script_payload_matches_in_process_collection() -> None: assert set(json.loads(_run_script())) == set(PythonInfo().to_dict()) def test_script_payload_loads_as_python_info() -> None: info = PythonInfo.from_json(_run_script()) assert tuple(info.version_info[:3]) == tuple(sys.version_info[:3]) assert info.executable == sys.executable @pytest.fixture def no_framework(mocker: MockerFixture) -> None: get_config_var = sysconfig.get_config_var mocker.patch.object( sysconfig, "get_config_var", side_effect=lambda name: "" if name == "PYTHONFRAMEWORK" else get_config_var(name), # ty: ignore[deprecated] # resolves the deprecated Literal["SO"] overload even though name is a generic str ) @pytest.fixture def not_a_venv(mocker: MockerFixture) -> None: mocker.patch.object(sys, "real_prefix", None, create=True) mocker.patch.object(sys, "base_prefix", sys.prefix) def _layout_regular_file(tmp_path: Path) -> tuple[Path, Path]: exe = tmp_path / "python" exe.touch() return exe, exe def _layout_broken_symlink(tmp_path: Path) -> tuple[Path, Path]: link = tmp_path / "python" link.symlink_to(tmp_path / "missing") return link, link def _layout_absolute_symlink(tmp_path: Path) -> tuple[Path, Path]: exe = tmp_path / "install" / "bin" / "python3.12" exe.parent.mkdir(parents=True) exe.touch() link = tmp_path / "symdir" / "python3" link.parent.mkdir() link.symlink_to(exe) return link, exe def _layout_relative_chain(tmp_path: Path) -> tuple[Path, Path]: exe = tmp_path / "python3.12" exe.touch() (tmp_path / "python3").symlink_to("python3.12") link = tmp_path / "python" link.symlink_to("python3") return link, exe def _layout_tree_symlink(tmp_path: Path) -> tuple[Path, Path]: real_bin = tmp_path / "install" / "bin" real_bin.mkdir(parents=True) (real_bin / "python3").touch() tree_link = tmp_path / "tree" tree_link.symlink_to(tmp_path / "install") via_tree = tree_link / "bin" / "python3" return via_tree, via_tree def _layout_normpath_mismatch(tmp_path: Path) -> tuple[Path, Path]: real_dir = tmp_path / "deep" / "real" real_dir.mkdir(parents=True) (tmp_path / "deep" / "exe").touch() (real_dir / "python").symlink_to("../exe") dir_link = tmp_path / "link" dir_link.symlink_to(real_dir) via_link = dir_link / "python" return via_link, via_link def _layout_stdlib_landmark(tmp_path: Path) -> tuple[Path, Path]: exe = tmp_path / "install" / "bin" / "python3.12" exe.parent.mkdir(parents=True) exe.touch() alias_bin = tmp_path / "alias" / "bin" alias_bin.mkdir(parents=True) landmark = tmp_path / "alias" / "lib" / Path(os.__file__).parent.name / "os.py" landmark.parent.mkdir(parents=True) landmark.touch() link = alias_bin / "python3" link.symlink_to(exe) return link, link @pytest.mark.skipif(sys.platform == "win32", reason="POSIX only") @pytest.mark.usefixtures("no_framework", "not_a_venv") @pytest.mark.parametrize( "layout", [ pytest.param(_layout_regular_file, id="regular-file"), pytest.param(_layout_broken_symlink, id="broken-symlink"), pytest.param(_layout_absolute_symlink, id="absolute-symlink"), pytest.param(_layout_relative_chain, id="relative-chain"), pytest.param(_layout_tree_symlink, id="tree-preserved"), pytest.param(_layout_normpath_mismatch, id="normpath-mismatch"), pytest.param(_layout_stdlib_landmark, id="stdlib-landmark-kept"), ], ) def test_system_executable_resolves_executable_symlink_only( tmp_path: Path, mocker: MockerFixture, layout: Callable[[Path], tuple[Path, Path]], ) -> None: path, expected = layout(tmp_path) mocker.patch.object(sys, "executable", str(path)) assert PythonInfo().system_executable == str(expected) @pytest.mark.skipif(sys.platform == "win32", reason="POSIX only") @pytest.mark.usefixtures("not_a_venv") def test_system_executable_framework_symlink_kept(tmp_path: Path, mocker: MockerFixture) -> None: link, _exe = _layout_absolute_symlink(tmp_path) get_config_var = sysconfig.get_config_var mocker.patch.object( sysconfig, "get_config_var", side_effect=lambda name: "Python" if name == "PYTHONFRAMEWORK" else get_config_var(name), # ty: ignore[deprecated] # resolves the deprecated Literal["SO"] overload even though name is a generic str ) mocker.patch.object(sys, "executable", str(link)) assert PythonInfo().system_executable == str(link) def _old_style_virtualenv(mocker: MockerFixture, _tmp_path: Path) -> None: mocker.patch.object(sys, "real_prefix", "/some/real/prefix", create=True) def _venv_without_base_executable(mocker: MockerFixture, _tmp_path: Path) -> None: _venv(mocker) mocker.patch.object(sys, "_base_executable", None, create=True) def _venv_base_executable_is_self(mocker: MockerFixture, _tmp_path: Path) -> None: _venv(mocker) mocker.patch.object(sys, "_base_executable", sys.executable, create=True) def _venv_missing_base_before_311(mocker: MockerFixture, tmp_path: Path) -> None: _venv(mocker) mocker.patch.object(sys, "_base_executable", str(tmp_path / "python"), create=True) mocker.patch.object(sys, "version_info", VersionInfo(3, 9, 0, "final", 0)) def _venv_missing_base_no_candidates(mocker: MockerFixture, tmp_path: Path) -> None: _venv(mocker) mocker.patch.object(sys, "_base_executable", str(tmp_path / "python"), create=True) mocker.patch.object(sys, "version_info", VersionInfo(3, 12, 0, "final", 0)) def _venv(mocker: MockerFixture) -> None: mocker.patch.object(sys, "real_prefix", None, create=True) mocker.patch.object(sys, "base_prefix", "/different/prefix") @pytest.mark.parametrize( "state", [ pytest.param(_old_style_virtualenv, id="old-style-virtualenv"), pytest.param(_venv_without_base_executable, id="no-base-executable"), pytest.param(_venv_base_executable_is_self, id="base-executable-is-self"), pytest.param(_venv_missing_base_before_311, id="missing-base-before-311"), pytest.param(_venv_missing_base_no_candidates, id="missing-base-no-candidates"), ], ) def test_system_executable_undetermined( mocker: MockerFixture, tmp_path: Path, state: Callable[[MockerFixture, Path], None] ) -> None: state(mocker, tmp_path) assert PythonInfo().system_executable is None def test_system_executable_from_existing_base(mocker: MockerFixture, tmp_path: Path) -> None: base = tmp_path / "python3.12" base.touch() _venv(mocker) mocker.patch.object(sys, "_base_executable", str(base), create=True) assert PythonInfo().system_executable == str(base) @pytest.mark.usefixtures("no_framework") @pytest.mark.parametrize( "same_file", [ pytest.param(False, id="distinct"), pytest.param(True, id="alias"), ], ) def test_system_executable_prefers_exact_version(mocker: MockerFixture, tmp_path: Path, *, same_file: bool) -> None: bin_dir = tmp_path / "bin" bin_dir.mkdir() versioned = bin_dir / "python3.13" versioned.touch() generic = bin_dir / "python3" if same_file: generic.symlink_to(versioned.name) landmark = tmp_path / "lib" / Path(os.__file__).parent.name / "os.py" landmark.parent.mkdir(parents=True) landmark.touch() else: generic.touch() _venv(mocker) mocker.patch.object(sys, "_base_executable", str(generic), create=True) mocker.patch.object(sys, "version_info", VersionInfo(3, 13, 0, "final", 0)) assert PythonInfo().system_executable == str(generic if same_file else versioned) def test_system_executable_versioned_fallback(mocker: MockerFixture, tmp_path: Path) -> None: _venv_missing_base_no_candidates(mocker, tmp_path) versioned = tmp_path / "python3.12" versioned.touch() (tmp_path / "python3").touch() assert PythonInfo().system_executable == str(versioned) def test_system_executable_pypy_fallback(mocker: MockerFixture, tmp_path: Path) -> None: _venv_missing_base_no_candidates(mocker, tmp_path) mocker.patch("platform.python_implementation", return_value="PyPy") mocker.patch.object(sys, "pypy_version_info", (7, 3, 11, "final", 0), create=True) pypy = tmp_path / "pypy3" pypy.touch() assert PythonInfo().system_executable == str(pypy) def test_tcl_tk_libs_none_without_env(mocker: MockerFixture) -> None: mocker.patch.dict(os.environ) os.environ.pop("TCL_LIBRARY", None) info = PythonInfo() assert (info.tcl_lib, info.tk_lib) == (None, None) class _TclError(Exception): """Stands in for tkinter.TclError; an except clause needs a real exception type.""" def _fake_tkinter(mocker: MockerFixture, eval_side_effect: Callable[[str], str] | Exception) -> Mock: evaluate = mocker.Mock(side_effect=eval_side_effect) profile = mocker.Mock(return_value=None) interpreter = type( "Tk", (), {"__init__": lambda self, **_: self.readprofile("Tk", "Tk"), "readprofile": profile, "eval": evaluate} ) module = mocker.MagicMock(TclError=_TclError, Tk=interpreter) module.Tcl.side_effect = lambda: interpreter(useTk=False) mocker.patch.dict(sys.modules, {"tkinter": module}) return profile def test_tcl_tk_libs_queried_with_env(tmp_path: Path, mocker: MockerFixture) -> None: tk_dir = tmp_path / "tk8.6" tk_dir.mkdir() responses = {"info library": "/tcl-lib", "set tk_library": str(tk_dir)} _fake_tkinter(mocker, responses.__getitem__) mocker.patch.dict(os.environ, {"TCL_LIBRARY": str(tmp_path)}) info = PythonInfo() assert (info.tcl_lib, info.tk_lib) == ("/tcl-lib", str(tk_dir)) @pytest.mark.parametrize("folder", [pytest.param("tk8.6", id="minor"), pytest.param("tk8", id="major")]) def test_tk_lib_resolved_without_loading_tk(tmp_path: Path, mocker: MockerFixture, folder: str) -> None: tcl_dir, tk_dir = tmp_path / "tcl8.6", tmp_path / folder tk_dir.mkdir() (tk_dir / "tk.tcl").touch() def evaluate(script: str) -> str: if script == "info library": return str(tcl_dir) if script == "set tk_library": raise _TclError(script) raise AssertionError _fake_tkinter(mocker, evaluate) mocker.patch.dict(sys.modules, {"_tkinter": mocker.MagicMock(TK_VERSION="8.6")}) mocker.patch.dict(os.environ, {"TCL_LIBRARY": str(tcl_dir)}) info = PythonInfo() assert (info.tcl_lib, info.tk_lib) == (str(tcl_dir), str(tk_dir)) def test_tcl_tk_libs_none_on_tcl_error(tmp_path: Path, mocker: MockerFixture) -> None: _fake_tkinter(mocker, _TclError("fail")) mocker.patch.dict(os.environ, {"TCL_LIBRARY": str(tmp_path)}) info = PythonInfo() assert (info.tcl_lib, info.tk_lib) == (None, None) def test_tcl_tk_libs_skip_tk_profile(tmp_path: Path, mocker: MockerFixture) -> None: profile = _fake_tkinter(mocker, {"info library": "/tcl-lib"}.__getitem__) profile.side_effect = AssertionError("Tk profile loaded") mocker.patch("python_discovery._py_info_collect.PythonInfoCollector._resolve_tk_lib", return_value=None) mocker.patch.dict(os.environ, {"TCL_LIBRARY": str(tmp_path)}) info = PythonInfo() assert info.tcl_lib == "/tcl-lib" profile.assert_not_called() def test_system_stdlib_empty_when_scheme_lacks_path(mocker: MockerFixture) -> None: names = tuple(name for name in sysconfig.get_path_names() if name not in {"stdlib", "platstdlib"}) mocker.patch.object(sysconfig, "get_path_names", return_value=names) info = PythonInfo() assert (info.system_stdlib, info.system_stdlib_platform) == ("", "") python_discovery-1.9.5/tests/test_py_info_extra.py0000644000000000000000000005404715273371321017523 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, SimpleSpecifierSet from python_discovery._py_info import VersionInfo 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_py_info_canonicalizes_graalvm(mocker: MockerFixture) -> None: mocker.patch("platform.python_implementation", return_value="GraalVM") info = PythonInfo() assert info.implementation == "GraalPy" def test_has_venv_attribute() -> None: info = PythonInfo() assert isinstance(info.has_venv, bool) @pytest.mark.skipif(sys.platform == "win32", reason="POSIX only") @pytest.mark.skipif(bool(CURRENT.sysconfig_vars.get("PYTHONFRAMEWORK")), reason="framework builds keep recorded path") def test_from_exe_resolves_executable_only_symlink( # pragma: no cover # skipped on framework interpreter hosts tmp_path: Path, session_cache: DiskCache, ) -> None: system_exe = CURRENT.system_executable assert system_exe is not None link = tmp_path / "python3" link.symlink_to(system_exe) info = PythonInfo.from_exe(str(link), session_cache, ignore_cache=True) assert info is not None assert info.system_executable is not None assert Path(info.system_executable).samefile(system_exe) assert Path(info.system_executable).parent != tmp_path 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_resolve_to_system_repeated_prefix_with_parent_segments(tmp_path: Path, mocker: MockerFixture) -> None: (tmp_path / "child").mkdir() target = PythonInfo() target.system_executable = target.real_prefix = None target.base_prefix = str(tmp_path) repeated = PythonInfo() repeated.system_executable = repeated.real_prefix = None repeated.base_prefix = str(tmp_path / "child" / "..") mocker.patch.object(PythonInfo, "discover_exe", return_value=repeated) result = PythonInfo.resolve_to_system(None, target) assert result.system_executable == repeated.executable def test_discover_exe_cache_hit() -> None: info = copy.deepcopy(CURRENT) cached = copy.deepcopy(CURRENT) PythonInfo._cache_exe_discovery["/some/prefix", True, info.version_info] = 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, info.version_info] def test_discover_exe_cache_keeps_requested_version(tmp_path: Path) -> None: prefix = CURRENT.prefix assert prefix is not None cache = DiskCache(tmp_path) PythonInfo.clear_cache(cache) try: found = CURRENT.discover_exe(cache, prefix) assert found.version_info == CURRENT.version_info other = copy.deepcopy(CURRENT) other.version_info = other.version_info._replace(major=0) with pytest.raises(RuntimeError): other.discover_exe(cache, prefix) finally: PythonInfo.clear_cache(cache) def test_check_exe_none_path(tmp_path: Path) -> None: info = copy.deepcopy(CURRENT) result = info._check_exe(MagicMock(), str(tmp_path), "nonexistent", [], dict(os.environ), exact=True) 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( ("suffix", "expected"), [ pytest.param("t", CURRENT.free_threaded, id="free-threaded"), pytest.param("", True, id="any-build"), ], ) def test_satisfies_version_specifier_free_threaded(suffix: str, expected: bool) -> None: spec = PythonSpec.from_string_spec(f">={sys.version_info.major}.{sys.version_info.minor}{suffix}") assert CURRENT.satisfies(spec, impl_must_match=False) is expected def test_satisfies_version_specifier_unparsable_version() -> None: specifier = SimpleSpecifierSet.from_string(">=3.x") spec = PythonSpec(">=3.x", None, None, None, None, None, None, version_specifier=specifier) assert CURRENT.satisfies(spec, impl_must_match=False) is False @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, 12, 0, "candidate", 1), "<3.12.0", False, id="prerelease_not_below_final_bound"), 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"), pytest.param(VersionInfo(3, 13, 7, "final", 0), "~=3.10", True, id="compatible_release_later_minor"), pytest.param(VersionInfo(4, 0, 0, "final", 0), "~=3.10", False, id="compatible_release_next_major"), pytest.param(VersionInfo(3, 13, 0, "alpha", 1), "~=3.12.0", False, id="compatible_release_next_minor_alpha"), ], ) 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) # stem drops everything from the first dot, so python3.15t arrives as python3 and stops naming a free-threaded # build; only the Windows .exe belongs off the end. name = Path(info.original_executable).name basename = name.removesuffix(".exe") spec = PythonSpec.from_string_spec(basename) 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.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_install_path() -> None: assert isinstance(CURRENT.install_path("purelib"), str) def test_install_path_rejects_format_width() -> None: info = copy.deepcopy(CURRENT) info.distutils_install = {} info.sysconfig_paths["purelib"] = "{base:>1000000}/lib" with pytest.raises(ValueError, match="format specifiers"): info.install_path("purelib") def test_system_include() -> None: result = CURRENT.system_include assert isinstance(result, str) @pytest.mark.parametrize( ("distutils_install", "existing", "expected"), [ pytest.param({}, [], "include", id="no-headers-path"), pytest.param({"headers": "headers/UNKNOWN"}, ["headers"], "headers", id="headers"), pytest.param({"headers": "headers/UNKNOWN"}, [], "include", id="headers-missing"), pytest.param({"headers": "headers/UNKNOWN"}, ["include", "headers"], "include", id="include-present"), ], ) def test_system_include_fallback( tmp_path: Path, distutils_install: dict[str, str], existing: list[str], expected: str ) -> None: info = copy.deepcopy(CURRENT) info.prefix, info.base_prefix, info.real_prefix = str(tmp_path), None, None info.sysconfig_vars = {"base": str(tmp_path)} info.sysconfig_paths = {"include": "{base}/include"} info.distutils_install = distutils_install for name in existing: (tmp_path / name).mkdir() assert info.system_include == str(tmp_path / expected) 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_from_exe_uses_supplied_environment_for_base_probe( mocker: MockerFixture, monkeypatch: pytest.MonkeyPatch ) -> None: monkeypatch.setenv("PYTHONPATH", "process-only") target = copy.deepcopy(CURRENT) target.executable = "venv" target.system_executable = "system" mapped = copy.deepcopy(CURRENT) mapped.executable = mapped.system_executable = "system" mapped.path = ["mapped"] process_only = copy.deepcopy(mapped) process_only.path = ["process-only"] mocker.patch( "python_discovery._cached_py_info.from_exe", side_effect=lambda _cls, _cache, exe, **kwargs: { "venv": target, "system": mapped if kwargs["env"] == {"PYTHONPATH": "mapped"} else process_only, }[exe], ) info = PythonInfo.from_exe("venv", env={"PYTHONPATH": "mapped"}) assert info is not None assert info.path == ["mapped"] def test_resolve_to_system_uses_supplied_environment_for_prefix_probe(mocker: MockerFixture) -> None: target = copy.deepcopy(CURRENT) target.system_executable = None target.real_prefix = None target.base_prefix = "/prefix" mapped = copy.deepcopy(CURRENT) mapped.path = ["mapped"] mapped.system_executable = mapped.executable fallback = copy.deepcopy(CURRENT) fallback.path = ["process"] fallback.system_executable = fallback.executable mocker.patch.object( PythonInfo, "discover_exe", side_effect=lambda _cache, **kwargs: mapped if kwargs.get("env") == {"PYTHONPATH": "mapped"} else fallback, ) resolved = PythonInfo.resolve_to_system(None, target, env={"PYTHONPATH": "mapped"}) assert resolved.path == ["mapped"] 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" @pytest.mark.parametrize( "system_executable", [ pytest.param("/some/system/python", id="resolved"), pytest.param(None, id="discovered"), ], ) def test_resolve_to_system_leaves_shared_info_untouched(mocker: MockerFixture, system_executable: str | None) -> None: shared = copy.deepcopy(CURRENT) shared.system_executable = shared.executable = "/some/system/python" mocker.patch.object(PythonInfo, "from_exe", return_value=shared) mocker.patch.object(PythonInfo, "discover_exe", return_value=shared) results: list[PythonInfo] = [] for venv in ("/some/venv-a", "/some/venv-b"): target = copy.deepcopy(CURRENT) target.system_executable = system_executable target.real_prefix, target.base_prefix, target.executable = None, venv, f"{venv}/python" results.append(PythonInfo.resolve_to_system(None, target)) assert [(info.executable, info.system_executable) for info in results] == [ ("/some/venv-a/python", "/some/system/python"), ("/some/venv-b/python", "/some/system/python"), ] assert shared.executable == "/some/system/python" def test_resolve_to_system_returns_target_when_executable_unchanged() -> None: target = copy.deepcopy(CURRENT) target.system_executable = target.executable = "/some/system/python" assert PythonInfo.resolve_to_system(None, target) is target python_discovery-1.9.5/tests/test_py_spec.py0000644000000000000000000003105715273371321016313 0ustar00from __future__ import annotations import re 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 @pytest.mark.parametrize( "text", [ "python2.3.4.5", ".", "..", "...", "python.", "cpython..", "pypy...", ], ) def test_bad_py_spec(text: str) -> None: spec = PythonSpec.from_string_spec(text) assert text in repr(spec) assert spec.str_spec == text assert spec.path == text content = vars(spec) del content["str_spec"] del content["path"] assert all(v is None for v in content.values()) @pytest.mark.parametrize( "text", [ pytest.param(">=3.x", id="non-numeric-version"), pytest.param(">=3.13.", id="trailing-dot"), pytest.param(">=3.10.post1", id="post-release"), pytest.param("cpython>=v3.10", id="v-prefix"), pytest.param(">=3.12 <4", id="missing-comma"), pytest.param(">=3.12,<4.x", id="bad-last-item"), pytest.param(">=3.x,<4", id="bad-first-item"), pytest.param("<,4", id="operator-without-version"), pytest.param(">=3.12,foo", id="item-without-operator"), pytest.param(">=", id="bare-operator"), pytest.param(">=3.13t,<3.15", id="modifier-before-last-item"), ], ) def test_invalid_specifier_raises(text: str) -> None: with pytest.raises(ValueError, match=f"^invalid version specifier {re.escape(repr(text))}$"): PythonSpec.from_string_spec(text) @pytest.mark.parametrize( ("text", "expected"), [ pytest.param(">=3.13t", (">=3.13", True, None, None, None), id="free-threaded"), pytest.param(">=3.13td", (">=3.13", True, True, None, None), id="free-threaded-debug"), pytest.param(">=3.13-dbg", (">=3.13", None, True, None, None), id="debug"), pytest.param(">=3.11,<3.14t", (">=3.11,<3.14", True, None, None, None), id="set"), pytest.param("cpython>=3.12-64-arm64", (">=3.12", None, None, 64, "arm64"), id="arch-machine"), pytest.param(">=3.13", (">=3.13", None, None, None, None), id="any-build"), ], ) def test_specifier_modifiers(text: str, expected: tuple[str, bool | None, bool | None, int | None, str | None]) -> None: spec = PythonSpec.from_string_spec(text) assert ( str(spec.version_specifier), spec.free_threaded, spec.debug, spec.architecture, spec.machine, ) == expected 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_with_graalpy_implementation_alias() -> None: spec = PythonSpec.from_string_spec("graalvm>=3.12") assert spec.implementation == "graalpy" assert spec.version_specifier == SpecifierSet.from_string(">=3.12") 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"), pytest.param("cpython3.14-64-i86pc.64bit", "i86pc.64bit", id="dotted-machine"), ], ) 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 assert spec.debug is None # the debug marker sits before -arch/-machine and must not swallow the ISA @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( "spec_str", [ pytest.param("python3.13-dbg", id="debian-dbg"), pytest.param("python3.13-debug", id="debian-debug"), pytest.param("python3.13d", id="abi-flag"), pytest.param("cpython3.13d", id="impl-abi-flag"), pytest.param("python3.13td", id="free-threaded-debug"), ], ) def test_spec_parse_debug(spec_str: str) -> None: spec = PythonSpec.from_string_spec(spec_str) assert spec.debug is True assert spec.major == 3 assert spec.minor == 13 assert spec.machine is None assert spec.path is None @pytest.mark.parametrize("spec_str", ["python3.13", "python"]) def test_spec_parse_debug_unconstrained_without_marker(spec_str: str) -> None: assert PythonSpec.from_string_spec(spec_str).debug is None @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)) @pytest.mark.parametrize(("left", "right"), [("graalpy", "graalvm"), ("graalvm", "graalpy")]) def test_spec_satisfies_graalpy_implementation_aliases(left: str, right: str) -> None: spec_1 = PythonSpec.from_string_spec(left) spec_2 = PythonSpec.from_string_spec(right) assert spec_1.satisfies(spec_2) is True python_discovery-1.9.5/tests/test_py_spec_extra.py0000644000000000000000000001536515273371321017522 0ustar00from __future__ import annotations import pytest 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 @pytest.mark.parametrize("name", ["python3.12-dbg", "python3.12d", "python3.12"]) def test_generate_re_with_debug(name: str) -> None: spec = PythonSpec.from_string_spec("python3.12-dbg") pat = spec.generate_re(windows=False) assert pat.fullmatch(name) is not None @pytest.mark.parametrize( "name", [ pytest.param("python3.99t", id="free-threaded"), pytest.param("python3.99d", id="debug-abi"), pytest.param("python3.99-dbg", id="debug-short"), pytest.param("python3.99-debug", id="debug-long"), pytest.param("python3.99td", id="free-threaded-debug-abi"), pytest.param("python3.99t-dbg", id="free-threaded-debug-short"), ], ) @pytest.mark.parametrize("windows", [pytest.param(False, id="posix"), pytest.param(True, id="windows")]) def test_generate_re_all_implementations_matches_variant_names(name: str, windows: bool) -> None: spec = PythonSpec("", None, None, None, None, None, None) pat = spec.generate_re(windows=windows, all_implementations=True) assert pat.fullmatch(f"{name}.exe" if windows else name) is not None @pytest.mark.parametrize( "name", [ pytest.param("python3.99t", id="free-threaded"), pytest.param("python3.99d", id="debug-abi"), pytest.param("python3.99-dbg", id="debug-debian"), ], ) @pytest.mark.parametrize("key", [pytest.param("python", id="name"), pytest.param("3.99", id="version")]) def test_generate_re_single_lookup_skips_variant_names(key: str, name: str) -> None: pat = PythonSpec.from_string_spec(key).generate_re(windows=False) assert pat.fullmatch(name) is None @pytest.mark.parametrize( "name", [ pytest.param("python3.99t", id="free-threaded"), pytest.param("python3.99d", id="debug-abi"), pytest.param("python3.99-dbg", id="debug-debian"), ], ) def test_generate_re_all_implementations_keeps_ruled_out_variants_out(name: str) -> None: spec = PythonSpec("", None, 3, 99, None, None, None, free_threaded=False, debug=False) pat = spec.generate_re(windows=False, all_implementations=True) assert pat.fullmatch(name) is None def test_single_digit_version() -> None: spec = PythonSpec.from_string_spec("python3") assert spec.major == 3 assert spec.minor is None 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" @pytest.mark.parametrize( ("candidate", "text", "expected"), [ pytest.param("3.11", ">=3.13", False, id="minor-too-old"), pytest.param("3.14", "<3.13", False, id="minor-too-new"), pytest.param("3.11", "==3.12.*", False, id="minor-wildcard-miss"), pytest.param("3.12", "==3.12.*", True, id="minor-wildcard-hit"), pytest.param("3.13", ">=3.12,<3.14", True, id="minor-in-range"), pytest.param("3", ">=4", False, id="major-too-old"), pytest.param("3.12", ">=3.12.5", True, id="micro-unknown"), pytest.param("3.12.1", "==3.12", True, id="micro-at-minor-precision"), pytest.param("3.14", "==3.14rc1", True, id="prerelease-unknown"), pytest.param("3.14", "<=3.14rc1", True, id="prerelease-upper-bound-unknown"), pytest.param("3.14.0", "==3.14.0rc1", True, id="prerelease-unknown-at-micro-precision"), pytest.param("3.13", ">=3.14rc1", False, id="earlier-release-still-filtered"), ], ) def test_version_specifier_compared_at_written_precision(candidate: str, text: str, expected: bool) -> None: assert PythonSpec.from_string_spec(candidate).satisfies(PythonSpec.from_string_spec(text)) is expected python_discovery-1.9.5/tests/test_query_timeout.py0000644000000000000000000001016515273371321017561 0ustar00from __future__ import annotations import logging import os import sys from time import monotonic from typing import TYPE_CHECKING import pytest from python_discovery import PythonInfo from python_discovery._cached_py_info import _collect_query_output if TYPE_CHECKING: from pathlib import Path from pytest_mock import MockerFixture @pytest.mark.parametrize( ("raw", "expected"), [ pytest.param(None, 15.0, id="default"), pytest.param("30", 30.0, id="seconds"), pytest.param(" 0.5 ", 0.5, id="padded-fraction"), pytest.param("inf", None, id="inf"), pytest.param("1e30", None, id="beyond-platform-wait"), pytest.param("2147483.647", 2147483.647, id="wait-limit"), pytest.param("2147483.648", None, id="above-wait-limit"), ], ) def test_from_exe_query_timeout( mocker: MockerFixture, caplog: pytest.LogCaptureFixture, raw: str | None, expected: float | None ) -> None: collect = mocker.patch("python_discovery._cached_py_info._collect_query_output", wraps=_collect_query_output) env = {key: value for key, value in os.environ.items() if key != "PY_DISCOVERY_TIMEOUT"} if raw is not None: env["PY_DISCOVERY_TIMEOUT"] = raw result = PythonInfo.from_exe(sys.executable, env=env, ignore_cache=True, resolve_to_host=False) assert result is not None assert result.version_info == sys.version_info assert collect.call_args_list[0].args[1] == expected assert not caplog.records @pytest.mark.parametrize( "raw", [ pytest.param("", id="empty"), pytest.param("abc", id="words"), pytest.param("0x10", id="hex"), pytest.param("nan", id="nan"), pytest.param("0", id="zero"), pytest.param("-1", id="negative"), pytest.param("-inf", id="negative-inf"), pytest.param("1e-999", id="underflow"), ], ) def test_from_exe_invalid_timeout_warns_once(mocker: MockerFixture, caplog: pytest.LogCaptureFixture, raw: str) -> None: collect = mocker.patch("python_discovery._cached_py_info._collect_query_output", wraps=_collect_query_output) env = {**os.environ, "PY_DISCOVERY_TIMEOUT": raw} for _ in range(2): result = PythonInfo.from_exe(sys.executable, env=env, ignore_cache=True, resolve_to_host=False) assert result is not None assert result.version_info == sys.version_info assert [call.args[1] for call in collect.call_args_list] == [15.0] * 2 assert [(record.levelno, record.getMessage()) for record in caplog.records] == [ (logging.WARNING, f"ignoring PY_DISCOVERY_TIMEOUT={raw!r}, not a positive number of seconds; using 15.0") ] def test_from_exe_timeout_warning_cache_is_bounded(caplog: pytest.LogCaptureFixture) -> None: for index in range(129): result = PythonInfo.from_exe( sys.executable, env={**os.environ, "PY_DISCOVERY_TIMEOUT": f"invalid-timeout-{index}"}, ignore_cache=True, resolve_to_host=False, ) assert result is not None assert result.version_info == sys.version_info caplog.clear() for raw in ("invalid-timeout-128", "invalid-timeout-0"): result = PythonInfo.from_exe( sys.executable, env={**os.environ, "PY_DISCOVERY_TIMEOUT": raw}, ignore_cache=True, resolve_to_host=False ) assert result is not None assert result.version_info == sys.version_info assert [record.getMessage() for record in caplog.records] == [ "ignoring PY_DISCOVERY_TIMEOUT='invalid-timeout-0', not a positive number of seconds; using 15.0" ] @pytest.mark.skipif(sys.platform == "win32", reason="POSIX shell launcher") def test_from_exe_timeout_with_inherited_pipes(tmp_path: Path) -> None: executable = tmp_path / "python3.11" executable.write_text("#!/bin/sh\n/bin/sleep 3 &\nexit 0\n") executable.chmod(0o755) started = monotonic() result = PythonInfo.from_exe( str(executable), env={**os.environ, "PY_DISCOVERY_TIMEOUT": "0.1"}, ignore_cache=True, raise_on_error=False, resolve_to_host=False, ) assert result is None assert monotonic() - started < 2.5 python_discovery-1.9.5/tests/test_specifier.py0000644000000000000000000002673215273371321016626 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") # ruff:ignore[unnecessary-dunder-call] 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") # ruff:ignore[unnecessary-dunder-call] 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") # ruff:ignore[unnecessary-dunder-call] 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", "3.12.0rc1", False, id="lt-boundary-prerelease-two-part"), pytest.param("<3.12.0", "3.12.0rc1", False, id="lt-boundary-prerelease-three-part"), pytest.param("<3.12.0rc2", "3.12.0rc1", True, id="lt-explicit-prerelease-bound"), pytest.param("<3.12.0", "3.11.9rc1", True, id="lt-lower-release-prerelease"), pytest.param("<=3.12.0", "3.12.0rc1", True, id="lte-boundary-prerelease"), 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.0", "3.13.0a1", False, id="compatible-next-minor-prerelease"), pytest.param("~=3.10", "3.13.7", True, id="compatible-two-part-next-minor"), pytest.param("~=3.10", "3.9.25", False, id="compatible-two-part-below"), pytest.param("~=3.10", "4.0.0", False, id="compatible-two-part-next-major"), pytest.param("~=3.10", "4.0.0a1", False, id="compatible-two-part-next-major-prerelease"), pytest.param("~=3.13rc1", "3.14.0", True, id="compatible-two-part-prerelease-next-minor"), 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") # ruff:ignore[unnecessary-dunder-call] 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") # ruff:ignore[unnecessary-dunder-call] 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 assert spec.contains("3.13.7") is True assert spec.contains("4.0.0") is False python_discovery-1.9.5/tests/py_info/test_py_info.py0000644000000000000000000005302315273371321017754 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, Final, NamedTuple import pytest from packaging.markers import default_environment from setuptools.dist import Distribution from python_discovery import DiskCache, MarkerEnvironment, PythonInfo, PythonSpec from python_discovery import _cached_py_info as cached_py_info from python_discovery._py_info import VersionInfo if TYPE_CHECKING: from packaging.markers import Environment 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 assert parsed["debug_build"] is bool(sysconfig.get_config_var("Py_DEBUG")) 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", [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 == 4 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) system_executable = CURRENT.system_executable assert system_executable is not None py_info.system_executable = system_executable py_info.executable = system_executable py_info.base_executable = str(path) # ty: ignore[unresolved-attribute] if pos == position: selected = py_info 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") @pytest.mark.parametrize("attr", ["free_threaded", "debug_build"]) def test_discover_exe_validates_abi_flag( attr: str, tmp_path: Path, mocker: MockerFixture, session_cache: DiskCache ) -> None: def candidate(*, flag: bool, name: str) -> PythonInfo: info = copy.deepcopy(CURRENT) setattr(info, attr, flag) exe = tmp_path / name exe.write_text("", encoding="utf-8") info.executable = str(exe) return info target = candidate(flag=True, name="target") mismatch = candidate(flag=False, name="release") match = candidate(flag=True, name="debug") by_path = {info.executable: info for info in (mismatch, match)} mocker.patch.object(target, "_find_possible_exe_names", return_value=["release", "debug"]) mocker.patch.object(target, "_find_possible_folders", return_value=[str(tmp_path)]) mocker.patch.object(target, "from_exe", side_effect=lambda exe, *_a, **_k: by_path[exe]) found = target.discover_exe(session_cache, prefix=str(tmp_path), exact=True) assert found is match # the release build is refused even though version, impl and arch match 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.executable).suffix spec = PythonSpec.from_string_spec(suffixed_name) mocker.patch.object(CURRENT, "executable", str(Path(CURRENT.executable).parent / suffixed_name)) assert CURRENT.satisfies(spec, impl_must_match=True) is True def test_satisfies_debug_spec_requires_debug_build(mocker: MockerFixture) -> None: spec = PythonSpec.from_string_spec(f"python{CURRENT.version_info.major}.{CURRENT.version_info.minor}-dbg") assert spec.debug is True mocker.patch.object(CURRENT, "free_threaded", spec.free_threaded) mocker.patch.object(CURRENT, "debug_build", False) assert CURRENT.satisfies(spec, impl_must_match=True) is False mocker.patch.object(CURRENT, "debug_build", True) assert CURRENT.satisfies(spec, impl_must_match=True) is True def test_discover_exe_on_path_non_spec_name_not_match(mocker: MockerFixture) -> None: suffixed_name = f"python{CURRENT.version_info.major}.{CURRENT.version_info.minor}m" if sys.platform == "win32": # pragma: win32 cover suffixed_name += Path(CURRENT.original_executable).suffix 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 @pytest.mark.parametrize("debug", [True, False], ids=["debug", "release"]) def test_py_info_debug_build_in_spec(*, debug: bool) -> None: info = copy.deepcopy(CURRENT) info.debug_build = debug assert ("d-" in info.spec) is debug @pytest.mark.parametrize("debug", [True, False], ids=["debug", "release"]) def test_py_info_debug_build_exe_names(*, debug: bool) -> None: info = copy.deepcopy(CURRENT) info.debug_build = debug names = info._find_possible_exe_names() abiflag_name = f"python{info.version_info.major}.{info.version_info.minor}d" assert any("_d" in n for n in names) is debug assert any(n.endswith("-dbg") for n in names) is debug assert (abiflag_name in names) is debug def test_py_info_debug_build_json_round_trip() -> None: info = copy.deepcopy(CURRENT) info.debug_build = True restored = PythonInfo.from_json(info.to_json()) assert restored.debug_build is True def test_py_info_to_dict_includes_sysconfig_platform() -> None: data = CURRENT.to_dict() assert "sysconfig_platform" in data assert data["sysconfig_platform"] == sysconfig.get_platform() def test_py_info_to_dict_preserves_property_named_answer_key() -> None: data = CURRENT.to_dict() data["system_include"] = "supplied path" assert PythonInfo.from_dict(data).to_dict()["system_include"] == "supplied path" def test_py_info_json_round_trip() -> None: json_str = CURRENT.to_json() 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 def test_marker_environment_matches_target_interpreter() -> None: info: Final[PythonInfo | None] = PythonInfo.from_exe(sys.executable, ignore_cache=True, resolve_to_host=False) assert info is not None assert info.marker_environment == default_environment() def test_marker_environment_type_has_standard_keys() -> None: assert MarkerEnvironment.__required_keys__ == default_environment().keys() def test_marker_environment_uses_target_platform(tmp_path: Path) -> None: (tmp_path / "sitecustomize.py").write_text( 'import platform\nplatform.machine = lambda: "target-machine"\nplatform.release = lambda: "target-release"\n', encoding="utf-8", ) expected: Final[Environment] = default_environment() assert expected["platform_machine"] != "target-machine" assert expected["platform_release"] != "target-release" info: Final[PythonInfo | None] = PythonInfo.from_exe( sys.executable, ignore_cache=True, resolve_to_host=False, env={**os.environ, "PYTHONPATH": str(tmp_path)}, ) assert info is not None assert info.marker_environment == { **expected, "platform_machine": "target-machine", "platform_release": "target-release", } def test_marker_environment_survives_json_round_trip() -> None: assert PythonInfo.from_json(CURRENT.to_json()).marker_environment == CURRENT.marker_environment def test_marker_environment_formats_prerelease(mocker: MockerFixture) -> None: version: Final[VersionInfo] = VersionInfo(*sys.implementation.version) mocker.patch.object(sys.implementation, "version", version._replace(releaselevel="candidate", serial=1)) assert PythonInfo().marker_environment["implementation_version"] == ( f"{version.major}.{version.minor}.{version.micro}c1" ) @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] @pytest.mark.parametrize( ("system_executable", "expected"), [ pytest.param("/usr/bin/python3", "/usr/bin/python3", id="resolved"), pytest.param(None, "/venv/bin/python", id="unresolved falls back to executable"), ], ) def test_py_info_system_exe(system_executable: str | None, expected: str) -> None: info = copy.deepcopy(CURRENT) info.executable = "/venv/bin/python" info.system_executable = system_executable assert info.system_exe == expected python_discovery-1.9.5/tests/py_info/test_py_info_exe_based_of.py0000644000000000000000000000536215273371321022442 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.9.5/tests/windows/conftest.py0000644000000000000000000001547315273371321017135 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) # ruff:ignore[exec-builtin] return loc["enum_collect"], loc["value_collect"], loc["key_open"], loc["hive_open"] # ty: ignore[invalid-return-type] class _Key: def __init__(self, value: object) -> None: self.value = value def __enter__(self) -> Self: return self def __exit__( self, exc_type: type[BaseException] | None, exc_val: BaseException | None, exc_tb: TracebackType | None ) -> None: return None def _make_enum_key(enum_collect: dict[object, dict[int, object]]) -> object: def _enum_key(key: object, at: int) -> str: key_id = key.value if isinstance(key, _Key) else key result = enum_collect[key_id][at] if isinstance(result, OSError): raise result return result # ty: ignore[invalid-return-type] return _enum_key def _make_query_value_ex(value_collect: dict[object, dict[str, object]]) -> object: def _query_value_ex(key: object, value_name: str) -> object: key_id = key.value if isinstance(key, _Key) else key result = value_collect[key_id][value_name] 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), None), info) python_discovery-1.9.5/tests/windows/test_windows.py0000644000000000000000000000432015273371321020026 0ustar00from __future__ import annotations import sys from pathlib import Path import pytest from python_discovery import PythonSpec, get_interpreter @pytest.mark.skipif(sys.platform != "win32", reason="Windows executable alias") def test_discovery_via_path_literal_symlink(tmp_path: Path) -> None: link = tmp_path / "python_custom.exe" link.symlink_to(Path(sys.base_prefix) / "python.exe") interpreter = get_interpreter("python_custom", [], env={"PATH": f"{tmp_path};{sys.base_prefix}"}) assert interpreter is not None assert interpreter.executable == str(link) @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.9.5/tests/windows/test_windows_pep514.py0000644000000000000000000002415615273371321021135 0ustar00from __future__ import annotations import json import os import shutil import subprocess import sys import textwrap from pathlib import Path from typing import TYPE_CHECKING from unittest.mock import MagicMock import pytest if TYPE_CHECKING: from collections.abc import Callable from pytest_mock import MockerFixture @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 @pytest.mark.usefixtures("_mock_registry") @pytest.mark.parametrize( ("executable_path", "install_path"), [ pytest.param(r"%LOCALAPPDATA%\Programs\Python\Python311\python.exe", None, id="unexpanded-variable"), pytest.param("python.exe", None, id="bare-name"), pytest.param(r"bin\python.exe", None, id="relative"), pytest.param("C:python.exe", None, id="drive-relative"), pytest.param(r"\Python311\python.exe", None, id="current-drive"), pytest.param(42, None, id="integer-executable"), pytest.param(b"C:\\python.exe", None, id="bytes-executable"), pytest.param(["C:\\python.exe"], None, id="list-executable"), pytest.param(None, "", id="empty-install-path"), pytest.param(None, "Python311", id="relative-install-path"), pytest.param(None, 42, id="integer-install-path"), ], ) def test_pep514_rejects_non_absolute_executable( mocker: MockerFixture, caplog: pytest.LogCaptureFixture, executable_path: str | int | bytes | list[str] | None, install_path: str | int | None, ) -> None: from python_discovery._windows import _pep514 as pep514 registered = {"ExecutablePath": executable_path, None: install_path} mocker.patch.object(pep514.winreg, "OpenKeyEx", return_value=MagicMock()) mocker.patch.object(pep514, "get_value", side_effect=lambda _key, name: registered.get(name)) mocker.patch.object(pep514.os.path, "exists", return_value=True) assert pep514.load_exe("HKCU", "PythonCore", MagicMock(), "3.11") is None assert "expected an absolute executable path" in caplog.text @pytest.fixture def load_exe_with_args(mocker: MockerFixture) -> Callable[[object], tuple[str, str | None] | None]: from python_discovery._windows import _pep514 as pep514 mocker.patch.object(pep514.winreg, "OpenKeyEx", return_value=MagicMock()) mocker.patch.object(pep514, "_resolve_exe", return_value=r"C:\py.exe") mocker.patch.object(pep514.os.path, "exists", return_value=True) def load(registered: object) -> tuple[str, str | None] | None: mocker.patch.object(pep514, "get_value", return_value=registered) return pep514.load_exe("HKCU", "PythonCore", MagicMock(), "3.11") return load @pytest.mark.usefixtures("_mock_registry") @pytest.mark.parametrize( ("registered", "expected"), [ pytest.param('-3.11 --config "a b"', '-3.11 --config "a b"', id="kept"), pytest.param(None, None, id="missing"), pytest.param("", None, id="empty"), pytest.param(" ", None, id="blank"), ], ) def test_pep514_loads_executable_arguments( load_exe_with_args: Callable[[object], tuple[str, str | None] | None], registered: str | None, expected: str | None ) -> None: assert load_exe_with_args(registered) == (r"C:\py.exe", expected) @pytest.mark.usefixtures("_mock_registry") def test_pep514_rejects_non_string_executable_arguments( load_exe_with_args: Callable[[object], tuple[str, str | None] | None], ) -> None: assert load_exe_with_args(42) is None @pytest.mark.skipif(sys.platform != "win32" or shutil.which("py") is None, reason="requires the Windows launcher") def test_pep514_launcher_arguments_select_interpreter(mocker: MockerFixture, tmp_path: Path) -> None: from python_discovery import DiskCache, PythonSpec from python_discovery._windows import _propose launcher = shutil.which("py") assert launcher is not None script = ( "import json, struct, sys; print(json.dumps([sys.executable, *sys.version_info[:2], struct.calcsize('P') * 8]))" ) probe = subprocess.run([launcher, "-3.11", "-c", script], capture_output=True, text=True, check=False) if probe.returncode: pytest.skip("the launcher cannot select 3.11") # ty: ignore[too-many-positional-arguments] # ty ships a stale pytest stub for 3.9 selected, major, minor, arch = json.loads(probe.stdout) mocker.patch.object( _propose, "discover_pythons", return_value=[("PythonCore", major, minor, arch, False, launcher, "-3.11")] ) info = next( _propose.propose_interpreters(PythonSpec.from_string_spec("python3.11"), DiskCache(tmp_path), os.environ) ) assert Path(info.executable).samefile(selected) python_discovery-1.9.5/tests/windows/winreg_mock_values.py0000644000000000000000000001672215273371321021171 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.9.5/.gitignore0000644000000000000000000000011015273371321014050 0ustar00*.pyc *.egg-info dist/ .tox/ /src/python_discovery/_version.py /uv.lock python_discovery-1.9.5/LICENSE0000644000000000000000000000177715273371321013111 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.9.5/pyproject.toml0000644000000000000000000002272515273371321015014 0ustar00[build-system] build-backend = "hatchling.build" requires = [ "hatch-fancy-pypi-readme>=25.1", "hatch-vcs>=0.5", "hatchling>=1.27; python_version<'3.10'", "hatchling>=1.28; python_version>='3.10'", ] [project] name = "python-discovery" import-names = [ "python_discovery" ] description = "Python interpreter discovery" keywords = [ "discovery", "interpreter", "python", ] license = "MIT" license-files = [ "LICENSE" ] maintainers = [ { name = "Bernát Gábor", email = "gaborjbernat@gmail.com" }, ] requires-python = ">=3.9" classifiers = [ "Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "Operating System :: MacOS :: MacOS X", "Operating System :: Microsoft :: Windows", "Operating System :: POSIX", "Programming Language :: Python :: 3 :: Only", "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 :: 3.15", "Programming Language :: Python :: Implementation :: CPython", "Topic :: Software Development :: Libraries", "Topic :: Utilities", ] dynamic = [ "readme", "version", ] dependencies = [ "filelock>=3.16.1", ] urls.Changelog = "https://github.com/tox-dev/python-discovery/blob/main/docs/changelog.rst" 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" [dependency-groups] test = [ "covdefaults>=2.3", "coverage>=7.6.1", "packaging>=23.2", "pytest>=8.3.5", "pytest-mock>=3.14.1", "setuptools>=75.3.4", "typing-extensions>=4.12", "vermin>=1.8", ] docs = [ "furo>=2025.12.19", "sphinx>=9.1", "sphinx-autodoc-typehints>=3.6.3", "sphinx-llm>=0.4.1,!=1.1", "sphinxcontrib-mermaid>=2", "sphinxcontrib-towncrier>=0.4", "towncrier>=25.8", ] build = [ "cython>=3", "hatch-fancy-pypi-readme>=25.1", "hatch-vcs>=0.5", "hatchling>=1.27; python_version<'3.10'", "hatchling>=1.28; python_version>='3.10'", "setuptools>=80", ] pkg-meta = [ "check-wheel-contents>=0.6.3", "twine>=6.2", "uv>=0.12.22", ] release-news = [ "release-creator>=4.4", "towncrier>=26.9", ] release-notes = [ "release-creator>=4.4", ] release-prepare = [ "prek>=0.2", "release-creator>=4.4", "towncrier>=26.9", ] [tool.hatch] version.source = "vcs" metadata.hooks.fancy-pypi-readme.content-type = "text/markdown" metadata.hooks.fancy-pypi-readme.fragments = [ { path = "README.md" } ] [tool.uv] dependency-groups.docs = { requires-python = ">=3.12" } dependency-groups.pkg-meta = { requires-python = ">=3.10" } dependency-groups.release-news = { requires-python = ">=3.15" } dependency-groups.release-notes = { requires-python = ">=3.15" } dependency-groups.release-prepare = { requires-python = ">=3.15" } [tool.ruff] target-version = "py39" line-length = 120 format.preview = true format.docstring-code-line-length = 100 format.docstring-code-format = true lint.select = [ "ALL", ] lint.ignore = [ "CPY", # No copyright statements "docstring-missing-exception", # `raises` is not documented in docstring "docstring-missing-returns", # `return` is not documented in docstring "docstring-missing-yields", # `yield` is not documented in docstring "hardcoded-bind-all-interfaces", # Possible binding to all interface "incorrect-blank-line-before-class", # `one-blank-line-before-class` and `no-blank-line-before-class` are incompatible "missing-trailing-comma", # Conflict with formatter "multi-line-summary-first-line", # `multi-line-summary-first-line` and `multi-line-summary-second-line` are incompatible "single-line-implicit-string-concatenation", # Conflict with formatter ] lint.per-file-ignores."docs/**/*.py" = [ "implicit-namespace-package", # 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", # string-based os.path kept for parity with the interrogation script data ] lint.per-file-ignores."src/python_discovery/_py_info_collect.py" = [ "ANN", # file must parse on Python 2.7 for the version gate: no annotations "missing-required-import", # `from __future__ import annotations` is a SyntaxError below 3.7 "module-import-not-at-top-of-file", # the version gate must run before importing sysconfig, absent on 3.0/3.1 "PTH", # must use os.path — file runs as subprocess script with only stdlib "UP", # pyupgrade pushes syntax newer than the 2.7 parse floor, e.g. f-strings ] 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."tasks/**/*.py" = [ "D", # release helper scripts, not API "implicit-namespace-package", # no __init__.py in tasks directory "subprocess-without-shell-equals-true", # `subprocess` call: argv built from trusted inputs (version, gh CLI) "suspicious-subprocess-import", # subprocess import (release tooling) ] lint.per-file-ignores."tests/**/*.py" = [ "assert", # asserts allowed in tests "D", # don't care about documentation in tests "FBT", # don't care about booleans as positional arguments in tests "implicit-namespace-package", # no implicit namespace "import-outside-top-level", # imports inside test functions (conditional on mocking) "import-private-name", # private imports needed to test internal APIs "magic-value-comparison", # Magic value used in comparison "private-member-access", # private member access needed to test internals "pytest-fixture-autouse", # shared setup fixtures are meant to apply to every test "subprocess-without-shell-equals-true", # `subprocess` call: check for execution of untrusted input "suspicious-subprocess-import", # subprocess import "too-many-arguments", # too many arguments (pytest fixtures) "too-many-positional-arguments", # too many positional arguments (pytest fixtures) ] lint.per-file-ignores."tests/windows/winreg_mock_values.py" = [ "undefined-name", # 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.15" [tool.ty] src.exclude = [ "tests/windows/winreg_mock_values.py" ] [[tool.ty.overrides]] include = [ "src/python_discovery/_py_info.py", "src/python_discovery/_py_info_collect.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.source = [ "python_discovery", "tests", ] run.branch = true run.dynamic_context = "test_function" run.parallel = true run.plugins = [ "covdefaults", ] 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 [tool.towncrier] name = "python-discovery" directory = "docs/changelog" filename = "docs/changelog.rst" template = "docs/changelog/template.jinja2" title_format = false issue_format = ":issue:`{issue}`" type = [ { directory = "feature", name = "Added", showcontent = true }, { directory = "change", name = "Changes", showcontent = true }, { directory = "bugfix", name = "Fixed", showcontent = true }, { directory = "doc", name = "Documentation", showcontent = true }, { directory = "breaking", name = "Breaking changes", showcontent = true }, { directory = "security", name = "Security", showcontent = true }, { directory = "performance", name = "Performance", showcontent = true }, { directory = "deprecation", name = "Deprecated", showcontent = true }, { directory = "removal", name = "Removed", showcontent = true }, { directory = "packaging", name = "Packaging", showcontent = true }, ] [tool.nab] mode = "universal" base-group = "project-runtime" uploaded-prior-to = "P7D" decision-order = "stable" matrix.python = ">=3.15,<3.16" matrix.platforms = [ "linux_x86_64" ] # release-creator 4.4.0 needs a seven-day cutoff exception through 2026-11-09. packages.release-creator.uploaded-prior-to = 2026-11-02T21:45:00Z python_discovery-1.9.5/PKG-INFO0000644000000000000000000001021315273371321013162 0ustar00Metadata-Version: 2.5 Name: python-discovery Version: 1.9.5 Import-Name: python_discovery Summary: Python interpreter discovery Project-URL: Changelog, https://github.com/tox-dev/python-discovery/blob/main/docs/changelog.rst 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-Expression: MIT License-File: LICENSE Keywords: discovery,interpreter,python Classifier: Development Status :: 5 - Production/Stable Classifier: Intended Audience :: Developers 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.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 :: 3.15 Classifier: Programming Language :: Python :: Implementation :: CPython Classifier: Topic :: Software Development :: Libraries Classifier: Topic :: Utilities Requires-Python: >=3.9 Requires-Dist: filelock>=3.16.1 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) [![OpenSSF Scorecard](https://api.scorecard.dev/projects/github.com/tox-dev/python-discovery/badge)](https://scorecard.dev/viewer/?uri=github.com/tox-dev/python-discovery) [![OpenSSF Best Practices](https://www.bestpractices.dev/projects/16262/badge)](https://www.bestpractices.dev/projects/16262) ## 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. The library runs on Python 3.9+ and discovers interpreters as old as Python 3.6; anything older is skipped with a warning naming the version it found. ## 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` - [Version specifier](https://packaging.python.org/en/latest/specifications/version-specifiers/): `>=3.10`, `>=3.11,<3.13` ## Documentation Full documentation is available at [python-discovery.readthedocs.io](https://python-discovery.readthedocs.io/en/latest/)