python_discovery-1.9.5/.pre-commit-config.yaml 0000644 0000000 0000000 00000004602 15273371321 016353 0 ustar 00 ci:
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.yaml 0000644 0000000 0000000 00000000407 15273371321 015320 0 ustar 00 version: 2
build:
os: ubuntu-lts-latest
tools: {}
commands:
- curl -LsSf https://astral.sh/uv/install.sh | sh
- ~/.local/bin/uv tool install tox --with tox-uv -p 3.15 --managed-python
- ~/.local/bin/tox run -e docs -- "$READTHEDOCS_OUTPUT/html"
python_discovery-1.9.5/.readthedocs.yml 0000644 0000000 0000000 00000000404 15273371321 015154 0 ustar 00 version: 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.md 0000644 0000000 0000000 00000006264 15273371321 014677 0 ustar 00 # 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.md 0000644 0000000 0000000 00000000220 15273371321 014313 0 ustar 00 # 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.md 0000644 0000000 0000000 00000005200 15273371321 013344 0 ustar 00 # [`python-discovery`](https://python-discovery.readthedocs.io/en/latest/)
[](https://pypi.org/project/python-discovery/)
[](https://pypi.org/project/python-discovery/)
[](https://pepy.tech/project/python-discovery)
[](https://github.com/tox-dev/python-discovery/actions/workflows/check.yaml)
[](https://python-discovery.readthedocs.io/en/latest/?badge=latest)
[](https://scorecard.dev/viewer/?uri=github.com/tox-dev/python-discovery)
[](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.toml 0000644 0000000 0000000 00000263161 15273371321 014277 0 ustar 00 lock-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.toml 0000644 0000000 0000000 00000004641 15273371321 013604 0 ustar 00 requires = [ "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.yaml 0000644 0000000 0000000 00000000061 15273371321 015403 0 ustar 00 github: gaborbernat
thanks_dev: u/gh/gaborbernat
python_discovery-1.9.5/.github/SECURITY.md 0000644 0000000 0000000 00000006344 15273371321 015230 0 ustar 00 # 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.md 0000644 0000000 0000000 00000021220 15273371321 015656 0 ustar 00 # 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.yaml 0000644 0000000 0000000 00000001103 15273371321 016414 0 ustar 00 version: 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.yaml 0000644 0000000 0000000 00000000162 15273371321 015733 0 ustar 00 changelog:
exclude:
authors:
- dependabot[bot]
- github-actions[bot]
- pre-commit-ci[bot]
python_discovery-1.9.5/.github/workflows/check.yaml 0000644 0000000 0000000 00000004770 15273371321 017436 0 ustar 00 name: 🧪 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.yaml 0000644 0000000 0000000 00000001035 15273371321 017617 0 ustar 00 name: 🛡️ 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.yaml 0000644 0000000 0000000 00000002033 15273371321 017323 0 ustar 00 name: 📝 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.yaml 0000644 0000000 0000000 00000001211 15273371321 021420 0 ustar 00 name: 🏷️ 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.yaml 0000644 0000000 0000000 00000003604 15273371321 017774 0 ustar 00 name: 🚀 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.yaml 0000644 0000000 0000000 00000002017 15273371321 020316 0 ustar 00 name: 🛡️ 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.yaml 0000644 0000000 0000000 00000001302 15273371321 022550 0 ustar 00 name: 🔒 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.rst 0000644 0000000 0000000 00000050263 15273371321 015507 0 ustar 00 #################
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.py 0000644 0000000 0000000 00000004306 15273371321 014322 0 ustar 00 """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.rst 0000644 0000000 0000000 00000002607 15273371321 016266 0 ustar 00 Contributing
============
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.rst 0000644 0000000 0000000 00000053367 15273371321 016112 0 ustar 00 How it works
============
Where does python-discovery look?
---------------------------------
When you call :func:`~python_discovery.get_interpreter`, the library checks several locations in
order. It stops as soon as it finds an interpreter that matches your spec.
.. mermaid::
flowchart TD
Start["get_interpreter()"] --> AbsPath{"Is spec an
absolute path?"}
AbsPath -->|Yes| TryAbs["Use path directly"]
AbsPath -->|No| TryFirst["try_first_with paths"]
TryFirst --> RelPath{"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.rst 0000644 0000000 0000000 00000002622 15273371321 014663 0 ustar 00 python-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.css 0000644 0000000 0000000 00000000070 15273371321 016467 0 ustar 00 .sidebar-logo img {
max-width: 100%;
width: 100%;
}
python_discovery-1.9.5/docs/_static/github.svg 0000644 0000000 0000000 00000001273 15273371321 016454 0 ustar 00
python_discovery-1.9.5/docs/_static/logo.svg 0000644 0000000 0000000 00000001043 15273371321 016125 0 ustar 00
python_discovery-1.9.5/docs/changelog/template.jinja2 0000644 0000000 0000000 00000002467 15273371321 017672 0 ustar 00 {% 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.rst 0000644 0000000 0000000 00000021542 15273371321 020225 0 ustar 00 How-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.rst 0000644 0000000 0000000 00000000164 15273371321 016262 0 ustar 00 API reference
=============
.. automodule:: python_discovery
:members:
:undoc-members:
:show-inheritance:
python_discovery-1.9.5/docs/reference/environment-variables.rst 0000644 0000000 0000000 00000003014 15273371321 022020 0 ustar 00 Environment 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.rst 0000644 0000000 0000000 00000021633 15273371321 020527 0 ustar 00 Getting started
===============
Installation
------------
.. code-block:: console
pip install python-discovery
Core concepts
-------------
Before diving into code, here are the key ideas:
- **Interpreter** -- a Python executable on your system (e.g., ``/usr/bin/python3.12``).
- **Spec** -- a short string describing what you are looking for (e.g., ``python3.12``, ``pypy3.9``, ``>=3.11``).
- **Discovery** -- the process of searching your system for an interpreter that matches a spec.
- **Cache** -- a disk store that remembers previously discovered interpreters so the next lookup is instant.
- **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__.py 0000644 0000000 0000000 00000001524 15273371321 020402 0 ustar 00 """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.py 0000644 0000000 0000000 00000020376 15273371321 020053 0 ustar 00 """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.py 0000644 0000000 0000000 00000041335 15273371321 021740 0 ustar 00 """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.py 0000644 0000000 0000000 00000001305 15273371321 020262 0 ustar 00 """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.py 0000644 0000000 0000000 00000061157 15273371321 021021 0 ustar 00 from __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.py 0000644 0000000 0000000 00000066776 15273371321 020471 0 ustar 00 """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.py 0000644 0000000 0000000 00000043010 15273371321 022146 0 ustar 00 """
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.py 0000644 0000000 0000000 00000030244 15273371321 020445 0 ustar 00 """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.py 0000644 0000000 0000000 00000024702 15273371321 020756 0 ustar 00 """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.typed 0000644 0000000 0000000 00000000000 15273371321 017754 0 ustar 00 python_discovery-1.9.5/src/python_discovery/_windows/__init__.py 0000644 0000000 0000000 00000000473 15273371321 022235 0 ustar 00 """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.py 0000644 0000000 0000000 00000003231 15273371321 022544 0 ustar 00 """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.py 0000644 0000000 0000000 00000020127 15273371321 021651 0 ustar 00 """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.py 0000644 0000000 0000000 00000003334 15273371321 022323 0 ustar 00 from __future__ import annotations
from typing import TYPE_CHECKING
from python_discovery._py_info import PythonInfo
from python_discovery._py_spec import PythonSpec
from ._pep514 import discover_pythons
if TYPE_CHECKING:
from collections.abc import Generator, Mapping
from python_discovery._cache import PyInfoCache
_IMPLEMENTATION_BY_ORG: dict[str, str] = {
"ContinuumAnalytics": "CPython",
"PythonCore": "CPython",
}
class Pep514PythonInfo(PythonInfo):
"""A Python information acquired from PEP-514."""
def propose_interpreters(
spec: PythonSpec,
cache: PyInfoCache | None,
env: Mapping[str, str],
) -> Generator[PythonInfo, None, None]:
existing = list(discover_pythons())
existing.sort(
key=lambda i: (
*tuple(-1 if j is None else j for j in i[1:4]),
1 if i[0] == "PythonCore" else 0,
),
reverse=True,
)
for name, major, minor, arch, threaded, exe, 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.py 0000644 0000000 0000000 00000003207 15273371321 017052 0 ustar 00 """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.sh 0000755 0000000 0000000 00000000674 15273371321 017044 0 ustar 00 #!/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.py 0000644 0000000 0000000 00000001545 15273371321 015436 0 ustar 00 from __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.py 0000644 0000000 0000000 00000032743 15273371321 015717 0 ustar 00 from __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.py 0000644 0000000 0000000 00000005057 15273371321 017633 0 ustar 00 from __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.py 0000644 0000000 0000000 00000056371 15273371321 017611 0 ustar 00 from __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.py 0000644 0000000 0000000 00000123167 15273371321 016664 0 ustar 00 from __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.py 0000644 0000000 0000000 00000020332 15273371321 020055 0 ustar 00 from __future__ import annotations
import os
import sys
from pathlib import Path
from typing import TYPE_CHECKING
import pytest
from python_discovery import DiskCache, get_interpreter
from python_discovery._discovery import (
IS_WIN,
LazyPathDump,
_active_versions,
_read_python_version_file,
_resolve_shim,
propose_interpreters,
)
from python_discovery._py_spec import PythonSpec
if TYPE_CHECKING:
from pytest_mock import MockerFixture
def test_propose_interpreters_abs_path_oserror(tmp_path: Path) -> None:
spec = PythonSpec.from_string_spec(str(tmp_path / "nonexistent"))
results = list(propose_interpreters(spec, []))
assert results == []
def test_propose_interpreters_try_first_with_valid(session_cache: DiskCache) -> None:
spec = PythonSpec.from_string_spec("python")
results = list(propose_interpreters(spec, [sys.executable], session_cache))
assert len(results) >= 1
def test_propose_interpreters_try_first_with_missing(tmp_path: Path) -> None:
spec = PythonSpec.from_string_spec("python")
bad_path = str(tmp_path / "nonexistent")
gen = propose_interpreters(spec, [bad_path])
results = []
for result in gen: # pragma: no branch
results.append(result)
break
assert len(results) >= 0
def test_propose_interpreters_try_first_with_duplicate(session_cache: DiskCache) -> None:
spec = PythonSpec.from_string_spec("python")
results = list(propose_interpreters(spec, [sys.executable, sys.executable], session_cache))
exes = [r[0].executable for r in results if r[0] is not None]
seen = set()
for exe in exes[:2]:
assert True
seen.add(exe)
def test_propose_interpreters_relative_path(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
session_cache: DiskCache,
) -> None:
Path(sys.executable)
link = tmp_path / ("python.exe" if IS_WIN else "python")
Path(str(link)).symlink_to(sys.executable)
spec = PythonSpec.from_string_spec(link.name)
spec.path = link.name
monkeypatch.setenv("PATH", str(tmp_path))
results = list(propose_interpreters(spec, [], session_cache))
assert len(results) >= 0
def test_lazy_path_dump_basic(tmp_path: Path) -> None:
dumper = LazyPathDump(0, tmp_path, {})
result = repr(dumper)
assert "PATH[0]" in result
assert "with =>" not in result
def test_lazy_path_dump_debug_with_dir(tmp_path: Path) -> None:
env = {"_VIRTUALENV_DEBUG": "1"}
sub = tmp_path / "subdir"
sub.mkdir()
dumper = LazyPathDump(0, tmp_path, env)
result = repr(dumper)
assert "subdir" not in result
@pytest.mark.skipif(IS_WIN, reason="POSIX test")
def test_lazy_path_dump_debug_non_executable(tmp_path: Path) -> None:
env = {"_VIRTUALENV_DEBUG": "1"}
non_exec = tmp_path / "not_executable"
non_exec.touch(mode=0o644)
dumper = LazyPathDump(0, tmp_path, env)
result = repr(dumper)
assert "not_executable" not in result
def test_lazy_path_dump_debug_oserror(tmp_path: Path, mocker: MockerFixture) -> None:
env = {"_VIRTUALENV_DEBUG": "1"}
bad_file = tmp_path / "bad_file"
bad_file.touch()
mocker.patch("pathlib.Path.is_dir", side_effect=[False, False])
mocker.patch("pathlib.Path.stat", side_effect=OSError("permission denied"))
dumper = LazyPathDump(0, tmp_path, env)
repr(dumper)
def test_active_versions_pyenv_version(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("PYENV_VERSION", "3.12.0:3.11.0")
versions = list(_active_versions(dict(os.environ)))
assert versions == ["3.12.0", "3.11.0"]
def test_active_versions_python_version_file(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("PYENV_VERSION", raising=False)
(tmp_path / ".python-version").write_text("3.12.0\n", encoding="utf-8")
monkeypatch.chdir(tmp_path)
versions = list(_active_versions(dict(os.environ)))
assert versions == ["3.12.0"]
def test_active_versions_global_version_file(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("PYENV_VERSION", raising=False)
monkeypatch.setenv("PYENV_ROOT", str(tmp_path))
workdir = tmp_path / "workdir"
workdir.mkdir()
monkeypatch.chdir(workdir)
(tmp_path / "version").write_text("3.11.0\n", encoding="utf-8")
versions = list(_active_versions(dict(os.environ)))
assert versions == ["3.11.0"]
def test_active_versions_no_source(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("PYENV_VERSION", raising=False)
monkeypatch.delenv("PYENV_ROOT", raising=False)
monkeypatch.chdir(tmp_path)
versions = list(_active_versions(dict(os.environ)))
assert versions == []
def test_read_python_version_file_found(tmp_path: Path) -> None:
(tmp_path / ".python-version").write_text("3.12.0\n# comment\n", encoding="utf-8")
result = _read_python_version_file(str(tmp_path))
assert result == ["3.12.0"]
def test_read_python_version_file_not_found(tmp_path: Path) -> None:
result = _read_python_version_file(str(tmp_path), search_parents=False)
assert result is None
def test_read_python_version_file_search_parents(tmp_path: Path) -> None:
(tmp_path / ".python-version").write_text("3.11.0\n", encoding="utf-8")
child = tmp_path / "child"
child.mkdir()
result = _read_python_version_file(str(child))
assert result == ["3.11.0"]
def test_read_python_version_file_direct_path(tmp_path: Path) -> None:
version_file = tmp_path / "version"
version_file.write_text("3.12.0\n", encoding="utf-8")
result = _read_python_version_file(str(version_file), search_parents=False)
assert result == ["3.12.0"]
def test_resolve_shim_no_match() -> None:
result = _resolve_shim("/some/random/path", dict(os.environ))
assert result is None
def test_path_exe_finder_returns_callable(tmp_path: Path) -> None:
from python_discovery._discovery import path_exe_finder
spec = PythonSpec.from_string_spec("python3.12")
finder = path_exe_finder(spec)
assert callable(finder)
results = list(finder(tmp_path))
assert results == []
def test_get_paths_no_path_env() -> None:
from python_discovery._discovery import get_paths
paths = list(get_paths({}))
assert isinstance(paths, list)
def test_propose_interpreters_abs_path_exists(session_cache: DiskCache) -> None:
spec = PythonSpec.from_string_spec(sys.executable)
results = list(propose_interpreters(spec, [], session_cache))
assert len(results) >= 1
def test_propose_interpreters_relative_spec_is_abs(tmp_path: Path, session_cache: DiskCache) -> None:
link = tmp_path / ("python.exe" if IS_WIN else "python")
Path(str(link)).symlink_to(sys.executable)
spec = PythonSpec.from_string_spec(str(link))
spec.path = str(link)
results = list(propose_interpreters(spec, [], session_cache))
assert len(results) >= 1
def test_resolve_shim_match_no_binary(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
shims = tmp_path / "shims"
shims.mkdir()
versions = tmp_path / "versions"
versions.mkdir()
monkeypatch.setenv("PYENV_ROOT", str(tmp_path))
exe_path = str(shims / "python3")
result = _resolve_shim(exe_path, dict(os.environ))
assert result is None
def test_resolve_shim_dir_no_match(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("PYENV_ROOT", str(tmp_path))
result = _resolve_shim("/other/dir/python3", dict(os.environ))
assert result is None
def test_read_python_version_file_reaches_root() -> None:
result = _read_python_version_file("/nonexistent/deep/path/that/does/not/exist")
assert result is None
def test_read_python_version_file_empty_versions(tmp_path: Path) -> None:
(tmp_path / ".python-version").write_text("# only comments\n\n", encoding="utf-8")
result = _read_python_version_file(str(tmp_path), search_parents=False)
assert result is None
def test_get_interpreter_multi_spec_all_fail(session_cache: DiskCache) -> None:
result = get_interpreter(["magic-one", "magic-two"], cache=session_cache)
assert result is None
def test_get_interpreter_multi_spec_fallback(session_cache: DiskCache) -> None:
result = get_interpreter(["magic-one", sys.executable], cache=session_cache)
assert result is not None
assert result.executable == sys.executable
python_discovery-1.9.5/tests/test_iter_interpreters.py 0000644 0000000 0000000 00000047113 15273371321 020422 0 ustar 00 from __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.py 0000644 0000000 0000000 00000034247 15273371321 020025 0 ustar 00 from __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.py 0000644 0000000 0000000 00000054047 15273371321 017523 0 ustar 00 from __future__ import annotations
import copy
import logging
import os
import sys
from pathlib import Path
from typing import TYPE_CHECKING
from unittest.mock import MagicMock
import pytest
from python_discovery import DiskCache, PythonInfo, PythonSpec, 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.py 0000644 0000000 0000000 00000031057 15273371321 016313 0 ustar 00 from __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.py 0000644 0000000 0000000 00000015365 15273371321 017522 0 ustar 00 from __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.py 0000644 0000000 0000000 00000010165 15273371321 017561 0 ustar 00 from __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.py 0000644 0000000 0000000 00000026732 15273371321 016626 0 ustar 00 from __future__ import annotations
import pytest
from python_discovery._specifier import SimpleSpecifier, SimpleSpecifierSet, SimpleVersion
# --- SimpleVersion ---
@pytest.mark.parametrize(
("version_str", "release", "pre_type", "pre_num"),
[
pytest.param("3.11.2", (3, 11, 2), None, None, id="basic"),
pytest.param("3.14.0a1", (3, 14, 0), "a", 1, id="alpha"),
pytest.param("3.14.0b2", (3, 14, 0), "b", 2, id="beta"),
pytest.param("3.14.0rc1", (3, 14, 0), "rc", 1, id="rc"),
pytest.param("3", (3, 0, 0), None, None, id="major-only"),
pytest.param("3.12", (3, 12, 0), None, None, id="major-minor"),
],
)
def test_version_parse(
version_str: str,
release: tuple[int, int, int],
pre_type: str | None,
pre_num: int | None,
) -> None:
version = SimpleVersion.from_string(version_str)
assert version.release == release
assert version.pre_type == pre_type
assert version.pre_num == pre_num
def test_version_invalid_raises() -> None:
with pytest.raises(ValueError, match="Invalid version"):
SimpleVersion.from_string("not_a_version")
def test_version_eq_same() -> None:
assert SimpleVersion.from_string("3.11") == SimpleVersion.from_string("3.11")
def test_version_eq_different() -> None:
assert SimpleVersion.from_string("3.11") != SimpleVersion.from_string("3.12")
def test_version_eq_not_implemented() -> None:
result = SimpleVersion.from_string("3.11").__eq__("3.11") # 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.py 0000644 0000000 0000000 00000053023 15273371321 017754 0 ustar 00 from __future__ import annotations
import copy
import itertools
import json
import logging
import os
import sys
import sysconfig
from pathlib import Path
from textwrap import dedent
from typing import TYPE_CHECKING, 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.py 0000644 0000000 0000000 00000005362 15273371321 022442 0 ustar 00 from __future__ import annotations
import logging
from pathlib import Path
import pytest
from python_discovery import DiskCache, PythonInfo
from python_discovery._compat import fs_is_case_sensitive
from python_discovery._discovery import IS_WIN
from python_discovery._py_info import EXTENSIONS
CURRENT = PythonInfo.current()
def _fs_supports_symlink() -> bool:
return not IS_WIN
def test_discover_empty_folder(tmp_path: Path, session_cache: DiskCache) -> None:
with pytest.raises(RuntimeError):
CURRENT.discover_exe(session_cache, prefix=str(tmp_path))
def _discover_base_folders() -> tuple[str, ...]:
exe_dir = str(Path(CURRENT.executable).parent)
folders: dict[str, None] = {}
if exe_dir.startswith(CURRENT.prefix): # pragma: no branch
relative = exe_dir[len(CURRENT.prefix) :].lstrip("/\\")
if relative: # pragma: no branch
folders[relative] = None
folders["."] = None
return tuple(folders)
BASE = _discover_base_folders()
@pytest.mark.skipif(not _fs_supports_symlink(), reason="symlink is not supported")
@pytest.mark.parametrize("suffix", sorted({".exe", ""} & set(EXTENSIONS) if IS_WIN else [""]))
@pytest.mark.parametrize("into", BASE)
@pytest.mark.parametrize("arch", [CURRENT.architecture, ""])
@pytest.mark.parametrize("version", [".".join(str(i) for i in CURRENT.version_info[0:i]) for i in range(3, 0, -1)])
@pytest.mark.parametrize("impl", [CURRENT.implementation, "python"])
def test_discover_ok(
tmp_path: Path,
suffix: str,
impl: str,
version: str,
*,
arch: int | str,
into: str,
caplog: pytest.LogCaptureFixture,
session_cache: DiskCache,
) -> None:
caplog.set_level(logging.DEBUG)
folder = tmp_path / into
folder.mkdir(parents=True, exist_ok=True)
name = f"{impl}{version}{'t' if CURRENT.free_threaded else ''}"
if arch:
name += f"-{arch}"
name += suffix
dest = folder / name
Path(str(dest)).symlink_to(CURRENT.executable)
pyvenv = Path(CURRENT.executable).parents[1] / "pyvenv.cfg"
if pyvenv.exists(): # pragma: no branch
(folder / pyvenv.name).write_text(pyvenv.read_text(encoding="utf-8"), encoding="utf-8")
inside_folder = str(tmp_path)
base = CURRENT.discover_exe(session_cache, inside_folder)
found = base.executable
dest_str = str(dest)
if not fs_is_case_sensitive(): # pragma: win32 cover
found = found.lower()
dest_str = dest_str.lower()
assert found == dest_str
assert len(caplog.messages) >= 1, caplog.text
assert "get interpreter info via cmd: " in caplog.text
dest.rename(dest.parent / (dest.name + "-1"))
CURRENT._cache_exe_discovery.clear()
with pytest.raises(RuntimeError):
CURRENT.discover_exe(session_cache, inside_folder)
python_discovery-1.9.5/tests/windows/conftest.py 0000644 0000000 0000000 00000015473 15273371321 017135 0 ustar 00 from __future__ import annotations
import os
import sys
from contextlib import contextmanager
from pathlib import Path
from types import ModuleType, TracebackType
from typing import TYPE_CHECKING
from unittest.mock import MagicMock
import pytest
if TYPE_CHECKING:
from collections.abc import Generator
from typing_extensions import Self
def _create_winreg_mock() -> ModuleType:
"""Create a mock winreg module that works on all platforms."""
winreg = ModuleType("winreg")
winreg.HKEY_CURRENT_USER = 0x80000001 # ty: ignore[unresolved-attribute]
winreg.HKEY_LOCAL_MACHINE = 0x80000002 # ty: ignore[unresolved-attribute]
winreg.KEY_READ = 0x20019 # ty: ignore[unresolved-attribute]
winreg.KEY_WOW64_64KEY = 0x0100 # ty: ignore[unresolved-attribute]
winreg.KEY_WOW64_32KEY = 0x0200 # ty: ignore[unresolved-attribute]
winreg.EnumKey = MagicMock() # ty: ignore[unresolved-attribute]
winreg.QueryValueEx = MagicMock() # ty: ignore[unresolved-attribute]
winreg.OpenKeyEx = MagicMock() # ty: ignore[unresolved-attribute]
return winreg
def _load_registry_data(
winreg: ModuleType,
) -> tuple[
dict[object, dict[int, object]],
dict[object, dict[str, object]],
dict[object, dict[str, object]],
dict[tuple[object, ...], object],
]:
"""Load winreg mock values using the given (possibly mock) winreg module."""
loc: dict[str, object] = {}
glob: dict[str, object] = {"winreg": winreg}
mock_value_str = (Path(__file__).parent / "winreg_mock_values.py").read_text(encoding="utf-8")
exec(mock_value_str, glob, loc) # 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.py 0000644 0000000 0000000 00000004320 15273371321 020026 0 ustar 00 from __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.py 0000644 0000000 0000000 00000024156 15273371321 021135 0 ustar 00 from __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.py 0000644 0000000 0000000 00000016722 15273371321 021171 0 ustar 00 from __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/.gitignore 0000644 0000000 0000000 00000000110 15273371321 014050 0 ustar 00 *.pyc
*.egg-info
dist/
.tox/
/src/python_discovery/_version.py
/uv.lock
python_discovery-1.9.5/LICENSE 0000644 0000000 0000000 00000001777 15273371321 013111 0 ustar 00 Permission is hereby granted, free of charge, to any person obtaining a
copy of this software and associated documentation files (the
"Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be included
in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
python_discovery-1.9.5/pyproject.toml 0000644 0000000 0000000 00000022725 15273371321 015014 0 ustar 00 [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-INFO 0000644 0000000 0000000 00000010213 15273371321 013162 0 ustar 00 Metadata-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/)
[](https://pypi.org/project/python-discovery/)
[](https://pypi.org/project/python-discovery/)
[](https://pepy.tech/project/python-discovery)
[](https://github.com/tox-dev/python-discovery/actions/workflows/check.yaml)
[](https://python-discovery.readthedocs.io/en/latest/?badge=latest)
[](https://scorecard.dev/viewer/?uri=github.com/tox-dev/python-discovery)
[](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/)