python_discovery-1.2.2/.pre-commit-config.yaml 0000644 0000000 0000000 00000002213 13615410400 016323 0 ustar 00 repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v6.0.0
hooks:
- id: end-of-file-fixer
- id: trailing-whitespace
- repo: https://github.com/python-jsonschema/check-jsonschema
rev: 0.37.1
hooks:
- id: check-github-workflows
args: ["--verbose"]
- repo: https://github.com/codespell-project/codespell
rev: v2.4.2
hooks:
- id: codespell
additional_dependencies: ["tomli>=2.4"]
- repo: https://github.com/tox-dev/pyproject-fmt
rev: "v2.21.0"
hooks:
- id: pyproject-fmt
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: "v0.15.9"
hooks:
- id: ruff-format
- id: ruff
args: ["--fix", "--unsafe-fixes", "--exit-non-zero-on-fix"]
- repo: https://github.com/rbubley/mirrors-prettier
rev: "v3.8.1"
hooks:
- id: prettier
additional_dependencies:
- prettier@3.8.1
- "@prettier/plugin-xml@3.4.2"
- repo: https://github.com/zizmorcore/zizmor-pre-commit
rev: v1.23.1
hooks:
- id: zizmor
- repo: meta
hooks:
- id: check-hooks-apply
- id: check-useless-excludes
python_discovery-1.2.2/.readthedocs.yaml 0000644 0000000 0000000 00000000407 13615410400 015274 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.14 --managed-python
- ~/.local/bin/tox run -e docs -- "$READTHEDOCS_OUTPUT/html"
python_discovery-1.2.2/.readthedocs.yml 0000644 0000000 0000000 00000000227 13615410400 015133 0 ustar 00 version: 2
build:
os: ubuntu-22.04
tools:
python: "3.12"
commands:
- pip install tox
- tox r -e docs -- "${READTHEDOCS_OUTPUT}"/html
python_discovery-1.2.2/CODE_OF_CONDUCT.md 0000644 0000000 0000000 00000006264 13615410400 014653 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.2.2/tox.toml 0000644 0000000 0000000 00000004310 13615410400 013551 0 ustar 00 requires = ["tox>=4.38", "tox-uv>=1.29"]
env_list = ["fix", "3.14", "3.13", "3.12", "3.11", "3.10", "3.9", "3.8", "type-3.8", "type-3.14", "docs", "pkg_meta"]
skip_missing_interpreters = true
[env_run_base]
description = "run the tests with pytest"
package = "wheel"
wheel_build_env = ".pkg"
extras = ["testing"]
pass_env = ["DIFF_AGAINST", "PYTEST_*"]
set_env.COVERAGE_FILE = "{work_dir}{/}.coverage.{env_name}"
commands = [
["coverage", "erase"],
[
"coverage",
"run",
"-m",
"pytest",
{ replace = "posargs", extend = true, default = [
"--junitxml",
"{work_dir}{/}junit.{env_name}.xml",
"tests",
] },
],
["coverage", "combine"],
["coverage", "report"],
["coverage", "html", "-d", "{env_tmp_dir}{/}htmlcov"],
]
[env.fix]
description = "run static analysis and style check using flake8"
skip_install = true
deps = ["pre-commit-uv>=4.2.1"]
pass_env = ["HOMEPATH", "PROGRAMDATA"]
commands = [["pre-commit", "run", "--all-files", "--show-diff-on-failure"]]
[env."type-3.8"]
description = "run type check on code base (3.8)"
deps = ["ty==0.0.17"]
commands = [["ty", "check", "--output-format", "concise", "--error-on-warning", "--python-version", "3.8", "."]]
[env."type-3.14"]
description = "run type check on code base (3.14)"
deps = ["ty==0.0.17"]
commands = [["ty", "check", "--output-format", "concise", "--error-on-warning", "--python-version", "3.14", "."]]
[env.docs]
description = "build documentation"
extras = ["docs"]
commands = [
[
"sphinx-build",
"-d",
"{env_tmp_dir}{/}doctree",
"docs",
"--color",
"-b",
"html",
"-W",
{ replace = "posargs", extend = true, default = ["{work_dir}{/}docs_out"] },
],
]
[env.pkg_meta]
description = "check that the long description is valid"
skip_install = true
deps = ["check-wheel-contents>=0.6.3", "twine>=6.2", "uv>=0.10.4"]
commands = [
["uv", "build", "--sdist", "--wheel", "--out-dir", "{env_tmp_dir}", "."],
["twine", "check", "{env_tmp_dir}{/}*"],
["check-wheel-contents", "--no-config", "{env_tmp_dir}"],
]
[env.dev]
description = "generate a DEV environment"
package = "editable"
extras = ["testing"]
commands = [["uv", "pip", "tree"], ["python", "-c", "import sys; print(sys.executable)"]]
python_discovery-1.2.2/.github/FUNDING.yaml 0000644 0000000 0000000 00000000024 13615410400 015356 0 ustar 00 github: gaborbernat
python_discovery-1.2.2/.github/SECURITY.md 0000644 0000000 0000000 00000000455 13615410400 015201 0 ustar 00 # Security Policy
## Supported Versions
Only the latest released version is supported with security updates. Older versions will not receive patches.
## Reporting a Vulnerability
Please report security vulnerabilities via [GitHub's private vulnerability reporting](../../security/advisories/new).
python_discovery-1.2.2/.github/dependabot.yaml 0000644 0000000 0000000 00000000231 13615410400 016371 0 ustar 00 version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "daily"
cooldown:
default-days: 7
python_discovery-1.2.2/.github/release.yaml 0000644 0000000 0000000 00000000126 13615410400 015707 0 ustar 00 changelog:
exclude:
authors:
- dependabot[bot]
- pre-commit-ci[bot]
python_discovery-1.2.2/.github/workflows/check.yaml 0000644 0000000 0000000 00000003101 13615410400 017375 0 ustar 00 name: check
on:
workflow_dispatch:
push:
branches: ["main"]
tags-ignore: ["**"]
pull_request:
schedule:
- cron: "0 8 * * *"
concurrency:
group: check-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
env:
- "3.14"
- "3.13"
- "3.12"
- "3.11"
- "3.10"
- "3.9"
- "3.8"
- type-3.8
- type-3.14
- dev
- pkg_meta
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
fetch-depth: 0
persist-credentials: false
- name: Install the latest version of uv
uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
with:
enable-cache: false
cache-dependency-glob: "pyproject.toml"
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Install tox
run: uv tool install --python-preference only-managed --python 3.14 tox --with tox-uv
- name: Install Python
if: startsWith(matrix.env, '3.') && matrix.env != '3.14'
run: uv python install --python-preference only-managed ${{ matrix.env }}
- name: Setup test suite
run: tox run -vv --notest --skip-missing-interpreters false -e ${{ matrix.env }}
- name: Run test suite
run: tox run --skip-pkg-install -e ${{ matrix.env }}
env:
PYTEST_ADDOPTS: "-vv --durations=20"
DIFF_AGAINST: HEAD
python_discovery-1.2.2/.github/workflows/release.yaml 0000644 0000000 0000000 00000003035 13615410400 017746 0 ustar 00 name: Release to PyPI
on:
push:
tags: ["*"]
permissions:
contents: read
env:
dists-artifact-name: python-package-distributions
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
fetch-depth: 0
persist-credentials: false
- name: Install the latest version of uv
uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
with:
enable-cache: false
cache-dependency-glob: "pyproject.toml"
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Build package
run: uv build --python 3.14 --python-preference only-managed --sdist --wheel . --out-dir dist
- name: Store the distribution packages
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7
with:
name: ${{ env.dists-artifact-name }}
path: dist/*
release:
needs:
- build
runs-on: ubuntu-latest
environment:
name: release
url: https://pypi.org/project/python-discovery/${{ github.ref_name }}
permissions:
id-token: write
steps:
- name: Download all the dists
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
name: ${{ env.dists-artifact-name }}
path: dist/
- name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0
with:
attestations: true
python_discovery-1.2.2/docs/conf.py 0000644 0000000 0000000 00000002604 13615410400 014275 0 ustar 00 """Sphinx configuration for python-discovery documentation."""
from __future__ import annotations
from datetime import datetime, timezone
from python_discovery import __version__
company = "tox-dev"
name = "python-discovery"
version = ".".join(__version__.split(".")[:2])
release = __version__
copyright = f"2026-{datetime.now(tz=timezone.utc).year}, {company}" # noqa: A001
extensions = [
"sphinx.ext.autodoc",
"sphinx.ext.autosectionlabel",
"sphinx.ext.extlinks",
"sphinx.ext.intersphinx",
"sphinx_autodoc_typehints",
"sphinxcontrib.mermaid",
]
extlinks = {
"issue": ("https://github.com/tox-dev/python-discovery/issues/%s", "#%s"),
"pull": ("https://github.com/tox-dev/python-discovery/pull/%s", "PR #%s"),
"user": ("https://github.com/%s", "@%s"),
}
intersphinx_mapping = {
"python": ("https://docs.python.org/3", None),
}
templates_path = []
source_suffix = ".rst"
exclude_patterns = ["_build", "changelog/*.rst"]
main_doc = "index"
pygments_style = "default"
always_document_param_types = True
project = name
html_theme = "furo"
html_title = project
html_last_updated_fmt = datetime.now(tz=timezone.utc).isoformat()
pygments_dark_style = "monokai"
html_show_sourcelink = False
html_static_path = ["_static"]
html_theme_options = {
"light_logo": "logo.svg",
"dark_logo": "logo.svg",
"sidebar_hide_name": True,
}
html_css_files = ["custom.css"]
python_discovery-1.2.2/docs/explanation.rst 0000644 0000000 0000000 00000015233 13615410400 016054 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{"Is spec a
relative path?"}
RelPath -->|Yes| TryRel["Resolve relative to cwd"]
RelPath -->|No| Current["Current interpreter"]
Current --> Win{"Windows?"}
Win -->|Yes| PEP514["PEP 514 registry"]
Win -->|No| PATH
PEP514 --> PATH["PATH search"]
PATH --> Shims["Version-manager shims
(pyenv / mise / asdf)"]
Shims --> UV["uv-managed Pythons"]
TryAbs --> Verify
TryRel --> Verify
UV --> Verify
Verify{{"Verify candidate
(subprocess call)"}}
Verify -->|Matches spec| Cache["Cache and return"]
Verify -->|No match| Next["Try next candidate"]
style Start fill:#4a90d9,stroke:#2a5f8f,color:#fff
style Verify fill:#d9904a,stroke:#8f5f2a,color:#fff
style Cache fill:#4a9f4a,stroke:#2a6f2a,color:#fff
style Next fill:#d94a4a,stroke:#8f2a2a,color:#fff
Each candidate is verified by running it as a subprocess and collecting its metadata (version,
architecture, platform, sysconfig values, etc.). This subprocess call is the expensive part, which
is why results are cached.
How version-manager shims are handled
-----------------------------------------
Version managers like `pyenv `_ install thin wrapper scripts called
**shims** (e.g., ``~/.pyenv/shims/python3.12``) that redirect to the real interpreter. python-discovery
detects these shims and resolves them to the actual binary.
.. mermaid::
flowchart TD
Shim["Shim detected"] --> EnvVar{"PYENV_VERSION
set?"}
EnvVar -->|Yes| Use["Use that version"]
EnvVar -->|No| File{".python-version
file exists?"}
File -->|Yes| Use
File -->|No| Global{"pyenv global
version exists?"}
Global -->|Yes| Use
Global -->|No| Skip["Skip shim"]
style Shim fill:#4a90d9,stroke:#2a5f8f,color:#fff
style Use fill:#4a9f4a,stroke:#2a6f2a,color:#fff
style Skip fill:#d94a4a,stroke:#8f2a2a,color:#fff
`mise `_ and `asdf `_ work similarly, using the
``MISE_DATA_DIR`` and ``ASDF_DATA_DIR`` environment variables to locate their installations.
How caching works
-------------------
Querying an interpreter requires a subprocess call, which is slow. The cache avoids repeating this
work by storing the result as a JSON file keyed by the interpreter's path.
.. mermaid::
flowchart TD
Lookup["py_info(path)"] --> Exists{"Cache hit?"}
Exists -->|Yes| Read["Read JSON"]
Exists -->|No| Run["Run subprocess"]
Run --> Write["Write JSON
(with filelock)"]
Write --> Return["Return PythonInfo"]
Read --> Return
style Lookup fill:#4a90d9,stroke:#2a5f8f,color:#fff
style Return fill:#4a9f4a,stroke:#2a6f2a,color:#fff
style Run fill:#d9904a,stroke:#8f5f2a,color:#fff
The built-in :class:`~python_discovery.DiskCache` stores files under ``/py_info/4/.json``
with `filelock `_-based locking for safe concurrent access. You
can also pass ``cache=None`` to disable caching, or implement your own backend (see
:doc:`/how-to/standalone-usage`).
Subprocess timeout behavior
----------------------------
When python-discovery verifies an interpreter candidate, it runs a subprocess to query its metadata.
On slow systems (especially Windows), Python startup can take significant time. The default timeout
is **15 seconds** to balance responsiveness with accommodation for real-world conditions.
If your system consistently hits timeouts, you can customize the timeout via the
``PY_DISCOVERY_TIMEOUT`` environment variable (in seconds):
.. code-block:: console
# Increase timeout to 30 seconds
export PY_DISCOVERY_TIMEOUT=30
python -c "from python_discovery import get_interpreter; get_interpreter('python3.12')"
The timeout applies to each individual interpreter being queried. If you set a value that is too low,
legitimate interpreters may be skipped; if too high, the discovery process may take longer to fail
when encountering problematic interpreters.
Spec format reference
-----------------------
A spec string follows the pattern ``[impl][version][t][-arch][-machine]``. Every part is optional.
.. mermaid::
flowchart TD
Spec["Spec string"] --> Impl["impl
(optional)"]
Impl --> Version["version
(optional)"]
Version --> T["t
(optional)"]
T --> Arch["-arch
(optional)"]
Arch --> Machine["-machine
(optional)"]
style Impl fill:#4a90d9,stroke:#2a5f8f,color:#fff
style Version fill:#4a9f4a,stroke:#2a6f2a,color:#fff
style T fill:#d9904a,stroke:#8f5f2a,color:#fff
style Arch fill:#d94a4a,stroke:#8f2a2a,color:#fff
style Machine fill:#904ad9,stroke:#5f2a8f,color:#fff
**Parts explained:**
- **impl** -- the Python implementation name. ``python`` and ``py`` both mean "any implementation"
(usually CPython). Use ``cpython``, ``pypy``, or ``graalpy`` to be explicit.
- **version** -- dotted version number (``3``, ``3.12``, or ``3.12.1``). You can also write
``312`` as shorthand for ``3.12``.
- **t** -- appended directly after the version. Matches free-threaded (no-GIL) builds only.
- **-arch** -- ``-32`` or ``-64`` for 32-bit or 64-bit interpreters.
- **-machine** -- the CPU instruction set: ``-arm64``, ``-x86_64``, ``-aarch64``, ``-riscv64``, etc.
**Full examples:**
.. list-table::
:header-rows: 1
:widths: 30 70
* - Spec
- Meaning
* - ``3.12``
- Any Python 3.12
* - ``python3.12``
- CPython 3.12
* - ``cpython3.12``
- Explicitly CPython 3.12
* - ``pypy3.9``
- PyPy 3.9
* - ``python3.13t``
- Free-threaded (no-GIL) CPython 3.13
* - ``python3.12-64``
- 64-bit CPython 3.12
* - ``python3.12-64-arm64``
- 64-bit CPython 3.12 on ARM64
* - ``/usr/bin/python3``
- Absolute path, used directly (no search)
* - ``>=3.11,<3.13``
- :pep:`440` version specifier (any Python in range)
* - ``cpython>=3.11``
- :pep:`440` specifier restricted to CPython
:pep:`440` specifiers (``>=``, ``<=``, ``~=``, ``!=``, ``==``, ``===``) are supported. Multiple
specifiers can be comma-separated, for example ``>=3.11,<3.13``.
python_discovery-1.2.2/docs/index.rst 0000644 0000000 0000000 00000002505 13615410400 014637 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
python_discovery-1.2.2/docs/_static/custom.css 0000644 0000000 0000000 00000000070 13615410400 016443 0 ustar 00 .sidebar-logo img {
max-width: 100%;
width: 100%;
}
python_discovery-1.2.2/docs/_static/logo.svg 0000644 0000000 0000000 00000001043 13615410400 016101 0 ustar 00
python_discovery-1.2.2/docs/changelog/59.bugfix.rst 0000644 0000000 0000000 00000000122 13615410400 017170 0 ustar 00 export normalize_isa and deprecate KNOWN_ARCHITECTURES - by :user:`rahuldevikar`.
python_discovery-1.2.2/docs/how-to/standalone-usage.rst 0000644 0000000 0000000 00000011614 13615410400 020200 0 ustar 00 How-to guides
=============
Search specific directories first
-----------------------------------
If you know a likely location for the interpreter, pass it via ``try_first_with`` to check there
before the normal search. This is useful when you have a custom Python install outside the
standard locations.
.. code-block:: python
from python_discovery import get_interpreter
info = get_interpreter("python3.12", try_first_with=["/opt/python/bin"])
if info is not None:
print(info.executable)
Restrict the search environment
---------------------------------
By default, python-discovery reads environment variables like ``PATH`` and ``PYENV_ROOT`` from your
shell. You can override these to control exactly where the library looks.
.. mermaid::
flowchart TD
Env["Custom env dict"] --> Call["get_interpreter(spec, env=env)"]
Call --> PATH["PATH"]
Call --> Pyenv["PYENV_ROOT"]
Call --> UV["UV_PYTHON_INSTALL_DIR"]
Call --> Mise["MISE_DATA_DIR"]
style Env fill:#4a90d9,stroke:#2a5f8f,color:#fff
.. code-block:: python
import os
from python_discovery import get_interpreter
env = {**os.environ, "PATH": "/usr/local/bin:/usr/bin"}
result = get_interpreter("python3.12", env=env)
Customize interpreter query timeout
-------------------------------------
On slower systems (especially Windows), Python startup can take more than the default 15 seconds.
If your discovery process times out when looking for interpreters, you can extend the timeout via
the ``PY_DISCOVERY_TIMEOUT`` environment variable.
.. code-block:: python
import os
from python_discovery import get_interpreter
# Increase timeout to 30 seconds for slow environments
env = {**os.environ, "PY_DISCOVERY_TIMEOUT": "30"}
result = get_interpreter("python3.12", env=env)
The timeout value should be a number in seconds. Each interpreter candidate is given this much time
to respond. If a timeout occurs, the candidate is skipped and the search continues with the next one.
Read interpreter metadata
---------------------------
Once you have a :class:`~python_discovery.PythonInfo`, you can inspect everything about the interpreter.
.. mermaid::
classDiagram
class PythonInfo {
+executable: str
+system_executable: str
+implementation: str
+version_info: VersionInfo
+architecture: int
+platform: str
+sysconfig_vars: dict
+sysconfig_paths: dict
+machine: str
+free_threaded: bool
}
.. code-block:: python
from pathlib import Path
from python_discovery import DiskCache, get_interpreter
cache = DiskCache(root=Path("~/.cache/python-discovery").expanduser())
info = get_interpreter("python3.12", cache=cache)
info.executable # Resolved path to the binary.
info.system_executable # The underlying system interpreter (outside any venv).
info.implementation # "CPython", "PyPy", "GraalPy", etc.
info.version_info # VersionInfo(major, minor, micro, releaselevel, serial).
info.architecture # 64 or 32.
info.platform # sys.platform value ("linux", "darwin", "win32").
info.machine # ISA: "arm64", "x86_64", etc.
info.free_threaded # True if this is a no-GIL build.
info.sysconfig_vars # All sysconfig.get_config_vars() values.
info.sysconfig_paths # All sysconfig.get_paths() values.
Implement a custom cache backend
-----------------------------------
The built-in :class:`~python_discovery.DiskCache` stores results as JSON files with
`filelock `_-based locking. If you need a different storage
strategy (e.g., in-memory, database-backed), implement the :class:`~python_discovery.PyInfoCache`
protocol.
.. mermaid::
classDiagram
class PyInfoCache {
<>
+py_info(path) ContentStore
+py_info_clear() None
}
class ContentStore {
<>
+exists() bool
+read() dict | None
+write(content) None
+remove() None
+locked() context
}
class DiskCache {
+root: Path
}
PyInfoCache <|.. DiskCache
PyInfoCache --> ContentStore
.. code-block:: python
from pathlib import Path
from python_discovery import ContentStore, PyInfoCache
class MyContentStore:
def __init__(self, path: Path) -> None:
self._path = path
def exists(self) -> bool: ...
def read(self) -> dict | None: ...
def write(self, content: dict) -> None: ...
def remove(self) -> None: ...
def locked(self): ...
class MyCache:
def py_info(self, path: Path) -> MyContentStore: ...
def py_info_clear(self) -> None: ...
Any object that matches the protocol works -- no inheritance required.
python_discovery-1.2.2/docs/reference/api.rst 0000644 0000000 0000000 00000000164 13615410400 016236 0 ustar 00 API reference
=============
.. automodule:: python_discovery
:members:
:undoc-members:
:show-inheritance:
python_discovery-1.2.2/docs/reference/environment-variables.rst 0000644 0000000 0000000 00000002251 13615410400 021776 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`
python_discovery-1.2.2/docs/tutorial/getting-started.rst 0000644 0000000 0000000 00000014645 13615410400 020510 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.
Inspecting the current interpreter
------------------------------------
The simplest use case: get information about the Python that is running right now.
.. mermaid::
flowchart TD
Call["PythonInfo.current_system(cache)"] --> Info["PythonInfo"]
Info --> Exe["executable: /usr/bin/python3.12"]
Info --> Ver["version_info: (3, 12, 1)"]
Info --> Impl["implementation: CPython"]
Info --> Arch["architecture: 64"]
style Call fill:#4a90d9,stroke:#2a5f8f,color:#fff
style Info fill:#4a9f4a,stroke:#2a6f2a,color:#fff
.. code-block:: python
from pathlib import Path
from python_discovery import DiskCache, PythonInfo
cache = DiskCache(root=Path("~/.cache/python-discovery").expanduser())
info = PythonInfo.current_system(cache)
print(info.executable) # /usr/bin/python3.12
print(info.version_info[:3]) # (3, 12, 1)
print(info.implementation) # CPython (or PyPy, GraalPy, etc.)
print(info.architecture) # 64 (or 32)
The returned :class:`~python_discovery.PythonInfo` object contains everything the library knows about that interpreter:
paths, version numbers, sysconfig variables, platform details, and more.
Finding a different interpreter
--------------------------------
Usually you need a *specific* Python version, not the one currently running. Pass a **spec** string
to :func:`~python_discovery.get_interpreter` to search your system.
.. mermaid::
flowchart TD
Spec["Spec: python3.12"] --> Call["get_interpreter(spec, cache)"]
Call --> Found{"Match found?"}
Found -->|Yes| Info["PythonInfo with full metadata"]
Found -->|No| Nil["None"]
style Spec fill:#4a90d9,stroke:#2a5f8f,color:#fff
style Info fill:#4a9f4a,stroke:#2a6f2a,color:#fff
style Nil fill:#d94a4a,stroke:#8f2a2a,color:#fff
.. code-block:: python
from pathlib import Path
from python_discovery import DiskCache, get_interpreter
cache = DiskCache(root=Path("~/.cache/python-discovery").expanduser())
result = get_interpreter("python3.12", cache=cache)
if result is not None:
print(result.executable)
You can pass multiple specs as a list -- the library tries each one in order and returns the first match.
.. code-block:: python
result = get_interpreter(["python3.12", "python3.11"], cache=cache)
Writing specs
-------------
A spec tells python-discovery what to look for. The simplest form is just a version number like ``3.12``.
You can add more constraints to narrow the search.
.. mermaid::
flowchart TD
Spec["Spec string"] --> Impl["impl
(optional)"]
Impl --> Version["version
(optional)"]
Version --> T["t
(optional)"]
T --> Arch["-arch
(optional)"]
Arch --> Machine["-machine
(optional)"]
style Impl fill:#4a90d9,stroke:#2a5f8f,color:#fff
style Version fill:#4a9f4a,stroke:#2a6f2a,color:#fff
style T fill:#d9904a,stroke:#8f5f2a,color:#fff
style Arch fill:#d94a4a,stroke:#8f2a2a,color:#fff
style Machine fill:#904ad9,stroke:#5f2a8f,color:#fff
Common examples:
.. list-table::
:header-rows: 1
:widths: 30 70
* - Spec
- What it matches
* - ``3.12``
- Any Python 3.12 (CPython, PyPy, etc.)
* - ``python3.12``
- CPython 3.12 (``python`` means CPython)
* - ``pypy3.9``
- PyPy 3.9
* - ``python3.13t``
- Free-threaded (no-GIL) CPython 3.13
* - ``python3.12-64``
- 64-bit CPython 3.12
* - ``python3.12-64-arm64``
- 64-bit CPython 3.12 on ARM64 hardware
* - ``/usr/bin/python3``
- An absolute path, used directly without searching
* - ``>=3.11,<3.13``
- Any Python in the 3.11--3.12 range (:pep:`440` syntax)
See the :doc:`full spec reference ` for all options.
Parsing a spec
--------------
You can parse a spec string into its components without searching the system. This is useful for
inspecting what a spec means or for building tools on top of python-discovery.
.. mermaid::
flowchart TD
Input["cpython3.12t-64-arm64"] --> Parse["PythonSpec.from_string_spec()"]
Parse --> Spec["PythonSpec"]
Spec --> impl["implementation: cpython"]
Spec --> ver["major: 3, minor: 12"]
Spec --> ft["free_threaded: True"]
Spec --> arch["architecture: 64"]
Spec --> mach["machine: arm64"]
style Input fill:#4a90d9,stroke:#2a5f8f,color:#fff
style Spec fill:#4a9f4a,stroke:#2a6f2a,color:#fff
.. code-block:: python
from python_discovery import PythonSpec
spec = PythonSpec.from_string_spec("cpython3.12t-64-arm64")
spec.implementation # "cpython"
spec.major # 3
spec.minor # 12
spec.free_threaded # True
spec.architecture # 64
spec.machine # "arm64"
Skipping the cache
------------------
If you only need to discover once and do not want to write anything to disk, pass ``cache=None``.
Every call will run a subprocess to query the interpreter, so this is slower for repeated lookups.
.. code-block:: python
from python_discovery import get_interpreter
result = get_interpreter("python3.12")
Handling slow interpreter queries
----------------------------------
On some systems (especially Windows with antivirus or other tools), Python startup is slow. If discovery
times out, increase the timeout using the ``PY_DISCOVERY_TIMEOUT`` environment variable.
.. code-block:: python
import os
from python_discovery import get_interpreter
# Allow up to 30 seconds per interpreter
os.environ["PY_DISCOVERY_TIMEOUT"] = "30"
result = get_interpreter("python3.12", cache=cache)
Or, pass it directly in a custom environment dict:
.. code-block:: python
import os
from python_discovery import get_interpreter
env = {**os.environ, "PY_DISCOVERY_TIMEOUT": "30"}
result = get_interpreter("python3.12", env=env, cache=cache)
python_discovery-1.2.2/src/python_discovery/__init__.py 0000644 0000000 0000000 00000001310 13615410400 020347 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
from ._py_info import KNOWN_ARCHITECTURES, PythonInfo, normalize_isa
from ._py_spec import PythonSpec
from ._specifier import SimpleSpecifier, SimpleSpecifierSet, SimpleVersion
__version__ = version("python-discovery")
__all__ = [
"KNOWN_ARCHITECTURES",
"ContentStore",
"DiskCache",
"PyInfoCache",
"PythonInfo",
"PythonSpec",
"SimpleSpecifier",
"SimpleSpecifierSet",
"SimpleVersion",
"__version__",
"get_interpreter",
"normalize_isa",
]
python_discovery-1.2.2/src/python_discovery/_cache.py 0000644 0000000 0000000 00000011707 13615410400 020025 0 ustar 00 """Cache Protocol and built-in implementations for Python interpreter discovery."""
from __future__ import annotations
import json
import logging
from contextlib import contextmanager, suppress
from hashlib import sha256
from typing import TYPE_CHECKING, Final, Protocol, runtime_checkable
if TYPE_CHECKING:
from collections.abc import Generator
from pathlib import Path
_LOGGER: Final[logging.Logger] = logging.getLogger(__name__)
@runtime_checkable
class ContentStore(Protocol):
"""A store for reading and writing cached content."""
def exists(self) -> bool:
"""Return whether the cached content exists."""
...
def read(self) -> dict | None:
"""Read the cached content, or ``None`` if unavailable or corrupt."""
...
def write(self, content: dict) -> None:
"""
Persist *content* to the store.
:param content: interpreter metadata to cache.
"""
...
def remove(self) -> None:
"""Delete the cached content."""
...
@contextmanager
def locked(self) -> Generator[None]:
"""Context manager that acquires an exclusive lock on this store."""
...
@runtime_checkable
class PyInfoCache(Protocol):
"""Cache interface for Python interpreter information."""
def py_info(self, path: Path) -> ContentStore:
"""
Return the content store for the interpreter at *path*.
:param path: absolute path to a Python executable.
"""
...
def py_info_clear(self) -> None:
"""Remove all cached interpreter information."""
...
class DiskContentStore:
"""JSON file-based content store with file locking."""
def __init__(self, folder: Path, key: str) -> None:
self._folder = folder
self._key = key
@property
def _file(self) -> Path:
return self._folder / f"{self._key}.json"
def exists(self) -> bool:
return self._file.exists()
def read(self) -> dict | None:
data, bad_format = None, False
try:
data = json.loads(self._file.read_text(encoding="utf-8"))
except ValueError:
bad_format = True
except OSError:
_LOGGER.debug("failed to read %s", self._file, exc_info=True)
else:
_LOGGER.debug("got python info from %s", self._file)
return data
if bad_format:
with suppress(OSError):
self.remove()
return None
def write(self, content: dict) -> None:
self._folder.mkdir(parents=True, exist_ok=True)
self._file.write_text(json.dumps(content, sort_keys=True, indent=2), encoding="utf-8")
_LOGGER.debug("wrote python info at %s", self._file)
def remove(self) -> None:
with suppress(OSError):
self._file.unlink()
_LOGGER.debug("removed python info at %s", self._file)
@contextmanager
def locked(self) -> Generator[None]:
from filelock import FileLock # noqa: PLC0415
lock_path = self._folder / f"{self._key}.lock"
lock_path.parent.mkdir(parents=True, exist_ok=True)
with FileLock(str(lock_path)):
yield
class DiskCache:
"""
File-system based Python interpreter info cache (``/py_info/4/.json``).
:param root: root directory for the on-disk cache.
"""
def __init__(self, root: Path) -> None:
self._root = root
@property
def _py_info_dir(self) -> Path:
return self._root / "py_info" / "4"
def py_info(self, path: Path) -> DiskContentStore:
"""
Return the content store for the interpreter at *path*.
:param path: absolute path to a Python executable.
"""
key = sha256(str(path).encode("utf-8")).hexdigest()
return DiskContentStore(self._py_info_dir, key)
def py_info_clear(self) -> None:
"""Remove all cached interpreter information."""
folder = self._py_info_dir
if folder.exists():
for entry in folder.iterdir():
if entry.suffix == ".json":
with suppress(OSError):
entry.unlink()
class NoOpContentStore(ContentStore):
"""Content store that does nothing -- implements ContentStore protocol."""
def exists(self) -> bool: # noqa: PLR6301
return False
def read(self) -> dict | None: # noqa: PLR6301
return None
def write(self, content: dict) -> None:
pass
def remove(self) -> None:
pass
@contextmanager
def locked(self) -> Generator[None]: # noqa: PLR6301
yield
class NoOpCache(PyInfoCache):
"""Cache that does nothing -- implements PyInfoCache protocol."""
def py_info(self, path: Path) -> NoOpContentStore: # noqa: ARG002, PLR6301
return NoOpContentStore()
def py_info_clear(self) -> None:
pass
__all__ = [
"ContentStore",
"DiskCache",
"DiskContentStore",
"NoOpCache",
"NoOpContentStore",
"PyInfoCache",
]
python_discovery-1.2.2/src/python_discovery/_cached_py_info.py 0000644 0000000 0000000 00000020534 13615410400 021712 0 ustar 00 """Acquire Python information via subprocess interrogation with multi-level caching."""
from __future__ import annotations
import hashlib
import json
import logging
import os
import pkgutil
import secrets
import subprocess # noqa: S404
import sys
import tempfile
from collections import OrderedDict
from contextlib import contextmanager
from pathlib import Path
from shlex import quote
from subprocess import Popen, TimeoutExpired # noqa: S404
from typing import TYPE_CHECKING, Final
from ._cache import NoOpCache
from ._py_info import PythonInfo
if TYPE_CHECKING:
from collections.abc import Generator, Mapping
from ._cache import ContentStore, PyInfoCache
_CACHE: OrderedDict[Path, PythonInfo | Exception] = OrderedDict()
_CACHE[Path(sys.executable)] = PythonInfo()
_LOGGER: Final[logging.Logger] = logging.getLogger(__name__)
def from_exe( # noqa: PLR0913
cls: type[PythonInfo],
cache: PyInfoCache | None,
exe: str,
env: Mapping[str, str] | None = None,
*,
raise_on_error: bool = True,
ignore_cache: bool = False,
) -> PythonInfo | None:
env = os.environ if env is None else env
result = _get_from_cache(cls, cache, exe, env, ignore_cache=ignore_cache)
if isinstance(result, Exception):
if raise_on_error:
raise result
_LOGGER.info("%s", result)
result = None
return result
def _get_from_cache(
cls: type[PythonInfo],
cache: PyInfoCache | None,
exe: str,
env: Mapping[str, str],
*,
ignore_cache: bool = True,
) -> PythonInfo | Exception:
exe_path = Path(exe)
if not ignore_cache and exe_path in _CACHE:
result = _CACHE[exe_path]
else:
py_info = _get_via_file_cache(cls, cache, exe_path, exe, env)
result = _CACHE[exe_path] = py_info
if isinstance(result, PythonInfo):
result.executable = exe
return result
def _get_via_file_cache(
cls: type[PythonInfo],
cache: PyInfoCache | None,
path: Path,
exe: str,
env: Mapping[str, str],
) -> PythonInfo | Exception:
path_text = str(path)
try:
path_modified = path.stat().st_mtime
except OSError:
path_modified = -1
py_info_script = Path(Path(__file__).resolve()).parent / "_py_info.py"
try:
py_info_hash: str | None = hashlib.sha256(py_info_script.read_bytes()).hexdigest()
except OSError:
py_info_hash = None
resolved_cache = cache if cache is not None else NoOpCache()
py_info: PythonInfo | None = None
py_info_store = resolved_cache.py_info(path)
with py_info_store.locked():
if py_info_store.exists() and (data := py_info_store.read()) is not None:
of_path, of_st_mtime = data.get("path"), data.get("st_mtime")
of_content, of_hash = data.get("content"), data.get("hash")
if (
of_path == path_text
and of_st_mtime == path_modified
and of_hash == py_info_hash
and isinstance(of_content, dict)
):
py_info = _load_cached_py_info(cls, py_info_store, of_content)
else:
py_info_store.remove()
if py_info is None:
failure, py_info = _run_subprocess(cls, exe, env)
if failure is not None:
_LOGGER.debug("first subprocess attempt failed for %s (%s), retrying", exe, failure)
failure, py_info = _run_subprocess(cls, exe, env)
if failure is not None:
return failure
if py_info is not None:
py_info_store.write({
"st_mtime": path_modified,
"path": path_text,
"content": py_info.to_dict(),
"hash": py_info_hash,
})
if py_info is None:
msg = f"{exe} failed to produce interpreter info"
return RuntimeError(msg)
return py_info
def _load_cached_py_info(
cls: type[PythonInfo],
py_info_store: ContentStore,
content: dict,
) -> PythonInfo | None:
try:
py_info = cls.from_dict(content.copy())
except (KeyError, TypeError):
py_info_store.remove()
return None
if (sys_exe := py_info.system_executable) is not None and not Path(sys_exe).exists():
py_info_store.remove()
return None
return py_info
COOKIE_LENGTH: Final[int] = 32
def gen_cookie() -> str:
return secrets.token_hex(COOKIE_LENGTH // 2)
@contextmanager
def _resolve_py_info_script() -> Generator[Path]:
py_info_script = Path(Path(__file__).resolve()).parent / "_py_info.py"
if py_info_script.is_file():
yield py_info_script
else:
data = pkgutil.get_data(__package__ or __name__, "_py_info.py")
if data is None:
msg = "cannot locate _py_info.py for subprocess interrogation"
raise FileNotFoundError(msg)
fd, tmp = tempfile.mkstemp(suffix=".py")
try:
os.write(fd, data)
os.close(fd)
yield Path(tmp)
finally:
Path(tmp).unlink()
def _extract_between_cookies(out: str, start_cookie: str, end_cookie: str) -> tuple[str, str, int, int]:
"""Extract payload between reversed cookie markers, forwarding any surrounding output to stdout."""
raw_out = out
out_starts = out.find(start_cookie[::-1])
if out_starts > -1:
if pre_cookie := out[:out_starts]:
sys.stdout.write(pre_cookie)
out = out[out_starts + COOKIE_LENGTH :]
out_ends = out.find(end_cookie[::-1])
if out_ends > -1:
if post_cookie := out[out_ends + COOKIE_LENGTH :]:
sys.stdout.write(post_cookie)
out = out[:out_ends]
return out, raw_out, out_starts, out_ends
def _run_subprocess(
cls: type[PythonInfo],
exe: str,
env: Mapping[str, str],
) -> tuple[Exception | None, PythonInfo | None]:
start_cookie = gen_cookie()
end_cookie = gen_cookie()
timeout = float(env.get("PY_DISCOVERY_TIMEOUT", "15"))
with _resolve_py_info_script() as py_info_script:
cmd = [exe, str(py_info_script), start_cookie, end_cookie]
env = dict(env)
env.pop("__PYVENV_LAUNCHER__", None)
env["PYTHONUTF8"] = "1"
_LOGGER.debug("get interpreter info via cmd: %s", LogCmd(cmd))
try:
process = Popen( # noqa: S603
cmd,
universal_newlines=True,
stdin=subprocess.PIPE,
stderr=subprocess.PIPE,
stdout=subprocess.PIPE,
env=env,
encoding="utf-8",
errors="backslashreplace",
)
out, err = process.communicate(timeout=timeout)
code = process.returncode
except TimeoutExpired:
process.kill()
process.communicate()
out, err, code = "", "timed out", -1
except OSError as os_error:
out, err, code = "", os_error.strerror, os_error.errno
if code != 0:
msg = f"{exe} with code {code}{f' out: {out!r}' if out else ''}{f' err: {err!r}' if err else ''}"
return RuntimeError(f"failed to query {msg}"), None
out, raw_out, out_starts, out_ends = _extract_between_cookies(out, start_cookie, end_cookie)
try:
result = cls.from_json(out)
result.executable = exe
except json.JSONDecodeError as exc:
_LOGGER.warning(
"subprocess %s returned invalid JSON; raw stdout %d chars, start cookie %s, end cookie %s, "
"parsed output %d chars: %r",
exe,
len(raw_out),
"found" if out_starts > -1 else "missing",
"found" if out_ends > -1 else "missing",
len(out),
out[:200] if out else "",
)
msg = f"{exe} returned invalid JSON (exit code {code}){f', stderr: {err!r}' if err else ''}"
failure = RuntimeError(msg)
failure.__cause__ = exc
return failure, None
return None, result
class LogCmd:
def __init__(self, cmd: list[str], env: Mapping[str, str] | None = None) -> None:
self.cmd = cmd
self.env = env
def __repr__(self) -> str:
cmd_repr = " ".join(quote(str(c)) for c in self.cmd)
if self.env is not None:
cmd_repr = f"{cmd_repr} env of {self.env!r}"
return cmd_repr
def clear(cache: PyInfoCache) -> None:
cache.py_info_clear()
_CACHE.clear()
__all__ = [
"LogCmd",
"clear",
"from_exe",
]
python_discovery-1.2.2/src/python_discovery/_compat.py 0000644 0000000 0000000 00000001305 13615410400 020236 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.2.2/src/python_discovery/_discovery.py 0000644 0000000 0000000 00000031026 13615410400 020765 0 ustar 00 from __future__ import annotations
import logging
import os
import sys
from contextlib import suppress
from pathlib import Path
from typing import TYPE_CHECKING, Final
from platformdirs import user_data_path
from ._compat import fs_path_id
from ._py_info import PythonInfo
from ._py_spec import PythonSpec
if TYPE_CHECKING:
from collections.abc import Callable, Generator, Iterable, Mapping, Sequence
from ._cache import PyInfoCache
_LOGGER: Final[logging.Logger] = logging.getLogger(__name__)
IS_WIN: Final[bool] = sys.platform == "win32"
def get_interpreter(
key: str | Sequence[str],
try_first_with: Iterable[str] | None = None,
cache: PyInfoCache | None = None,
env: Mapping[str, str] | None = None,
predicate: Callable[[PythonInfo], bool] | None = None,
) -> PythonInfo | None:
"""
Find a Python interpreter matching *key*.
Iterates over one or more specification strings and returns the first interpreter that satisfies the spec and passes
the optional *predicate*.
:param key: interpreter specification string(s) — an absolute path, a version (``3.12``), an implementation prefix
(``cpython3.12``), or a PEP 440 specifier (``>=3.10``). When a sequence is given each entry is tried in order.
:param try_first_with: executables to probe before the normal discovery search.
:param cache: interpreter metadata cache; when ``None`` results are not cached.
:param env: environment mapping for ``PATH`` lookup; defaults to :data:`os.environ`.
:param predicate: optional callback applied after an interpreter matches the spec. Return ``True`` to accept the
interpreter, ``False`` to skip it and continue searching.
:return: the first matching interpreter, or ``None`` if no match is found.
"""
specs = [key] if isinstance(key, str) else key
for spec_str in specs:
if result := _find_interpreter(spec_str, try_first_with or (), cache, env, predicate):
return result
return None
def _find_interpreter(
key: str,
try_first_with: Iterable[str],
cache: PyInfoCache | None = None,
env: Mapping[str, str] | None = None,
predicate: Callable[[PythonInfo], bool] | None = None,
) -> PythonInfo | None:
spec = PythonSpec.from_string_spec(key)
_LOGGER.info("find interpreter for spec %r", spec)
proposed_paths: set[tuple[str | None, bool]] = set()
env = os.environ if env is None else env
for interpreter, impl_must_match in propose_interpreters(spec, try_first_with, cache, env):
if interpreter is None: # pragma: no cover
continue
proposed_key = interpreter.system_executable, impl_must_match
if proposed_key in proposed_paths:
continue
_LOGGER.info("proposed %s", interpreter)
if interpreter.satisfies(spec, impl_must_match=impl_must_match) and (
predicate is None or predicate(interpreter)
):
_LOGGER.debug("accepted %s", interpreter)
return interpreter
proposed_paths.add(proposed_key)
return None
def _check_exe(path: str, tested_exes: set[str]) -> str | None:
"""Resolve *path* to an absolute path and return it if not yet tested, otherwise ``None``."""
try:
os.lstat(path)
except OSError:
return None
resolved = str(Path(path).resolve())
exe_id = fs_path_id(resolved)
if exe_id in tested_exes:
return None
tested_exes.add(exe_id)
return str(Path(path).absolute())
def _is_new_exe(exe_raw: str, tested_exes: set[str]) -> bool:
"""Return ``True`` and register *exe_raw* if it hasn't been tested yet."""
exe_id = fs_path_id(exe_raw)
if exe_id in tested_exes:
return False
tested_exes.add(exe_id)
return True
def propose_interpreters(
spec: PythonSpec,
try_first_with: Iterable[str],
cache: PyInfoCache | None = None,
env: Mapping[str, str] | None = None,
) -> Generator[tuple[PythonInfo | None, bool], None, None]:
"""
Yield ``(interpreter, impl_must_match)`` candidates for *spec*.
:param spec: the parsed interpreter specification to match against.
:param try_first_with: executable paths to probe before the standard search.
:param cache: interpreter metadata cache; when ``None`` results are not cached.
:param env: environment mapping for ``PATH`` lookup; defaults to :data:`os.environ`.
"""
env = os.environ if env is None else env
tested_exes: set[str] = set()
if spec.is_abs and spec.path is not None:
if exe_raw := _check_exe(spec.path, tested_exes): # pragma: no branch # first exe always new
yield PythonInfo.from_exe(exe_raw, cache, env=env), True
return
yield from _propose_explicit(spec, try_first_with, cache, env, tested_exes)
if spec.path is not None and spec.is_abs: # pragma: no cover # relative spec.path is never abs
return
yield from _propose_from_path(spec, cache, env, tested_exes)
yield from _propose_from_uv(cache, env)
def _propose_explicit(
spec: PythonSpec,
try_first_with: Iterable[str],
cache: PyInfoCache | None,
env: Mapping[str, str],
tested_exes: set[str],
) -> Generator[tuple[PythonInfo | None, bool], None, None]:
for py_exe in try_first_with:
if exe_raw := _check_exe(str(Path(py_exe).resolve()), tested_exes):
yield PythonInfo.from_exe(exe_raw, cache, env=env), True
if spec.path is not None:
if exe_raw := _check_exe(spec.path, tested_exes): # pragma: no branch
yield PythonInfo.from_exe(exe_raw, cache, env=env), True
else:
yield from _propose_current_and_windows(spec, cache, env, tested_exes)
def _propose_current_and_windows(
spec: PythonSpec,
cache: PyInfoCache | None,
env: Mapping[str, str],
tested_exes: set[str],
) -> Generator[tuple[PythonInfo | None, bool], None, None]:
current_python = PythonInfo.current_system(cache)
if _is_new_exe(str(current_python.executable), tested_exes):
yield current_python, True
if IS_WIN: # pragma: win32 cover
from ._windows import propose_interpreters as win_propose # noqa: PLC0415
for interpreter in win_propose(spec, cache, env):
if _is_new_exe(str(interpreter.executable), tested_exes):
yield interpreter, True
def _propose_from_path(
spec: PythonSpec,
cache: PyInfoCache | None,
env: Mapping[str, str],
tested_exes: set[str],
) -> Generator[tuple[PythonInfo | None, bool], None, None]:
find_candidates = path_exe_finder(spec)
for pos, path in enumerate(get_paths(env)):
_LOGGER.debug(LazyPathDump(pos, path, env))
for exe, impl_must_match in find_candidates(path):
exe_raw = str(exe)
if resolved := _resolve_shim(exe_raw, env):
_LOGGER.debug("resolved shim %s to %s", exe_raw, resolved)
exe_raw = resolved
if not _is_new_exe(exe_raw, tested_exes):
continue
interpreter = PathPythonInfo.from_exe(exe_raw, cache, raise_on_error=False, env=env)
if interpreter is not None:
yield interpreter, impl_must_match
def _propose_from_uv(
cache: PyInfoCache | None,
env: Mapping[str, str],
) -> Generator[tuple[PythonInfo | None, bool], None, None]:
if uv_python_dir := os.getenv("UV_PYTHON_INSTALL_DIR"):
uv_python_path = Path(uv_python_dir).expanduser()
elif xdg_data_home := os.getenv("XDG_DATA_HOME"):
uv_python_path = Path(xdg_data_home).expanduser() / "uv" / "python"
else:
uv_python_path = user_data_path("uv") / "python"
for exe_path in uv_python_path.glob("*/bin/python"): # pragma: no branch
interpreter = PathPythonInfo.from_exe(str(exe_path), cache, raise_on_error=False, env=env)
if interpreter is not None: # pragma: no branch
yield interpreter, True
def get_paths(env: Mapping[str, str]) -> Generator[Path, None, None]:
path = env.get("PATH", None)
if path is None:
try:
path = os.confstr("CS_PATH")
except (AttributeError, ValueError): # pragma: no cover # Windows only (no confstr)
path = os.defpath
if path:
for entry in map(Path, path.split(os.pathsep)):
with suppress(OSError):
if entry.is_dir() and next(entry.iterdir(), None):
yield entry
class LazyPathDump:
def __init__(self, pos: int, path: Path, env: Mapping[str, str]) -> None:
self.pos = pos
self.path = path
self.env = env
def __repr__(self) -> str:
content = f"discover PATH[{self.pos}]={self.path}"
if self.env.get("_VIRTUALENV_DEBUG"):
content += " with =>"
for file_path in self.path.iterdir():
try:
if file_path.is_dir():
continue
if IS_WIN: # pragma: win32 cover
pathext = self.env.get("PATHEXT", ".COM;.EXE;.BAT;.CMD").split(";")
if not any(file_path.name.upper().endswith(ext) for ext in pathext):
continue
elif not (file_path.stat().st_mode & os.X_OK):
continue
except OSError:
pass
content += " "
content += file_path.name
return content
def path_exe_finder(spec: PythonSpec) -> Callable[[Path], Generator[tuple[Path, bool], None, None]]:
"""Given a spec, return a function that can be called on a path to find all matching files in it."""
pat = spec.generate_re(windows=sys.platform == "win32")
direct = spec.str_spec
if sys.platform == "win32": # pragma: win32 cover
direct = f"{direct}.exe"
def path_exes(path: Path) -> Generator[tuple[Path, bool], None, None]:
direct_path = path / direct
if direct_path.exists():
yield direct_path, False
for exe in path.iterdir():
match = pat.fullmatch(exe.name)
if match:
yield exe.absolute(), match["impl"] == "python"
return path_exes
def _resolve_shim(exe_path: str, env: Mapping[str, str]) -> str | None:
"""Resolve a version-manager shim to the actual Python binary."""
for shims_dir_env, versions_path in _VERSION_MANAGER_LAYOUTS:
if root := env.get(shims_dir_env):
shims_dir = os.path.join(root, "shims")
if os.path.dirname(exe_path) == shims_dir:
exe_name = os.path.basename(exe_path)
versions_dir = os.path.join(root, *versions_path)
return _resolve_shim_to_binary(exe_name, versions_dir, env)
return None
_VERSION_MANAGER_LAYOUTS: list[tuple[str, tuple[str, ...]]] = [
("PYENV_ROOT", ("versions",)),
("MISE_DATA_DIR", ("installs", "python")),
("ASDF_DATA_DIR", ("installs", "python")),
]
def _resolve_shim_to_binary(exe_name: str, versions_dir: str, env: Mapping[str, str]) -> str | None:
for version in _active_versions(env):
resolved = os.path.join(versions_dir, version, "bin", exe_name)
if Path(resolved).is_file() and os.access(resolved, os.X_OK):
return resolved
return None
def _active_versions(env: Mapping[str, str]) -> Generator[str, None, None]:
"""Yield active Python version strings by reading version-manager configuration."""
if pyenv_version := env.get("PYENV_VERSION"):
yield from pyenv_version.split(":")
return
if versions := _read_python_version_file(Path.cwd()):
yield from versions
return
if (pyenv_root := env.get("PYENV_ROOT")) and (
versions := _read_python_version_file(os.path.join(pyenv_root, "version"), search_parents=False)
):
yield from versions
def _read_python_version_file(start: str | Path, *, search_parents: bool = True) -> list[str] | None:
"""Read a ``.python-version`` file, optionally searching parent directories."""
current = start
while True:
candidate = os.path.join(current, ".python-version") if Path(current).is_dir() else current
if Path(candidate).is_file():
with Path(candidate).open(encoding="utf-8") as fh:
if versions := [v for line in fh if (v := line.strip()) and not v.startswith("#")]:
return versions
if not search_parents:
return None
parent = Path(current).parent
if parent == current:
return None
current = parent
class PathPythonInfo(PythonInfo):
"""python info from path."""
__all__ = [
"LazyPathDump",
"PathPythonInfo",
"get_interpreter",
"get_paths",
"propose_interpreters",
]
python_discovery-1.2.2/src/python_discovery/_py_info.py 0000644 0000000 0000000 00000103216 13615410400 020422 0 ustar 00 """Concrete Python interpreter information, also used as subprocess interrogation script (stdlib only)."""
from __future__ import annotations
import json
import logging
import os
import platform
import re
import struct
import sys
import sysconfig
import warnings
from collections import OrderedDict
from string import digits
from typing import TYPE_CHECKING, ClassVar, Final, NamedTuple
if TYPE_CHECKING:
from collections.abc import Generator, Mapping
from ._cache import PyInfoCache
from ._py_spec import PythonSpec
class VersionInfo(NamedTuple):
major: int
minor: int
micro: int
releaselevel: str
serial: int
_LOGGER: Final[logging.Logger] = logging.getLogger(__name__)
def _get_path_extensions() -> list[str]:
return list(OrderedDict.fromkeys(["", *os.environ.get("PATHEXT", "").lower().split(os.pathsep)]))
EXTENSIONS: Final[list[str]] = _get_path_extensions()
_32BIT_POINTER_SIZE: Final[int] = 4
_CONF_VAR_RE: Final[re.Pattern[str]] = re.compile(
r"""
\{ \w+ } # sysconfig variable placeholder like {base}
""",
re.VERBOSE,
)
class PythonInfo: # noqa: PLR0904
"""Contains information for a Python interpreter."""
def __init__(self) -> None:
self._init_identity()
self._init_prefixes()
self._init_schemes()
self._init_sysconfig()
def _init_identity(self) -> None:
self.platform = sys.platform
self.implementation = platform.python_implementation()
if self.implementation == "PyPy":
self.pypy_version_info = tuple(sys.pypy_version_info) # ty: ignore[unresolved-attribute] # pypy only
self.version_info = VersionInfo(*sys.version_info)
# same as stdlib platform.architecture to account for pointer size != max int
self.architecture = 32 if struct.calcsize("P") == _32BIT_POINTER_SIZE else 64
self.sysconfig_platform = sysconfig.get_platform()
self.version_nodot = sysconfig.get_config_var("py_version_nodot")
self.version = sys.version
self.os = os.name
self.free_threaded = sysconfig.get_config_var("Py_GIL_DISABLED") == 1
def _init_prefixes(self) -> None:
def abs_path(value: str | None) -> str | None:
return None if value is None else os.path.abspath(value)
self.prefix = abs_path(getattr(sys, "prefix", None))
self.base_prefix = abs_path(getattr(sys, "base_prefix", None))
self.real_prefix = abs_path(getattr(sys, "real_prefix", None))
self.base_exec_prefix = abs_path(getattr(sys, "base_exec_prefix", None))
self.exec_prefix = abs_path(getattr(sys, "exec_prefix", None))
self.executable = abs_path(sys.executable)
self.original_executable = abs_path(self.executable)
self.system_executable = self._fast_get_system_executable()
try:
__import__("venv")
has = True
except ImportError: # pragma: no cover # venv is always available in standard CPython
has = False
self.has_venv = has
self.path = sys.path
self.file_system_encoding = sys.getfilesystemencoding()
self.stdout_encoding = getattr(sys.stdout, "encoding", None)
def _init_schemes(self) -> None:
scheme_names = sysconfig.get_scheme_names()
if "venv" in scheme_names: # pragma: >=3.11 cover
self.sysconfig_scheme = "venv"
self.sysconfig_paths = {
i: sysconfig.get_path(i, expand=False, scheme=self.sysconfig_scheme) for i in sysconfig.get_path_names()
}
self.distutils_install = {}
# debian / ubuntu python 3.10 without `python3-distutils` will report mangled `local/bin` / etc. names
elif sys.version_info[:2] == (3, 10) and "deb_system" in scheme_names: # pragma: no cover # Debian/Ubuntu 3.10
self.sysconfig_scheme = "posix_prefix"
self.sysconfig_paths = {
i: sysconfig.get_path(i, expand=False, scheme=self.sysconfig_scheme) for i in sysconfig.get_path_names()
}
self.distutils_install = {}
else: # pragma: no cover # "venv" scheme always present on Python 3.12+
self.sysconfig_scheme = None
self.sysconfig_paths = {i: sysconfig.get_path(i, expand=False) for i in sysconfig.get_path_names()}
self.distutils_install = self._distutils_install().copy()
def _init_sysconfig(self) -> None:
makefile = getattr(sysconfig, "get_makefile_filename", getattr(sysconfig, "_get_makefile_filename", None))
self.sysconfig = {
k: v
for k, v in [
("makefile_filename", makefile() if makefile is not None else None),
]
if k is not None
}
config_var_keys = set()
for element in self.sysconfig_paths.values():
config_var_keys.update(k[1:-1] for k in _CONF_VAR_RE.findall(element))
config_var_keys.add("PYTHONFRAMEWORK")
config_var_keys.update(("Py_ENABLE_SHARED", "INSTSONAME", "LIBDIR"))
self.sysconfig_vars = {i: sysconfig.get_config_var(i or "") for i in config_var_keys}
if "TCL_LIBRARY" in os.environ:
self.tcl_lib, self.tk_lib = self._get_tcl_tk_libs()
else:
self.tcl_lib, self.tk_lib = None, None
confs = {
k: (self.system_prefix if isinstance(v, str) and v.startswith(self.prefix) else v)
for k, v in self.sysconfig_vars.items()
}
self.system_stdlib = self.sysconfig_path("stdlib", confs)
self.system_stdlib_platform = self.sysconfig_path("platstdlib", confs)
self.max_size = getattr(sys, "maxsize", getattr(sys, "maxint", None))
self._creators = None # virtualenv-specific, set via monkey-patch
@staticmethod
def _get_tcl_tk_libs() -> tuple[
str | None,
str | None,
]: # pragma: no cover # tkinter availability varies; tested indirectly via __init__
"""Detect the tcl and tk libraries using tkinter."""
tcl_lib, tk_lib = None, None
try:
import tkinter as tk # noqa: PLC0415
except ImportError:
pass
else:
try:
tcl = tk.Tcl()
tcl_lib = tcl.eval("info library")
# Try to get TK library path directly first
try:
tk_lib = tcl.eval("set tk_library")
if tk_lib and os.path.isdir(tk_lib):
pass # We found it directly
else:
tk_lib = None # Reset if invalid
except tk.TclError:
tk_lib = None
# If direct query failed, try constructing the path
if tk_lib is None:
tk_version = tcl.eval("package require Tk")
tcl_parent = os.path.dirname(tcl_lib)
# Try different version formats
version_variants = [
tk_version, # Full version like "8.6.12"
".".join(tk_version.split(".")[:2]), # Major.minor like "8.6"
tk_version.split(".")[0], # Just major like "8"
]
for version in version_variants:
tk_lib_path = os.path.join(tcl_parent, f"tk{version}")
if not os.path.isdir(tk_lib_path):
continue
if os.path.exists(os.path.join(tk_lib_path, "tk.tcl")):
tk_lib = tk_lib_path
break
except tk.TclError:
pass
return tcl_lib, tk_lib
def _fast_get_system_executable(self) -> str | None:
"""Try to get the system executable by just looking at properties."""
# if we're not in a virtual environment, this is already a system python, so return the original executable
# note we must choose the original and not the pure executable as shim scripts might throw us off
if not (self.real_prefix or (self.base_prefix is not None and self.base_prefix != self.prefix)):
return self.original_executable
# if this is NOT a virtual environment, can't determine easily, bail out
if self.real_prefix is not None:
return None
base_executable = getattr(sys, "_base_executable", None) # some platforms may set this to help us
if base_executable is None: # use the saved system executable if present
return None
# we know we're in a virtual environment, can not be us
if sys.executable == base_executable:
return None
# We're not in a venv and base_executable exists; use it directly
if os.path.exists(base_executable): # pragma: >=3.11 cover
return base_executable
# Try fallback for POSIX virtual environments
return self._try_posix_fallback_executable(base_executable) # pragma: >=3.11 cover
def _try_posix_fallback_executable(self, base_executable: str) -> str | None:
"""Find a versioned Python binary as fallback for POSIX virtual environments."""
major, minor = self.version_info.major, self.version_info.minor
if self.os != "posix" or (major, minor) < (3, 11):
return None
# search relative to the directory of sys._base_executable
base_dir = os.path.dirname(base_executable)
candidates = [f"python{major}", f"python{major}.{minor}"]
if self.implementation == "PyPy":
candidates.extend(["pypy", "pypy3", f"pypy{major}", f"pypy{major}.{minor}"])
for candidate in candidates:
full_path = os.path.join(base_dir, candidate)
if os.path.exists(full_path):
return full_path
return None # in this case we just can't tell easily without poking around FS and calling them, bail
def install_path(self, key: str) -> str:
"""
Return the relative installation path for a given installation scheme *key*.
:param key: sysconfig installation scheme key (e.g. ``"scripts"``, ``"purelib"``).
"""
result = self.distutils_install.get(key)
if result is None: # pragma: >=3.11 cover # distutils is empty when "venv" scheme is available
# set prefixes to empty => result is relative from cwd
prefixes = self.prefix, self.exec_prefix, self.base_prefix, self.base_exec_prefix
config_var = {k: "" if v in prefixes else v for k, v in self.sysconfig_vars.items()}
result = self.sysconfig_path(key, config_var=config_var).lstrip(os.sep)
return result
@staticmethod
def _distutils_install() -> dict[str, str]:
# use distutils primarily because that's what pip does
# https://github.com/pypa/pip/blob/main/src/pip/_internal/locations.py#L95
# note here we don't import Distribution directly to allow setuptools to patch it
with warnings.catch_warnings(): # disable warning for PEP-632
warnings.simplefilter("ignore")
try:
from distutils import dist # noqa: PLC0415 # ty: ignore[unresolved-import]
from distutils.command.install import SCHEME_KEYS # noqa: PLC0415 # ty: ignore[unresolved-import]
except ImportError: # pragma: no cover # if removed or not installed ignore
return {}
distribution = dist.Distribution({
"script_args": "--no-user-cfg",
}) # conf files not parsed so they do not hijack paths
if hasattr(sys, "_framework"): # pragma: no cover # macOS framework builds only
sys._framework = None # noqa: SLF001 # disable macOS static paths for framework
with warnings.catch_warnings(): # disable warning for PEP-632
warnings.simplefilter("ignore")
install = distribution.get_command_obj("install", create=True)
install.prefix = os.sep # paths generated are relative to prefix that contains the path sep
install.finalize_options()
return {key: (getattr(install, f"install_{key}")[1:]).lstrip(os.sep) for key in SCHEME_KEYS}
@property
def version_str(self) -> str:
"""The full version as ``major.minor.micro`` string (e.g. ``3.13.2``)."""
return ".".join(str(i) for i in self.version_info[0:3])
@property
def version_release_str(self) -> str:
"""The release version as ``major.minor`` string (e.g. ``3.13``)."""
return ".".join(str(i) for i in self.version_info[0:2])
@property
def python_name(self) -> str:
"""The python executable name as ``pythonX.Y`` (e.g. ``python3.13``)."""
version_info = self.version_info
return f"python{version_info.major}.{version_info.minor}"
@property
def is_old_virtualenv(self) -> bool:
"""``True`` if this interpreter runs inside an old-style virtualenv (has ``real_prefix``)."""
return self.real_prefix is not None
@property
def is_venv(self) -> bool:
"""``True`` if this interpreter runs inside a PEP 405 venv (has ``base_prefix``)."""
return self.base_prefix is not None
def sysconfig_path(self, key: str, config_var: dict[str, str] | None = None, sep: str = os.sep) -> str:
"""
Return the sysconfig install path for a scheme *key*, optionally substituting config variables.
:param key: sysconfig path key (e.g. ``"purelib"``, ``"include"``).
:param config_var: replacement mapping for sysconfig variables; when ``None`` uses the interpreter's own values.
:param sep: path separator to use in the result.
"""
pattern = self.sysconfig_paths.get(key)
if pattern is None:
return ""
if config_var is None:
config_var = self.sysconfig_vars
else:
base = self.sysconfig_vars.copy()
base.update(config_var)
config_var = base
return pattern.format(**config_var).replace("/", sep)
@property
def system_include(self) -> str:
"""The path to the system include directory for C headers."""
path = self.sysconfig_path(
"include",
{
k: (self.system_prefix if isinstance(v, str) and v.startswith(self.prefix) else v)
for k, v in self.sysconfig_vars.items()
},
)
if not os.path.exists(path): # pragma: no cover # broken packaging fallback
fallback = os.path.join(self.prefix, os.path.dirname(self.install_path("headers")))
if os.path.exists(fallback):
path = fallback
return path
@property
def system_prefix(self) -> str:
"""The prefix of the system Python this interpreter is based on."""
return self.real_prefix or self.base_prefix or self.prefix
@property
def system_exec_prefix(self) -> str:
"""The exec prefix of the system Python this interpreter is based on."""
return self.real_prefix or self.base_exec_prefix or self.exec_prefix
def __repr__(self) -> str:
return "{}({!r})".format(
self.__class__.__name__,
{k: v for k, v in self.__dict__.items() if not k.startswith("_")},
)
def __str__(self) -> str:
return "{}({})".format(
self.__class__.__name__,
", ".join(
f"{k}={v}"
for k, v in (
("spec", self.spec),
(
"system"
if self.system_executable is not None and self.system_executable != self.executable
else None,
self.system_executable,
),
(
"original"
if self.original_executable not in {self.system_executable, self.executable}
else None,
self.original_executable,
),
("exe", self.executable),
("platform", self.platform),
("version", repr(self.version)),
("encoding_fs_io", f"{self.file_system_encoding}-{self.stdout_encoding}"),
)
if k is not None
),
)
@property
def machine(self) -> str:
"""Return the instruction set architecture (ISA) derived from :func:`sysconfig.get_platform`."""
plat = self.sysconfig_platform
if plat is None:
return "unknown"
if plat == "win32":
return "x86"
isa = plat.rsplit("-", 1)[-1]
if isa == "universal2":
isa = platform.machine().lower()
return normalize_isa(isa)
@property
def spec(self) -> str:
"""A specification string identifying this interpreter (e.g. ``CPython3.13.2-64-arm64``)."""
return "{}{}{}-{}-{}".format(
self.implementation,
".".join(str(i) for i in self.version_info),
"t" if self.free_threaded else "",
self.architecture,
self.machine,
)
@classmethod
def clear_cache(cls, cache: PyInfoCache) -> None:
"""
Clear all cached interpreter information from *cache*.
:param cache: the cache store to clear.
"""
from ._cached_py_info import clear # noqa: PLC0415
clear(cache)
cls._cache_exe_discovery.clear()
def satisfies(self, spec: PythonSpec, *, impl_must_match: bool) -> bool: # noqa: PLR0911
"""
Check if a given specification can be satisfied by this python interpreter instance.
:param spec: the specification to check against.
:param impl_must_match: when ``True``, the implementation name must match exactly.
"""
if spec.path and not self._satisfies_path(spec):
return False
if impl_must_match and not self._satisfies_implementation(spec):
return False
if spec.architecture is not None and spec.architecture != self.architecture:
return False
if spec.machine is not None and spec.machine != self.machine:
return False
if spec.free_threaded is not None and spec.free_threaded != self.free_threaded:
return False
if spec.version_specifier is not None and not self._satisfies_version_specifier(spec):
return False
return all(
req is None or our is None or our == req
for our, req in zip(self.version_info[0:3], (spec.major, spec.minor, spec.micro))
)
def _satisfies_path(self, spec: PythonSpec) -> bool:
if self.executable == os.path.abspath(spec.path):
return True
if spec.is_abs:
return True
basename = os.path.basename(self.original_executable)
spec_path = spec.path
if sys.platform == "win32":
basename, suffix = os.path.splitext(basename)
spec_path = spec_path[: -len(suffix)] if suffix and spec_path.endswith(suffix) else spec_path
return basename == spec_path
def _satisfies_implementation(self, spec: PythonSpec) -> bool:
return spec.implementation is None or spec.implementation.lower() == self.implementation.lower()
def _satisfies_version_specifier(self, spec: PythonSpec) -> bool:
if spec.version_specifier is None: # pragma: no cover
return True
version_info = self.version_info
for specifier in spec.version_specifier:
assert specifier.version is not None # noqa: S101
numeric_version = specifier.version_str
for prefix in ("rc", "b", "a"):
if prefix in numeric_version:
numeric_version = numeric_version.split(prefix)[0]
break
precision = numeric_version.count(".") + 1
release = ".".join(str(c) for c in [version_info.major, version_info.minor, version_info.micro][:precision])
if (
version_info.releaselevel != "final"
and (precision == 3 or specifier.version.pre_type is not None) # noqa: PLR2004
and (suffix := {"alpha": "a", "beta": "b", "candidate": "rc"}.get(version_info.releaselevel))
):
release = f"{release}{suffix}{version_info.serial}"
if not specifier.contains(release):
return False
return True
_current_system = None
_current = None
@classmethod
def current(cls, cache: PyInfoCache | None = None) -> PythonInfo:
"""
Locate the current host interpreter information.
:param cache: interpreter metadata cache; when ``None`` results are not cached.
"""
if cls._current is None:
result = cls.from_exe(sys.executable, cache, raise_on_error=True, resolve_to_host=False)
if result is None:
msg = "failed to query current Python interpreter"
raise RuntimeError(msg)
cls._current = result
return cls._current
@classmethod
def current_system(cls, cache: PyInfoCache | None = None) -> PythonInfo:
"""
Locate the current system interpreter information, resolving through any virtualenv layers.
:param cache: interpreter metadata cache; when ``None`` results are not cached.
"""
if cls._current_system is None:
result = cls.from_exe(sys.executable, cache, raise_on_error=True, resolve_to_host=True)
if result is None:
msg = "failed to query current system Python interpreter"
raise RuntimeError(msg)
cls._current_system = result
return cls._current_system
def to_json(self) -> str:
"""Serialize this interpreter information to a JSON string."""
return json.dumps(self.to_dict(), indent=2)
def to_dict(self) -> dict[str, object]:
"""Convert this interpreter information to a plain dictionary."""
data = {var: (getattr(self, var) if var != "_creators" else None) for var in vars(self)}
version_info = data["version_info"]
data["version_info"] = version_info._asdict() if hasattr(version_info, "_asdict") else version_info
return data
@classmethod
def from_exe( # noqa: PLR0913
cls,
exe: str,
cache: PyInfoCache | None = None,
*,
raise_on_error: bool = True,
ignore_cache: bool = False,
resolve_to_host: bool = True,
env: Mapping[str, str] | None = None,
) -> PythonInfo | None:
"""
Get the python information for a given executable path.
:param exe: path to the Python executable.
:param cache: interpreter metadata cache; when ``None`` results are not cached.
:param raise_on_error: raise on failure instead of returning ``None``.
:param ignore_cache: bypass the cache and re-query the interpreter.
:param resolve_to_host: resolve through virtualenv layers to the system interpreter.
:param env: environment mapping; defaults to :data:`os.environ`.
"""
from ._cached_py_info import from_exe # noqa: PLC0415
env = os.environ if env is None else env
proposed = from_exe(cls, cache, exe, env=env, raise_on_error=raise_on_error, ignore_cache=ignore_cache)
if isinstance(proposed, PythonInfo) and resolve_to_host:
try:
proposed = proposed.resolve_to_system(cache, proposed)
except Exception as exception:
if raise_on_error:
raise
_LOGGER.info("ignore %s due cannot resolve system due to %r", proposed.original_executable, exception)
proposed = None
return proposed
@classmethod
def from_json(cls, payload: str) -> PythonInfo:
"""
Deserialize interpreter information from a JSON string.
:param payload: JSON produced by :meth:`to_json`.
"""
raw = json.loads(payload)
return cls.from_dict(raw.copy())
@classmethod
def from_dict(cls, data: dict[str, object]) -> PythonInfo:
"""
Reconstruct a :class:`PythonInfo` from a plain dictionary.
:param data: dictionary produced by :meth:`to_dict`.
"""
data["version_info"] = VersionInfo(**data["version_info"]) # restore this to a named tuple structure
result = cls()
result.__dict__ = data.copy()
return result
@classmethod
def resolve_to_system(cls, cache: PyInfoCache | None, target: PythonInfo) -> PythonInfo:
"""
Walk virtualenv/venv prefix chains to find the underlying system interpreter.
:param cache: interpreter metadata cache; when ``None`` results are not cached.
:param target: the interpreter to resolve.
"""
start_executable = target.executable
prefixes = OrderedDict()
while target.system_executable is None:
prefix = target.real_prefix or target.base_prefix or target.prefix
if prefix in prefixes:
if len(prefixes) == 1:
_LOGGER.info("%r links back to itself via prefixes", target)
target.system_executable = target.executable
break
for at, (p, t) in enumerate(prefixes.items(), start=1):
_LOGGER.error("%d: prefix=%s, info=%r", at, p, t)
_LOGGER.error("%d: prefix=%s, info=%r", len(prefixes) + 1, prefix, target)
msg = "prefixes are causing a circle {}".format("|".join(prefixes.keys()))
raise RuntimeError(msg)
prefixes[prefix] = target
target = target.discover_exe(cache, prefix=prefix, exact=False)
if target.executable != target.system_executable:
resolved = cls.from_exe(target.system_executable, cache)
if resolved is not None:
target = resolved
target.executable = start_executable
return target
_cache_exe_discovery: ClassVar[dict[tuple[str, bool], PythonInfo]] = {}
def discover_exe(
self,
cache: PyInfoCache,
prefix: str,
*,
exact: bool = True,
env: Mapping[str, str] | None = None,
) -> PythonInfo:
"""
Discover a matching Python executable under a given *prefix* directory.
:param cache: interpreter metadata cache.
:param prefix: directory prefix to search under.
:param exact: when ``True``, require an exact version match.
:param env: environment mapping; defaults to :data:`os.environ`.
"""
key = prefix, exact
if key in self._cache_exe_discovery and prefix:
_LOGGER.debug("discover exe from cache %s - exact %s: %r", prefix, exact, self._cache_exe_discovery[key])
return self._cache_exe_discovery[key]
_LOGGER.debug("discover exe for %s in %s", self, prefix)
possible_names = self._find_possible_exe_names()
possible_folders = self._find_possible_folders(prefix)
discovered = []
env = os.environ if env is None else env
for folder in possible_folders:
for name in possible_names:
info = self._check_exe(cache, folder, name, discovered, env, exact=exact)
if info is not None:
self._cache_exe_discovery[key] = info
return info
if exact is False and discovered:
info = self._select_most_likely(discovered, self)
folders = os.pathsep.join(possible_folders)
self._cache_exe_discovery[key] = info
_LOGGER.debug("no exact match found, chosen most similar of %s within base folders %s", info, folders)
return info
msg = "failed to detect {} in {}".format("|".join(possible_names), os.pathsep.join(possible_folders))
raise RuntimeError(msg)
def _check_exe( # noqa: PLR0913
self,
cache: PyInfoCache | None,
folder: str,
name: str,
discovered: list[PythonInfo],
env: Mapping[str, str],
*,
exact: bool,
) -> PythonInfo | None:
exe_path = os.path.join(folder, name)
if not os.path.exists(exe_path):
return None
info = self.from_exe(exe_path, cache, resolve_to_host=False, raise_on_error=False, env=env)
if info is None: # ignore if for some reason we can't query
return None
for item in ["implementation", "architecture", "machine", "version_info"]:
found = getattr(info, item)
searched = getattr(self, item)
if found != searched:
if item == "version_info":
found, searched = ".".join(str(i) for i in found), ".".join(str(i) for i in searched)
executable = info.executable
_LOGGER.debug("refused interpreter %s because %s differs %s != %s", executable, item, found, searched)
if exact is False:
discovered.append(info)
break
else:
return info
return None
@staticmethod
def _select_most_likely(discovered: list[PythonInfo], target: PythonInfo) -> PythonInfo:
def sort_by(info: PythonInfo) -> int:
# we need to setup some priority of traits, this is as follows:
# implementation, major, minor, architecture, machine, micro, tag, serial
matches = [
info.implementation == target.implementation,
info.version_info.major == target.version_info.major,
info.version_info.minor == target.version_info.minor,
info.architecture == target.architecture,
info.machine == target.machine,
info.version_info.micro == target.version_info.micro,
info.version_info.releaselevel == target.version_info.releaselevel,
info.version_info.serial == target.version_info.serial,
]
return sum((1 << pos if match else 0) for pos, match in enumerate(reversed(matches)))
sorted_discovered = sorted(discovered, key=sort_by, reverse=True) # sort by priority in decreasing order
return sorted_discovered[0]
def _find_possible_folders(self, inside_folder: str) -> list[str]:
candidate_folder = OrderedDict()
executables = OrderedDict()
executables[os.path.realpath(self.executable)] = None
executables[self.executable] = None
executables[os.path.realpath(self.original_executable)] = None
executables[self.original_executable] = None
for exe in executables:
base = os.path.dirname(exe)
if base.startswith(self.prefix):
relative = base[len(self.prefix) :]
candidate_folder[f"{inside_folder}{relative}"] = None
# or at root level
candidate_folder[inside_folder] = None
return [i for i in candidate_folder if os.path.exists(i)]
def _find_possible_exe_names(self) -> list[str]:
name_candidate = OrderedDict()
for name in self._possible_base():
for at in (3, 2, 1, 0):
version = ".".join(str(i) for i in self.version_info[:at])
mods = [""]
if self.free_threaded:
mods.append("t")
for mod in mods:
for arch in [f"-{self.architecture}", ""]:
for ext in EXTENSIONS:
candidate = f"{name}{version}{mod}{arch}{ext}"
name_candidate[candidate] = None
return list(name_candidate.keys())
def _possible_base(self) -> Generator[str, None, None]:
possible_base = OrderedDict()
basename = os.path.splitext(os.path.basename(self.executable))[0].rstrip(digits)
possible_base[basename] = None
possible_base[self.implementation] = None
# python is always the final option as in practice is used by multiple implementation as exe name
if "python" in possible_base:
del possible_base["python"]
possible_base["python"] = None
for base in possible_base:
lower = base.lower()
yield lower
from ._compat import fs_is_case_sensitive # noqa: PLC0415
if fs_is_case_sensitive(): # pragma: no branch
if base != lower:
yield base
upper = base.upper()
if upper != base:
yield upper
KNOWN_ARCHITECTURES: frozenset[str] = frozenset({
"arm64",
"loongarch64",
"ppc",
"ppc64",
"ppc64le",
"riscv64",
"s390x",
"sparc64",
"x86",
"x86_64",
})
"""Known CPU architecture (ISA) values after normalization.
.. deprecated::
Use :func:`normalize_isa` instead, which handles both known and unknown architectures.
"""
def normalize_isa(isa: str) -> str:
"""
Normalize an ISA (instruction set architecture) string to a canonical form.
Known aliases are mapped (e.g. ``amd64`` → ``x86_64``, ``aarch64`` → ``arm64``).
Unrecognized values are lowercased and returned as-is.
"""
low = isa.lower()
return {
"amd64": "x86_64",
"aarch64": "arm64",
"i386": "x86",
"i486": "x86",
"i586": "x86",
"i686": "x86",
"powerpc": "ppc",
"powerpc64": "ppc64",
"powerpc64le": "ppc64le",
"sparcv9": "sparc64",
}.get(low, low)
def _main() -> None: # pragma: no cover
argv = sys.argv[1:]
if len(argv) >= 1:
start_cookie = argv[0]
argv = argv[1:]
else:
start_cookie = ""
if len(argv) >= 1:
end_cookie = argv[0]
argv = argv[1:]
else:
end_cookie = ""
sys.argv = sys.argv[:1] + argv
result = PythonInfo().to_json()
sys.stdout.write("".join((start_cookie[::-1], result, end_cookie[::-1])))
sys.stdout.flush()
if __name__ == "__main__":
_main()
python_discovery-1.2.2/src/python_discovery/_py_spec.py 0000644 0000000 0000000 00000022601 13615410400 020417 0 ustar 00 """A Python specification is an abstract requirement definition of an interpreter."""
from __future__ import annotations
import contextlib
import pathlib
import re
from typing import Final
from ._py_info import normalize_isa
from ._specifier import SimpleSpecifier, SimpleSpecifierSet, SimpleVersion
PATTERN = re.compile(
r"""
^
(?P[a-zA-Z]+)? # implementation (e.g. cpython, pypy)
(?P[0-9.]+)? # version (e.g. 3.12, 3.12.1)
(?Pt)? # free-threaded flag
(?:-(?P32|64))? # architecture bitness
(?:-(?P[a-zA-Z0-9_]+))? # ISA (e.g. arm64, x86_64)
$
""",
re.VERBOSE,
)
SPECIFIER_PATTERN = re.compile(
r"""
^
(?:(?P[A-Za-z]+)\s*)? # optional implementation prefix
(?P(?:===|==|~=|!=|<=|>=|<|>).+) # PEP 440 version specifier
$
""",
re.VERBOSE,
)
_MAX_VERSION_PARTS: Final[int] = 3
_SINGLE_DIGIT_MAX: Final[int] = 9
SpecifierSet = SimpleSpecifierSet
Version = SimpleVersion
InvalidSpecifier = ValueError
InvalidVersion = ValueError
def _int_or_none(val: str | None) -> int | None:
return None if val is None else int(val)
def _parse_version_parts(version: str) -> tuple[int | None, int | None, int | None]:
versions = tuple(int(i) for i in version.split(".") if i)
if len(versions) > _MAX_VERSION_PARTS:
msg = "too many version parts"
raise ValueError(msg)
if len(versions) == _MAX_VERSION_PARTS:
return versions[0], versions[1], versions[2]
if len(versions) == 2: # noqa: PLR2004
return versions[0], versions[1], None
version_data = versions[0]
major = int(str(version_data)[0])
minor = int(str(version_data)[1:]) if version_data > _SINGLE_DIGIT_MAX else None
return major, minor, None
def _parse_spec_pattern(string_spec: str) -> PythonSpec | None:
if not (match := re.match(PATTERN, string_spec)):
return None
groups = match.groupdict()
version = groups["version"]
major, minor, micro, threaded = None, None, None, None
if version is not None:
try:
major, minor, micro = _parse_version_parts(version)
except ValueError:
return None
threaded = bool(groups["threaded"])
impl = groups["impl"]
if impl in {"py", "python"}:
impl = None
arch = _int_or_none(groups["arch"])
machine = groups.get("machine")
if machine is not None:
machine = normalize_isa(machine)
return PythonSpec(string_spec, impl, major, minor, micro, arch, None, free_threaded=threaded, machine=machine)
def _parse_specifier(string_spec: str) -> PythonSpec | None:
if not (specifier_match := SPECIFIER_PATTERN.match(string_spec.strip())):
return None
if SpecifierSet is None: # pragma: no cover
return None
impl = specifier_match.group("impl")
spec_text = specifier_match.group("spec").strip()
try:
version_specifier = SpecifierSet.from_string(spec_text)
except InvalidSpecifier: # pragma: no cover
return None
if impl in {"py", "python"}:
impl = None
return PythonSpec(string_spec, impl, None, None, None, None, None, version_specifier=version_specifier)
class PythonSpec:
"""
Contains specification about a Python Interpreter.
:param str_spec: the raw specification string as provided by the caller.
:param implementation: interpreter implementation name (e.g. ``"cpython"``, ``"pypy"``), or ``None`` for any.
:param major: required major version, or ``None`` for any.
:param minor: required minor version, or ``None`` for any.
:param micro: required micro (patch) version, or ``None`` for any.
:param architecture: required pointer-size bitness (``32`` or ``64``), or ``None`` for any.
:param path: filesystem path to a specific interpreter, or ``None``.
:param free_threaded: whether a free-threaded build is required, or ``None`` for any.
:param machine: required ISA (e.g. ``"arm64"``), or ``None`` for any.
:param version_specifier: PEP 440 version constraints, or ``None``.
"""
def __init__( # noqa: PLR0913, PLR0917
self,
str_spec: str,
implementation: str | None,
major: int | None,
minor: int | None,
micro: int | None,
architecture: int | None,
path: str | None,
*,
free_threaded: bool | None = None,
machine: str | None = None,
version_specifier: SpecifierSet | None = None,
) -> None:
self.str_spec = str_spec
self.implementation = implementation
self.major = major
self.minor = minor
self.micro = micro
self.free_threaded = free_threaded
self.architecture = architecture
self.machine = machine
self.path = path
self.version_specifier = version_specifier
@classmethod
def from_string_spec(cls, string_spec: str) -> PythonSpec:
"""
Parse a string specification into a :class:`PythonSpec`.
:param string_spec: an interpreter spec — an absolute path, a version string, an implementation prefix,
or a PEP 440 specifier.
"""
if pathlib.Path(string_spec).is_absolute():
return cls(string_spec, None, None, None, None, None, string_spec)
if result := _parse_spec_pattern(string_spec):
return result
if result := _parse_specifier(string_spec):
return result
return cls(string_spec, None, None, None, None, None, string_spec)
def generate_re(self, *, windows: bool) -> re.Pattern:
"""
Generate a regular expression for matching interpreter filenames.
:param windows: if ``True``, require a ``.exe`` suffix.
"""
version = r"{}(\.{}(\.{})?)?".format(
*(r"\d+" if v is None else v for v in (self.major, self.minor, self.micro)),
)
impl = "python" if self.implementation is None else f"python|{re.escape(self.implementation)}"
mod = "t?" if self.free_threaded else ""
suffix = r"\.exe" if windows else ""
version_conditional = "?" if windows or self.major is None else ""
return re.compile(
rf"(?P{impl})(?P{version}{mod}){version_conditional}{suffix}$",
flags=re.IGNORECASE,
)
@property
def is_abs(self) -> bool:
"""``True`` if the spec refers to an absolute filesystem path."""
return self.path is not None and pathlib.Path(self.path).is_absolute()
def _check_version_specifier(self, spec: PythonSpec) -> bool:
"""Check if version specifier is satisfied."""
components: list[int] = []
for part in (self.major, self.minor, self.micro):
if part is None:
break
components.append(part)
if not components:
return True
version_str = ".".join(str(part) for part in components)
if spec.version_specifier is None:
return True
with contextlib.suppress(InvalidVersion):
Version.from_string(version_str)
for item in spec.version_specifier:
required_precision = self._get_required_precision(item)
if required_precision is None or len(components) < required_precision:
continue
if not item.contains(version_str):
return False
return True
@staticmethod
def _get_required_precision(item: SimpleSpecifier) -> int | None:
"""Get the required precision for a specifier item."""
if item.version is None:
return None
with contextlib.suppress(AttributeError, ValueError):
return len(item.version.release)
return None
def satisfies(self, spec: PythonSpec) -> bool: # noqa: PLR0911
"""
Check if this spec is compatible with the given *spec* (e.g. PEP-514 on Windows).
:param spec: the requirement to check against.
"""
if spec.is_abs and self.is_abs and self.path != spec.path:
return False
if (
spec.implementation is not None
and self.implementation is not None
and spec.implementation.lower() != self.implementation.lower()
):
return False
if spec.architecture is not None and spec.architecture != self.architecture:
return False
if spec.machine is not None and self.machine is not None and spec.machine != self.machine:
return False
if spec.free_threaded is not None and spec.free_threaded != self.free_threaded:
return False
if spec.version_specifier is not None and not self._check_version_specifier(spec):
return False
return all(
req is None or our is None or our == req
for our, req in zip((self.major, self.minor, self.micro), (spec.major, spec.minor, spec.micro))
)
def __repr__(self) -> str:
name = type(self).__name__
params = (
"implementation",
"major",
"minor",
"micro",
"architecture",
"machine",
"path",
"free_threaded",
"version_specifier",
)
return f"{name}({', '.join(f'{k}={getattr(self, k)}' for k in params if getattr(self, k) is not None)})"
__all__ = [
"InvalidSpecifier",
"InvalidVersion",
"PythonSpec",
"SpecifierSet",
"Version",
]
python_discovery-1.2.2/src/python_discovery/_specifier.py 0000644 0000000 0000000 00000024200 13615410400 020723 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 = {"frozen": True, "kw_only": True, "slots": True} if sys.version_info >= (3, 10) else {"frozen": True}
if TYPE_CHECKING:
from collections.abc import Iterator
_VERSION_RE: Final[re.Pattern[str]] = re.compile(
r"""
^
(\d+) # major
(?:\.(\d+))? # optional minor
(?:\.(\d+))? # optional micro
(?:(a|b|rc)(\d+))? # optional pre-release suffix
$
""",
re.VERBOSE,
)
_SPECIFIER_RE: Final[re.Pattern[str]] = re.compile(
r"""
^
(===|==|~=|!=|<=|>=|<|>) # operator
\s*
(.+) # version string
$
""",
re.VERBOSE,
)
_PRE_ORDER: Final[dict[str, int]] = {"a": 1, "b": 2, "rc": 3}
@dataclass(**_DC_KW)
class SimpleVersion:
"""
Simple PEP 440-like version parser using only standard library.
:param version_str: the original version string.
:param major: major version number.
:param minor: minor version number.
:param micro: micro (patch) version number.
:param pre_type: pre-release label (``"a"``, ``"b"``, or ``"rc"``), or ``None``.
:param pre_num: pre-release sequence number, or ``None``.
:param release: the ``(major, minor, micro)`` tuple.
"""
version_str: str
major: int
minor: int
micro: int
pre_type: str | None
pre_num: int | None
release: tuple[int, int, int]
@classmethod
def from_string(cls, version_str: str) -> SimpleVersion:
"""
Parse a PEP 440 version string (e.g. ``3.12.1``).
:param version_str: the version string to parse.
"""
stripped = version_str.strip()
if not (match := _VERSION_RE.match(stripped)):
msg = f"Invalid version: {version_str}"
raise ValueError(msg)
major = int(match.group(1))
minor = int(match.group(2)) if match.group(2) else 0
micro = int(match.group(3)) if match.group(3) else 0
return cls(
version_str=stripped,
major=major,
minor=minor,
micro=micro,
pre_type=match.group(4),
pre_num=int(match.group(5)) if match.group(5) else None,
release=(major, minor, micro),
)
def __eq__(self, other: object) -> bool:
if not isinstance(other, SimpleVersion):
return NotImplemented
return self.release == other.release and self.pre_type == other.pre_type and self.pre_num == other.pre_num
def __hash__(self) -> int:
return hash((self.release, self.pre_type, self.pre_num))
def __lt__(self, other: object) -> bool: # noqa: PLR0911
if not isinstance(other, SimpleVersion):
return NotImplemented
if self.release != other.release:
return self.release < other.release
if self.pre_type is None and other.pre_type is None:
return False
if self.pre_type is None:
return False
if other.pre_type is None:
return True
if _PRE_ORDER[self.pre_type] != _PRE_ORDER[other.pre_type]:
return _PRE_ORDER[self.pre_type] < _PRE_ORDER[other.pre_type]
return (self.pre_num or 0) < (other.pre_num or 0)
def __le__(self, other: object) -> bool:
return self == other or self < other
def __gt__(self, other: object) -> bool:
if not isinstance(other, SimpleVersion):
return NotImplemented
return not self <= other
def __ge__(self, other: object) -> bool:
return not self < other
def __str__(self) -> str:
return self.version_str
def __repr__(self) -> str:
return f"SimpleVersion('{self.version_str}')"
@dataclass(**_DC_KW)
class SimpleSpecifier:
"""
Simple PEP 440-like version specifier using only standard library.
:param spec_str: the original specifier string (e.g. ``>=3.10``).
:param operator: the comparison operator (``==``, ``>=``, ``<``, etc.).
:param version_str: the version portion of the specifier, without the operator.
:param is_wildcard: ``True`` if the specifier uses a wildcard suffix (``.*``).
:param wildcard_precision: number of version components before the wildcard, or ``None``.
:param version: the parsed version, or ``None`` if parsing failed.
"""
spec_str: str
operator: str
version_str: str
is_wildcard: bool
wildcard_precision: int | None
version: SimpleVersion | None
@classmethod
def from_string(cls, spec_str: str) -> SimpleSpecifier:
"""
Parse a single PEP 440 specifier (e.g. ``>=3.10``).
:param spec_str: the specifier string to parse.
"""
stripped = spec_str.strip()
if not (match := _SPECIFIER_RE.match(stripped)):
msg = f"Invalid specifier: {spec_str}"
raise ValueError(msg)
op = match.group(1)
version_str = match.group(2).strip()
is_wildcard = version_str.endswith(".*")
wildcard_precision: int | None = None
if is_wildcard:
version_str = version_str[:-2]
wildcard_precision = len(version_str.split("."))
try:
version = SimpleVersion.from_string(version_str)
except ValueError:
version = None
return cls(
spec_str=stripped,
operator=op,
version_str=version_str,
is_wildcard=is_wildcard,
wildcard_precision=wildcard_precision,
version=version,
)
def contains(self, version_str: str) -> bool:
"""
Check if a version string satisfies this specifier.
:param version_str: the version string to test.
"""
try:
candidate = SimpleVersion.from_string(version_str) if isinstance(version_str, str) else version_str
except ValueError:
return False
if self.version is None:
return False
if self.is_wildcard:
return self._check_wildcard(candidate)
return self._check_standard(candidate)
def _check_wildcard(self, candidate: SimpleVersion) -> bool:
if self.version is None: # pragma: no branch
return False # pragma: no cover
if self.operator == "==":
return candidate.release[: self.wildcard_precision] == self.version.release[: self.wildcard_precision]
if self.operator == "!=":
return candidate.release[: self.wildcard_precision] != self.version.release[: self.wildcard_precision]
return False
def _check_standard(self, candidate: SimpleVersion) -> bool:
if self.version is None: # pragma: no branch
return False # pragma: no cover
if self.operator == "===":
return str(candidate) == str(self.version)
if self.operator == "~=":
return self._check_compatible_release(candidate)
cmp_ops = {
"==": operator.eq,
"!=": operator.ne,
"<": operator.lt,
"<=": operator.le,
">": operator.gt,
">=": operator.ge,
}
if self.operator in cmp_ops:
return cmp_ops[self.operator](candidate, self.version)
return False
def _check_compatible_release(self, candidate: SimpleVersion) -> bool:
if self.version is None:
return False
if candidate < self.version:
return False
if len(self.version.release) >= 2: # noqa: PLR2004 # pragma: no branch # SimpleVersion always has 3-part release
upper_parts = list(self.version.release[:-1])
upper_parts[-1] += 1
upper = SimpleVersion.from_string(".".join(str(p) for p in upper_parts))
return candidate < upper
return True # pragma: no cover
def __eq__(self, other: object) -> bool:
if not isinstance(other, SimpleSpecifier):
return NotImplemented
return self.spec_str == other.spec_str
def __hash__(self) -> int:
return hash(self.spec_str)
def __str__(self) -> str:
return self.spec_str
def __repr__(self) -> str:
return f"SimpleSpecifier('{self.spec_str}')"
@dataclass(**_DC_KW)
class SimpleSpecifierSet:
"""
Simple PEP 440-like specifier set using only standard library.
:param specifiers_str: the original comma-separated specifier string.
:param specifiers: the parsed individual specifiers.
"""
specifiers_str: str
specifiers: tuple[SimpleSpecifier, ...]
@classmethod
def from_string(cls, specifiers_str: str = "") -> SimpleSpecifierSet:
"""
Parse a comma-separated PEP 440 specifier string (e.g. ``>=3.10,<4``).
:param specifiers_str: the specifier string to parse.
"""
stripped = specifiers_str.strip()
specs: list[SimpleSpecifier] = []
if stripped:
for spec_item in stripped.split(","):
item = spec_item.strip()
if item:
with contextlib.suppress(ValueError):
specs.append(SimpleSpecifier.from_string(item))
return cls(specifiers_str=stripped, specifiers=tuple(specs))
def contains(self, version_str: str) -> bool:
"""
Check if a version satisfies all specifiers in the set.
:param version_str: the version string to test.
"""
if not self.specifiers:
return True
return all(spec.contains(version_str) for spec in self.specifiers)
def __iter__(self) -> Iterator[SimpleSpecifier]:
return iter(self.specifiers)
def __eq__(self, other: object) -> bool:
if not isinstance(other, SimpleSpecifierSet):
return NotImplemented
return self.specifiers_str == other.specifiers_str
def __hash__(self) -> int:
return hash(self.specifiers_str)
def __str__(self) -> str:
return self.specifiers_str
def __repr__(self) -> str:
return f"SimpleSpecifierSet('{self.specifiers_str}')"
__all__ = [
"SimpleSpecifier",
"SimpleSpecifierSet",
"SimpleVersion",
]
python_discovery-1.2.2/src/python_discovery/py.typed 0000644 0000000 0000000 00000000000 13615410400 017730 0 ustar 00 python_discovery-1.2.2/src/python_discovery/_windows/__init__.py 0000644 0000000 0000000 00000000473 13615410400 022211 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.2.2/src/python_discovery/_windows/_pep514.py 0000644 0000000 0000000 00000016752 13615410400 021636 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 typing import TYPE_CHECKING, Any, Final
if TYPE_CHECKING:
from collections.abc import Generator
_RegistrySpec = tuple[str, int | None, int | None, int, bool, str, str | None]
_LOGGER: Final[logging.Logger] = getLogger(__name__)
_ARCH_RE: Final[re.Pattern[str]] = re.compile(
r"""
^
(\d+) # bitness number
bit # literal suffix
$
""",
re.VERBOSE,
)
_VERSION_RE: Final[re.Pattern[str]] = re.compile(
r"""
^
(\d+) # major
(?:\.(\d+))? # optional minor
(?:\.(\d+))? # optional micro
$
""",
re.VERBOSE,
)
_THREADED_TAG_RE: Final[re.Pattern[str]] = re.compile(
r"""
^
\d+ # major
(\.\d+){0,2} # optional minor/micro
t # free-threaded flag
$
""",
re.VERBOSE | re.IGNORECASE,
)
def enum_keys(key: Any) -> Generator[str, None, None]: # noqa: ANN401
at = 0
while True:
try:
yield winreg.EnumKey(key, at) # ty: ignore[unresolved-attribute]
except OSError:
break
at += 1
def get_value(key: Any, value_name: str | None) -> Any: # noqa: ANN401
try:
return winreg.QueryValueEx(key, value_name)[0] # ty: ignore[unresolved-attribute]
except OSError:
return None
def discover_pythons() -> Generator[_RegistrySpec, None, None]:
for hive, hive_name, key, flags, default_arch in [
(winreg.HKEY_CURRENT_USER, "HKEY_CURRENT_USER", r"Software\Python", 0, 64), # ty: ignore[unresolved-attribute]
(winreg.HKEY_LOCAL_MACHINE, "HKEY_LOCAL_MACHINE", r"Software\Python", winreg.KEY_WOW64_64KEY, 64), # ty: ignore[unresolved-attribute]
(winreg.HKEY_LOCAL_MACHINE, "HKEY_LOCAL_MACHINE", r"Software\Python", winreg.KEY_WOW64_32KEY, 32), # ty: ignore[unresolved-attribute]
]:
yield from process_set(hive, hive_name, key, flags, default_arch)
def process_set(
hive: int,
hive_name: str,
key: str,
flags: int,
default_arch: int,
) -> Generator[_RegistrySpec, None, None]:
try:
with winreg.OpenKeyEx(hive, key, 0, winreg.KEY_READ | flags) as root_key: # ty: ignore[unresolved-attribute]
for company in enum_keys(root_key):
if company == "PyLauncher": # reserved
continue
yield from process_company(hive_name, company, root_key, default_arch)
except OSError:
pass
def process_company(
hive_name: str,
company: str,
root_key: Any, # noqa: ANN401
default_arch: int,
) -> Generator[_RegistrySpec, None, None]:
with winreg.OpenKeyEx(root_key, company) as company_key: # ty: ignore[unresolved-attribute]
for tag in enum_keys(company_key):
spec = process_tag(hive_name, company, company_key, tag, default_arch)
if spec is not None:
yield spec
def process_tag(hive_name: str, company: str, company_key: Any, tag: str, default_arch: int) -> _RegistrySpec | None: # noqa: ANN401
with winreg.OpenKeyEx(company_key, tag) as tag_key: # ty: ignore[unresolved-attribute]
version = load_version_data(hive_name, company, tag, tag_key)
if version is not None: # if failed to get version bail
major, minor, _ = version
arch = load_arch_data(hive_name, company, tag, tag_key, default_arch)
if arch is not None:
exe_data = load_exe(hive_name, company, company_key, tag)
if exe_data is not None:
exe, args = exe_data
threaded = load_threaded(hive_name, company, tag, tag_key)
return company, major, minor, arch, threaded, exe, args
return None
return None
return None
def load_exe(hive_name: str, company: str, company_key: Any, tag: str) -> tuple[str, str | None] | None: # noqa: ANN401
key_path = f"{hive_name}/{company}/{tag}"
try:
with winreg.OpenKeyEx(company_key, rf"{tag}\InstallPath") as ip_key, ip_key: # ty: ignore[unresolved-attribute]
exe = get_value(ip_key, "ExecutablePath")
if exe is None:
ip = get_value(ip_key, None)
if ip is None:
msg(key_path, "no ExecutablePath or default for it")
else:
exe = os.path.join(ip, "python.exe")
if exe is not None and os.path.exists(exe):
args = get_value(ip_key, "ExecutableArguments")
return exe, args
msg(key_path, f"could not load exe with value {exe}")
except OSError:
msg(f"{key_path}/InstallPath", "missing")
return None
def load_arch_data(hive_name: str, company: str, tag: str, tag_key: Any, default_arch: int) -> int | None: # noqa: ANN401
arch_str = get_value(tag_key, "SysArchitecture")
if arch_str is not None:
key_path = f"{hive_name}/{company}/{tag}/SysArchitecture"
try:
return parse_arch(arch_str)
except ValueError as sys_arch:
msg(key_path, sys_arch)
return default_arch
def parse_arch(arch_str: Any) -> int: # noqa: ANN401
if isinstance(arch_str, str):
if match := _ARCH_RE.match(arch_str):
return int(next(iter(match.groups())))
error = f"invalid format {arch_str}"
else:
error = f"arch is not string: {arch_str!r}"
raise ValueError(error)
def load_version_data(
hive_name: str,
company: str,
tag: str,
tag_key: Any, # noqa: ANN401
) -> tuple[int | None, int | None, int | None] | None:
for candidate, key_path in [
(get_value(tag_key, "SysVersion"), f"{hive_name}/{company}/{tag}/SysVersion"),
(tag, f"{hive_name}/{company}/{tag}"),
]:
if candidate is not None:
try:
return parse_version(candidate)
except ValueError as sys_version:
msg(key_path, sys_version)
return None
def parse_version(version_str: Any) -> tuple[int | None, int | None, int | None]: # noqa: ANN401
if isinstance(version_str, str):
if match := _VERSION_RE.match(version_str):
g1, g2, g3 = match.groups()
return (
int(g1) if g1 is not None else None,
int(g2) if g2 is not None else None,
int(g3) if g3 is not None else None,
)
error = f"invalid format {version_str}"
else:
error = f"version is not string: {version_str!r}"
raise ValueError(error)
def load_threaded(hive_name: str, company: str, tag: str, tag_key: Any) -> bool: # noqa: ANN401
display_name = get_value(tag_key, "DisplayName")
if display_name is not None:
if isinstance(display_name, str):
if "freethreaded" in display_name.lower():
return True
else:
key_path = f"{hive_name}/{company}/{tag}/DisplayName"
msg(key_path, f"display name is not string: {display_name!r}")
return bool(_THREADED_TAG_RE.match(tag))
def msg(path: str, what: object) -> None:
_LOGGER.warning("PEP-514 violation in Windows Registry at %s error: %s", path, what)
def _run() -> None:
basicConfig()
interpreters = [repr(spec) for spec in discover_pythons()]
sys.stdout.write("\n".join(sorted(interpreters)))
sys.stdout.write("\n")
if __name__ == "__main__":
_run()
python_discovery-1.2.2/src/python_discovery/_windows/_propose.py 0000644 0000000 0000000 00000003035 13615410400 022275 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, _ in existing:
implementation = _IMPLEMENTATION_BY_ORG.get(name, name)
skip_pre_filter = implementation.lower() != "cpython"
registry_spec = PythonSpec("", implementation, major, minor, None, arch, exe, free_threaded=threaded)
if skip_pre_filter or registry_spec.satisfies(spec):
interpreter = Pep514PythonInfo.from_exe(exe, cache, env=env, raise_on_error=False)
if interpreter is not None and interpreter.satisfies(spec, impl_must_match=True):
yield interpreter
__all__ = [
"Pep514PythonInfo",
"propose_interpreters",
]
python_discovery-1.2.2/tests/conftest.py 0000644 0000000 0000000 00000001545 13615410400 015412 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.2.2/tests/test_cache.py 0000644 0000000 0000000 00000006410 13615410400 015663 0 ustar 00 from __future__ import annotations
from pathlib import Path
from python_discovery._cache import DiskCache, DiskContentStore, NoOpCache, NoOpContentStore
def test_disk_content_store_read_valid_json(tmp_path: Path) -> None:
store = DiskContentStore(tmp_path, "test")
data = {"key": "value"}
store.write(data)
assert store.read() == data
def test_disk_content_store_read_invalid_json(tmp_path: Path) -> None:
store = DiskContentStore(tmp_path, "test")
tmp_path.mkdir(parents=True, exist_ok=True)
(tmp_path / "test.json").write_text("not json", encoding="utf-8")
assert store.read() is None
assert not (tmp_path / "test.json").exists()
def test_disk_content_store_read_missing_file(tmp_path: Path) -> None:
store = DiskContentStore(tmp_path, "test")
assert store.read() is None
def test_disk_content_store_remove(tmp_path: Path) -> None:
store = DiskContentStore(tmp_path, "test")
store.write({"key": "value"})
assert store.exists()
store.remove()
assert not store.exists()
def test_disk_content_store_remove_missing_file(tmp_path: Path) -> None:
store = DiskContentStore(tmp_path, "test")
store.remove()
def test_disk_content_store_locked(tmp_path: Path) -> None:
store = DiskContentStore(tmp_path, "test")
with store.locked():
store.write({"key": "value"})
assert store.read() == {"key": "value"}
def test_disk_content_store_exists(tmp_path: Path) -> None:
store = DiskContentStore(tmp_path, "test")
assert store.exists() is False
store.write({"key": "value"})
assert store.exists() is True
def test_disk_cache_py_info(tmp_path: Path) -> None:
cache = DiskCache(tmp_path)
store = cache.py_info(Path("/some/path"))
assert isinstance(store, DiskContentStore)
def test_disk_cache_py_info_clear(tmp_path: Path) -> None:
cache = DiskCache(tmp_path)
store = cache.py_info(Path("/some/path"))
store.write({"key": "value"})
assert store.exists()
cache.py_info_clear()
assert not store.exists()
def test_disk_cache_py_info_clear_empty(tmp_path: Path) -> None:
cache = DiskCache(tmp_path)
cache.py_info_clear()
def test_disk_cache_py_info_clear_skips_non_json(tmp_path: Path) -> None:
cache = DiskCache(tmp_path)
py_info_dir = tmp_path / "py_info" / "4"
py_info_dir.mkdir(parents=True)
(py_info_dir / "test.json").write_text("{}", encoding="utf-8")
(py_info_dir / "notjson.txt").touch()
cache.py_info_clear()
remaining = [entry.name for entry in py_info_dir.iterdir()]
assert "notjson.txt" in remaining
assert "test.json" not in remaining
def test_noop_content_store_exists() -> None:
assert NoOpContentStore().exists() is False
def test_noop_content_store_read() -> None:
assert NoOpContentStore().read() is None
def test_noop_content_store_write() -> None:
NoOpContentStore().write({"key": "value"})
def test_noop_content_store_remove() -> None:
NoOpContentStore().remove()
def test_noop_content_store_locked() -> None:
with NoOpContentStore().locked():
pass
def test_noop_cache_py_info() -> None:
cache = NoOpCache()
store = cache.py_info(Path("/some/path"))
assert isinstance(store, NoOpContentStore)
def test_noop_cache_py_info_clear() -> None:
NoOpCache().py_info_clear()
python_discovery-1.2.2/tests/test_cached_py_info.py 0000644 0000000 0000000 00000020631 13615410400 017553 0 ustar 00 from __future__ import annotations
import json
import logging
import os
import sys
from pathlib import Path
from subprocess import TimeoutExpired
from typing import TYPE_CHECKING
from unittest.mock import MagicMock, patch
import pytest
from python_discovery import DiskCache, PythonInfo
from python_discovery._cached_py_info import (
LogCmd,
_get_via_file_cache,
_load_cached_py_info,
_resolve_py_info_script,
_run_subprocess,
gen_cookie,
)
if TYPE_CHECKING:
from pytest_mock import MockerFixture
def test_gen_cookie_length() -> None:
cookie = gen_cookie()
assert len(cookie) == 32
def test_log_cmd_repr() -> None:
cmd = LogCmd(["python", "-c", "print('hello')"])
assert "python" in repr(cmd)
assert cmd.env is None
def test_log_cmd_repr_with_env() -> None:
cmd = LogCmd(["python", "-c", "print('hello')"], env={"FOO": "bar"})
result = repr(cmd)
assert "python" in result
assert "env of" in result
assert "FOO" in result
def test_resolve_py_info_script_file_exists() -> None:
with _resolve_py_info_script() as script:
assert script.exists()
assert script.name == "_py_info.py"
def test_resolve_py_info_script_fallback_to_pkgutil(mocker: MockerFixture) -> None:
mocker.patch("python_discovery._cached_py_info.Path.is_file", return_value=False)
mocker.patch("pkgutil.get_data", return_value=b"# mock script")
with _resolve_py_info_script() as script:
assert script.exists()
content = script.read_text(encoding="utf-8")
assert content == "# mock script"
assert not script.exists()
def test_resolve_py_info_script_pkgutil_returns_none(mocker: MockerFixture) -> None:
mocker.patch("python_discovery._cached_py_info.Path.is_file", return_value=False)
mocker.patch("pkgutil.get_data", return_value=None)
with pytest.raises(FileNotFoundError, match="cannot locate"), _resolve_py_info_script():
pass # pragma: no cover
def test_run_subprocess_success() -> None:
failure, result = _run_subprocess(PythonInfo, sys.executable, dict(os.environ))
assert failure is None
assert result is not None
assert isinstance(result, PythonInfo)
def test_run_subprocess_bad_exe() -> None:
failure, result = _run_subprocess(PythonInfo, "/nonexistent/python", dict(os.environ))
assert failure is not None
assert result is None
assert isinstance(failure, RuntimeError)
def test_run_subprocess_invalid_json(mocker: MockerFixture) -> None:
mock_process = MagicMock()
mock_process.communicate.return_value = ("not json", "")
mock_process.returncode = 0
mocker.patch("python_discovery._cached_py_info.Popen", return_value=mock_process)
failure, result = _run_subprocess(PythonInfo, sys.executable, dict(os.environ))
assert failure is not None
assert result is None
def test_run_subprocess_with_cookies(mocker: MockerFixture) -> None:
start_cookie = "a" * 32
end_cookie = "b" * 32
payload = json.dumps(PythonInfo().to_dict())
out = f"pre{start_cookie[::-1]}{payload}{end_cookie[::-1]}post"
mock_process = MagicMock()
mock_process.communicate.return_value = (out, "")
mock_process.returncode = 0
mocker.patch("python_discovery._cached_py_info.Popen", return_value=mock_process)
mocker.patch("python_discovery._cached_py_info.gen_cookie", side_effect=[start_cookie, end_cookie])
with patch("sys.stdout") as mock_stdout:
failure, result = _run_subprocess(PythonInfo, sys.executable, dict(os.environ))
assert failure is None
assert result is not None
assert mock_stdout.write.call_count == 2
def test_run_subprocess_timeout(mocker: MockerFixture) -> None:
mock_process = MagicMock()
mock_process.communicate.side_effect = [TimeoutExpired(cmd="python", timeout=30), ("", "")]
mocker.patch("python_discovery._cached_py_info.Popen", return_value=mock_process)
failure, result = _run_subprocess(PythonInfo, sys.executable, dict(os.environ))
assert failure is not None
assert "timed out" in str(failure)
assert result is None
mock_process.kill.assert_called_once()
assert mock_process.communicate.call_count == 2
def test_run_subprocess_custom_timeout(mocker: MockerFixture) -> None:
mock_process = MagicMock()
mock_process.communicate.return_value = (json.dumps(PythonInfo().to_dict()), "")
mock_process.returncode = 0
mocker.patch("python_discovery._cached_py_info.Popen", return_value=mock_process)
env = dict(os.environ)
env["PY_DISCOVERY_TIMEOUT"] = "30"
_run_subprocess(PythonInfo, sys.executable, env)
mock_process.communicate.assert_called_once_with(timeout=30.0)
def test_run_subprocess_nonzero_exit(mocker: MockerFixture) -> None:
mock_process = MagicMock()
mock_process.communicate.return_value = ("some output", "some error")
mock_process.returncode = 1
mocker.patch("python_discovery._cached_py_info.Popen", return_value=mock_process)
failure, result = _run_subprocess(PythonInfo, sys.executable, dict(os.environ))
assert failure is not None
assert "failed to query" in str(failure)
assert result is None
def test_load_cached_py_info_valid() -> None:
store = MagicMock()
content = PythonInfo().to_dict()
result = _load_cached_py_info(PythonInfo, store, content)
assert result is not None
assert isinstance(result, PythonInfo)
def test_load_cached_py_info_bad_data() -> None:
store = MagicMock()
result = _load_cached_py_info(PythonInfo, store, {"bad": "data"})
assert result is None
store.remove.assert_called_once()
def test_load_cached_py_info_system_exe_missing(mocker: MockerFixture) -> None:
store = MagicMock()
content = PythonInfo().to_dict()
content["system_executable"] = "/nonexistent/python"
mocker.patch("os.path.exists", return_value=False)
result = _load_cached_py_info(PythonInfo, store, content)
assert result is None
store.remove.assert_called_once()
def test_get_via_file_cache_uses_cached(tmp_path: Path) -> None:
cache = DiskCache(tmp_path)
path = Path(sys.executable)
env = dict(os.environ)
result1 = _get_via_file_cache(PythonInfo, cache, path, sys.executable, env)
assert isinstance(result1, PythonInfo)
result2 = _get_via_file_cache(PythonInfo, cache, path, sys.executable, env)
assert isinstance(result2, PythonInfo)
def test_get_via_file_cache_stale_hash(tmp_path: Path) -> None:
cache = DiskCache(tmp_path)
path = Path(sys.executable)
env = dict(os.environ)
result1 = _get_via_file_cache(PythonInfo, cache, path, sys.executable, env)
assert isinstance(result1, PythonInfo)
store = cache.py_info(path)
data = store.read()
assert data is not None
data["hash"] = "stale_hash"
store.write(data)
result2 = _get_via_file_cache(PythonInfo, cache, path, sys.executable, env)
assert isinstance(result2, PythonInfo)
def test_get_via_file_cache_nonexistent_path(tmp_path: Path) -> None:
cache = DiskCache(tmp_path)
path = Path(tmp_path / "nonexistent")
env = dict(os.environ)
result = _get_via_file_cache(PythonInfo, cache, path, str(path), env)
assert isinstance(result, (PythonInfo, Exception))
def test_from_exe_retry_on_first_failure(
tmp_path: Path, mocker: MockerFixture, caplog: pytest.LogCaptureFixture
) -> None:
caplog.set_level(logging.DEBUG)
cache = DiskCache(tmp_path)
error = RuntimeError("fail")
mocker.patch(
"python_discovery._cached_py_info._run_subprocess",
side_effect=[(error, None), (None, PythonInfo())],
)
result = _get_via_file_cache(PythonInfo, cache, Path("/fake"), "/fake", dict(os.environ))
assert isinstance(result, PythonInfo)
assert any("retrying" in r.message for r in caplog.records)
def test_get_via_file_cache_hash_oserror(tmp_path: Path, mocker: MockerFixture) -> None:
cache = DiskCache(tmp_path)
mocker.patch("python_discovery._cached_py_info.Path.read_bytes", side_effect=OSError("permission denied"))
result = _get_via_file_cache(PythonInfo, cache, Path(sys.executable), sys.executable, dict(os.environ))
assert isinstance(result, PythonInfo)
def test_get_via_file_cache_py_info_none(tmp_path: Path, mocker: MockerFixture) -> None:
cache = DiskCache(tmp_path)
mocker.patch(
"python_discovery._cached_py_info._run_subprocess",
return_value=(None, None),
)
result = _get_via_file_cache(PythonInfo, cache, Path("/fake"), "/fake", dict(os.environ))
assert isinstance(result, RuntimeError)
python_discovery-1.2.2/tests/test_discovery.py 0000644 0000000 0000000 00000043126 13615410400 016634 0 ustar 00 from __future__ import annotations
import logging
import os
import stat
import subprocess
import sys
from pathlib import Path
from typing import TYPE_CHECKING
from unittest.mock import patch
from uuid import uuid4
import pytest
from python_discovery import DiskCache, PythonInfo, get_interpreter
from python_discovery._discovery import IS_WIN, LazyPathDump, get_paths
if TYPE_CHECKING:
from pytest_mock import MockerFixture
@pytest.mark.graalpy
@pytest.mark.skipif(not Path(sys.executable).is_symlink() and not Path(sys.executable).is_file(), reason="no symlink")
@pytest.mark.parametrize("case", ["mixed", "lower", "upper"])
@pytest.mark.parametrize("specificity", ["more", "less", "none"])
def test_discovery_via_path(
monkeypatch: pytest.MonkeyPatch,
case: str,
specificity: str,
*,
tmp_path: Path,
caplog: pytest.LogCaptureFixture,
session_cache: DiskCache,
) -> None:
caplog.set_level(logging.DEBUG)
current = PythonInfo.current_system(session_cache)
name = "somethingVeryCryptic"
threaded = "t" if current.free_threaded else ""
if case == "lower":
name = name.lower()
elif case == "upper":
name = name.upper()
if specificity == "more":
core_ver = current.version_info.major
exe_ver = ".".join(str(i) for i in current.version_info[0:2]) + threaded
elif specificity == "less":
core_ver = ".".join(str(i) for i in current.version_info[0:3])
exe_ver = current.version_info.major
elif specificity == "none": # pragma: no branch
core_ver = ".".join(str(i) for i in current.version_info[0:3])
exe_ver = ""
core = "" if specificity == "none" else f"{name}{core_ver}{threaded}"
exe_name = f"{name}{exe_ver}{'.exe' if sys.platform == 'win32' else ''}"
target = tmp_path / current.install_path("scripts")
target.mkdir(parents=True)
executable = target / exe_name
Path(str(executable)).symlink_to(sys.executable)
pyvenv_cfg = Path(sys.executable).parents[1] / "pyvenv.cfg"
if pyvenv_cfg.exists(): # pragma: no branch
(target / pyvenv_cfg.name).write_bytes(pyvenv_cfg.read_bytes())
new_path = os.pathsep.join([str(target), *os.environ.get("PATH", "").split(os.pathsep)])
monkeypatch.setenv("PATH", new_path)
interpreter = get_interpreter(core, [])
assert interpreter is not None
def test_discovery_via_path_not_found(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("PATH", str(tmp_path))
interpreter = get_interpreter(uuid4().hex, [])
assert interpreter is None
def test_discovery_via_path_in_nonbrowseable_directory(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
bad_perm = tmp_path / "bad_perm"
bad_perm.mkdir(mode=0o000)
monkeypatch.setenv("PATH", str(bad_perm))
interpreter = get_interpreter(uuid4().hex, [])
assert interpreter is None
monkeypatch.setenv("PATH", str(bad_perm / "bin"))
interpreter = get_interpreter(uuid4().hex, [])
assert interpreter is None
def test_relative_path(session_cache: DiskCache, monkeypatch: pytest.MonkeyPatch) -> None:
sys_executable = Path(PythonInfo.current_system(session_cache).system_executable)
cwd = sys_executable.parents[1]
monkeypatch.chdir(str(cwd))
relative = str(sys_executable.relative_to(cwd))
result = get_interpreter(relative, [], session_cache)
assert result is not None
def test_uv_python(
monkeypatch: pytest.MonkeyPatch, tmp_path_factory: pytest.TempPathFactory, mocker: MockerFixture
) -> None:
monkeypatch.delenv("UV_PYTHON_INSTALL_DIR", raising=False)
monkeypatch.delenv("XDG_DATA_HOME", raising=False)
monkeypatch.setenv("PATH", "")
mocker.patch.object(PythonInfo, "satisfies", return_value=False)
uv_python_install_dir = tmp_path_factory.mktemp("uv_python_install_dir")
with patch("python_discovery._discovery.PathPythonInfo.from_exe") as mock_from_exe, monkeypatch.context() as m:
m.setenv("UV_PYTHON_INSTALL_DIR", str(uv_python_install_dir))
get_interpreter("python", [])
mock_from_exe.assert_not_called()
bin_path = uv_python_install_dir.joinpath("some-py-impl", "bin")
bin_path.mkdir(parents=True)
bin_path.joinpath("python").touch()
get_interpreter("python", [])
mock_from_exe.assert_called_once()
assert mock_from_exe.call_args[0][0] == str(bin_path / "python")
mock_from_exe.reset_mock()
python_exe = "python.exe" if IS_WIN else "python"
dir_in_path = tmp_path_factory.mktemp("path_bin_dir")
dir_in_path.joinpath(python_exe).touch()
m.setenv("PATH", str(dir_in_path))
get_interpreter("python", [])
mock_from_exe.assert_called_once()
assert mock_from_exe.call_args[0][0] == str(dir_in_path / python_exe)
xdg_data_home = tmp_path_factory.mktemp("xdg_data_home")
with patch("python_discovery._discovery.PathPythonInfo.from_exe") as mock_from_exe, monkeypatch.context() as m:
m.setenv("XDG_DATA_HOME", str(xdg_data_home))
get_interpreter("python", [])
mock_from_exe.assert_not_called()
bin_path = xdg_data_home.joinpath("uv", "python", "some-py-impl", "bin")
bin_path.mkdir(parents=True)
bin_path.joinpath("python").touch()
get_interpreter("python", [])
mock_from_exe.assert_called_once()
assert mock_from_exe.call_args[0][0] == str(bin_path / "python")
user_data_path = tmp_path_factory.mktemp("user_data_path")
with patch("python_discovery._discovery.PathPythonInfo.from_exe") as mock_from_exe, monkeypatch.context() as m:
m.setattr("python_discovery._discovery.user_data_path", lambda x: user_data_path / x)
get_interpreter("python", [])
mock_from_exe.assert_not_called()
bin_path = user_data_path.joinpath("uv", "python", "some-py-impl", "bin")
bin_path.mkdir(parents=True)
bin_path.joinpath("python").touch()
get_interpreter("python", [])
mock_from_exe.assert_called_once()
assert mock_from_exe.call_args[0][0] == str(bin_path / "python")
def test_discovery_fallback_fail(session_cache: DiskCache, caplog: pytest.LogCaptureFixture) -> None:
caplog.set_level(logging.DEBUG)
result = get_interpreter(["magic-one", "magic-two"], cache=session_cache)
assert result is None
assert "accepted" not in caplog.text
def test_discovery_fallback_ok(session_cache: DiskCache, caplog: pytest.LogCaptureFixture) -> None:
caplog.set_level(logging.DEBUG)
result = get_interpreter(["magic-one", sys.executable], cache=session_cache)
assert result is not None, caplog.text
assert result.executable == sys.executable, caplog.text
assert "accepted" in caplog.text
@pytest.fixture
def mock_find_interpreter(mocker: MockerFixture) -> None:
mocker.patch(
"python_discovery._discovery._find_interpreter",
lambda key, *_args, **_kwargs: getattr(mocker.sentinel, key),
)
@pytest.mark.usefixtures("mock_find_interpreter")
def test_returns_first_python_specified(mocker: MockerFixture) -> None:
result = get_interpreter(["python_from_cli"])
assert result == mocker.sentinel.python_from_cli
def test_discovery_absolute_path_with_try_first(
tmp_path: Path,
session_cache: DiskCache,
) -> None:
good_env = tmp_path / "good"
bad_env = tmp_path / "bad"
subprocess.check_call([sys.executable, "-m", "venv", str(good_env)])
subprocess.check_call([sys.executable, "-m", "venv", str(bad_env)])
scripts_dir = "Scripts" if IS_WIN else "bin"
exe_name = "python.exe" if IS_WIN else "python"
good_exe = good_env / scripts_dir / exe_name
bad_exe = bad_env / scripts_dir / exe_name
interpreter = get_interpreter(
str(good_exe),
try_first_with=[str(bad_exe)],
cache=session_cache,
)
assert interpreter is not None
assert Path(interpreter.executable) == good_exe
def test_discovery_via_path_with_file(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
a_file = tmp_path / "a_file"
a_file.touch()
monkeypatch.setenv("PATH", str(a_file))
interpreter = get_interpreter(uuid4().hex, [])
assert interpreter is None
def test_get_paths_no_path_env(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("PATH", raising=False)
paths = list(get_paths({}))
assert paths
def test_lazy_path_dump_debug(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
monkeypatch.setenv("_VIRTUALENV_DEBUG", "1")
a_dir = tmp_path
executable_file = "a_file.exe" if IS_WIN else "a_file"
(a_dir / executable_file).touch(mode=0o755)
(a_dir / "b_file").touch(mode=0o644)
dumper = LazyPathDump(0, a_dir, os.environ)
output = repr(dumper)
assert executable_file in output
assert "b_file" not in output
def test_discovery_via_version_specifier(session_cache: DiskCache) -> None:
current = PythonInfo.current_system(session_cache)
major, minor = current.version_info.major, current.version_info.minor
spec = f">={major}.{minor}"
interpreter = get_interpreter(spec, [], session_cache)
assert interpreter is not None
assert interpreter.version_info.major == major
assert interpreter.version_info.minor >= minor
spec = f">={major}.{minor},<{major}.{minor + 10}"
interpreter = get_interpreter(spec, [], session_cache)
assert interpreter is not None
assert interpreter.version_info.major == major
assert minor <= interpreter.version_info.minor < minor + 10
spec = f"cpython>={major}.{minor}"
interpreter = get_interpreter(spec, [], session_cache)
if current.implementation == "CPython": # pragma: no branch
assert interpreter is not None
assert interpreter.implementation == "CPython"
def _create_version_manager(tmp_path: Path, env_var: str) -> Path:
root = tmp_path / env_var.lower()
root.mkdir()
(root / "shims").mkdir()
return root
def _create_versioned_binary(root: Path, versions_path: tuple[str, ...], version: str, exe_name: str) -> Path:
bin_dir = root.joinpath(*versions_path, version, "bin")
bin_dir.mkdir(parents=True, exist_ok=True)
exe = bin_dir / (f"{exe_name}.exe" if IS_WIN else exe_name)
exe.touch()
exe.chmod(exe.stat().st_mode | stat.S_IEXEC)
return exe
@pytest.mark.parametrize(
("env_var", "versions_path"),
[
pytest.param("PYENV_ROOT", ("versions",), id="pyenv"),
pytest.param("MISE_DATA_DIR", ("installs", "python"), id="mise"),
pytest.param("ASDF_DATA_DIR", ("installs", "python"), id="asdf"),
],
)
def test_shim_resolved_to_real_binary(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
env_var: str,
versions_path: tuple[str, ...],
) -> None:
root = _create_version_manager(tmp_path, env_var)
real_binary = _create_versioned_binary(root, versions_path, "2.7.18", "python2.7")
shim = root / "shims" / ("python2.7.exe" if IS_WIN else "python2.7")
shim.touch(mode=0o755)
monkeypatch.setenv("PATH", str(root / "shims"))
monkeypatch.setenv(env_var, str(root))
monkeypatch.setenv("PYENV_VERSION", "2.7.18")
monkeypatch.delenv("MISE_DATA_DIR", raising=False) if env_var != "MISE_DATA_DIR" else None
monkeypatch.delenv("ASDF_DATA_DIR", raising=False) if env_var != "ASDF_DATA_DIR" else None
monkeypatch.delenv("PYENV_ROOT", raising=False) if env_var != "PYENV_ROOT" else None
with patch("python_discovery._discovery.PathPythonInfo.from_exe") as mock_from_exe:
mock_from_exe.return_value = None
get_interpreter("python2.7", [])
assert mock_from_exe.call_args_list[0][0][0] == str(real_binary)
def test_shim_not_resolved_without_version_manager_env(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
shims_dir = tmp_path / "shims"
shims_dir.mkdir()
shim = shims_dir / ("python2.7.exe" if IS_WIN else "python2.7")
shim.touch(mode=0o755)
monkeypatch.setenv("PATH", str(shims_dir))
monkeypatch.delenv("PYENV_ROOT", raising=False)
monkeypatch.delenv("MISE_DATA_DIR", raising=False)
monkeypatch.delenv("ASDF_DATA_DIR", raising=False)
with patch("python_discovery._discovery.PathPythonInfo.from_exe") as mock_from_exe:
mock_from_exe.return_value = None
get_interpreter("python2.7", [])
assert mock_from_exe.call_args_list[0][0][0] == str(shim)
def test_shim_falls_through_when_binary_missing(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
root = _create_version_manager(tmp_path, "PYENV_ROOT")
shim = root / "shims" / ("python2.7.exe" if IS_WIN else "python2.7")
shim.touch(mode=0o755)
monkeypatch.setenv("PATH", str(root / "shims"))
monkeypatch.setenv("PYENV_ROOT", str(root))
monkeypatch.setenv("PYENV_VERSION", "2.7.18")
with patch("python_discovery._discovery.PathPythonInfo.from_exe") as mock_from_exe:
mock_from_exe.return_value = None
get_interpreter("python2.7", [])
assert mock_from_exe.call_args_list[0][0][0] == str(shim)
def test_shim_uses_python_version_file(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
root = _create_version_manager(tmp_path, "PYENV_ROOT")
real_binary = _create_versioned_binary(root, ("versions",), "2.7.18", "python2.7")
shim = root / "shims" / ("python2.7.exe" if IS_WIN else "python2.7")
shim.touch(mode=0o755)
(tmp_path / ".python-version").write_text(encoding="utf-8", data="2.7.18\n")
monkeypatch.setenv("PATH", str(root / "shims"))
monkeypatch.setenv("PYENV_ROOT", str(root))
monkeypatch.delenv("PYENV_VERSION", raising=False)
monkeypatch.chdir(tmp_path)
with patch("python_discovery._discovery.PathPythonInfo.from_exe") as mock_from_exe:
mock_from_exe.return_value = None
get_interpreter("python2.7", [])
assert mock_from_exe.call_args_list[0][0][0] == str(real_binary)
def test_shim_pyenv_version_env_takes_priority_over_file(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
root = _create_version_manager(tmp_path, "PYENV_ROOT")
_create_versioned_binary(root, ("versions",), "2.7.18", "python2.7")
env_binary = _create_versioned_binary(root, ("versions",), "2.7.15", "python2.7")
shim = root / "shims" / ("python2.7.exe" if IS_WIN else "python2.7")
shim.touch(mode=0o755)
(tmp_path / ".python-version").write_text(encoding="utf-8", data="2.7.18\n")
monkeypatch.setenv("PATH", str(root / "shims"))
monkeypatch.setenv("PYENV_ROOT", str(root))
monkeypatch.setenv("PYENV_VERSION", "2.7.15")
monkeypatch.chdir(tmp_path)
with patch("python_discovery._discovery.PathPythonInfo.from_exe") as mock_from_exe:
mock_from_exe.return_value = None
get_interpreter("python2.7", [])
assert mock_from_exe.call_args_list[0][0][0] == str(env_binary)
def test_shim_uses_global_version_file(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
root = _create_version_manager(tmp_path, "PYENV_ROOT")
real_binary = _create_versioned_binary(root, ("versions",), "2.7.18", "python2.7")
shim = root / "shims" / ("python2.7.exe" if IS_WIN else "python2.7")
shim.touch(mode=0o755)
(root / "version").write_text(encoding="utf-8", data="2.7.18\n")
workdir = tmp_path / "workdir"
workdir.mkdir()
monkeypatch.setenv("PATH", str(root / "shims"))
monkeypatch.setenv("PYENV_ROOT", str(root))
monkeypatch.delenv("PYENV_VERSION", raising=False)
monkeypatch.chdir(workdir)
with patch("python_discovery._discovery.PathPythonInfo.from_exe") as mock_from_exe:
mock_from_exe.return_value = None
get_interpreter("python2.7", [])
assert mock_from_exe.call_args_list[0][0][0] == str(real_binary)
def test_shim_colon_separated_pyenv_version_picks_first_match(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
root = _create_version_manager(tmp_path, "PYENV_ROOT")
_create_versioned_binary(root, ("versions",), "2.7.18", "python2.7")
second_binary = _create_versioned_binary(root, ("versions",), "2.7.15", "python2.7")
shim = root / "shims" / ("python2.7.exe" if IS_WIN else "python2.7")
shim.touch(mode=0o755)
monkeypatch.setenv("PATH", str(root / "shims"))
monkeypatch.setenv("PYENV_ROOT", str(root))
monkeypatch.setenv("PYENV_VERSION", "3.9.1:2.7.15")
with patch("python_discovery._discovery.PathPythonInfo.from_exe") as mock_from_exe:
mock_from_exe.return_value = None
get_interpreter("python2.7", [])
assert mock_from_exe.call_args_list[0][0][0] == str(second_binary)
def test_predicate_filters_interpreters(session_cache: DiskCache) -> None:
result = get_interpreter(sys.executable, [], session_cache, predicate=lambda _: False)
assert result is None
def test_predicate_accepts_interpreter(session_cache: DiskCache) -> None:
result = get_interpreter(sys.executable, [], session_cache, predicate=lambda _: True)
assert result is not None
assert result.executable == sys.executable
def test_predicate_none_is_noop(session_cache: DiskCache) -> None:
result = get_interpreter(sys.executable, [], session_cache, predicate=None)
assert result is not None
assert result.executable == sys.executable
def test_predicate_with_fallback_specs(session_cache: DiskCache) -> None:
current = PythonInfo.current_system(session_cache)
major, minor = current.version_info.major, current.version_info.minor
accepted_exe: str | None = None
def reject_first(info: PythonInfo) -> bool:
nonlocal accepted_exe
if accepted_exe is None:
accepted_exe = str(info.executable)
return False
return True
result = get_interpreter([f"{major}.{minor}", sys.executable], [], session_cache, predicate=reject_first)
assert accepted_exe is not None
assert result is not None
assert str(result.executable) != accepted_exe
python_discovery-1.2.2/tests/test_discovery_extra.py 0000644 0000000 0000000 00000020332 13615410400 020031 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.2.2/tests/test_py_info_extra.py 0000644 0000000 0000000 00000044133 13615410400 017472 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
from python_discovery._py_info import VersionInfo
try:
import tkinter as tk # pragma: no cover
except ImportError: # pragma: no cover
tk = None # type: ignore[assignment]
if TYPE_CHECKING:
from pytest_mock import MockerFixture
CURRENT = PythonInfo.current_system()
def test_py_info_pypy_version(mocker: MockerFixture) -> None:
mocker.patch("platform.python_implementation", return_value="PyPy")
mocker.patch.object(sys, "pypy_version_info", (7, 3, 11, "final", 0), create=True)
info = PythonInfo()
assert info.implementation == "PyPy"
assert info.pypy_version_info == (7, 3, 11, "final", 0)
def test_has_venv_attribute() -> None:
info = PythonInfo()
assert isinstance(info.has_venv, bool)
def test_tcl_tk_libs_with_env(mocker: MockerFixture) -> None:
mocker.patch.dict(os.environ, {"TCL_LIBRARY": "/some/path"})
mocker.patch.object(PythonInfo, "_get_tcl_tk_libs", return_value=("/tcl", "/tk"))
info = PythonInfo()
assert info.tcl_lib == "/tcl"
assert info.tk_lib == "/tk"
def test_get_tcl_tk_libs_returns_tuple() -> None:
tcl_path, tk_path = PythonInfo._get_tcl_tk_libs()
assert tcl_path is None or isinstance(tcl_path, str)
assert tk_path is None or isinstance(tk_path, str)
@pytest.mark.skipif(tk is None, reason="tkinter not available")
def test_get_tcl_tk_libs_tcl_error(mocker: MockerFixture) -> None: # pragma: no cover
mock_tcl = MagicMock()
mock_tcl.eval.side_effect = tk.TclError("fail")
mocker.patch("tkinter.Tcl", return_value=mock_tcl)
tcl, _tk = PythonInfo._get_tcl_tk_libs()
assert tcl is None
def test_fast_get_system_executable_not_venv() -> None:
info = PythonInfo()
info.real_prefix = None
info.base_prefix = info.prefix
assert info._fast_get_system_executable() == info.original_executable
def test_fast_get_system_executable_real_prefix() -> None:
info = PythonInfo()
info.real_prefix = "/some/real/prefix"
assert info._fast_get_system_executable() is None
def test_fast_get_system_executable_no_base_executable(mocker: MockerFixture) -> None:
info = PythonInfo()
info.real_prefix = None
info.base_prefix = "/different/prefix"
mocker.patch.object(sys, "_base_executable", None, create=True)
assert info._fast_get_system_executable() is None
def test_fast_get_system_executable_same_as_current(mocker: MockerFixture) -> None:
info = PythonInfo()
info.real_prefix = None
info.base_prefix = "/different/prefix"
mocker.patch.object(sys, "_base_executable", sys.executable, create=True)
assert info._fast_get_system_executable() is None
def test_try_posix_fallback_not_posix() -> None:
info = PythonInfo()
info.os = "nt"
assert info._try_posix_fallback_executable("/some/python") is None
def test_try_posix_fallback_old_python() -> None:
info = PythonInfo()
info.os = "posix"
info.version_info = VersionInfo(3, 10, 0, "final", 0)
assert info._try_posix_fallback_executable("/some/python") is None
def test_try_posix_fallback_finds_versioned(tmp_path: Path) -> None:
info = PythonInfo()
info.os = "posix"
info.version_info = VersionInfo(3, 12, 0, "final", 0)
info.implementation = "CPython"
base_exe = str(tmp_path / "python")
versioned = tmp_path / "python3"
versioned.touch()
result = info._try_posix_fallback_executable(base_exe)
assert result == str(versioned)
def test_try_posix_fallback_pypy(tmp_path: Path) -> None:
info = PythonInfo()
info.os = "posix"
info.version_info = VersionInfo(3, 12, 0, "final", 0)
info.implementation = "PyPy"
base_exe = str(tmp_path / "python")
pypy = tmp_path / "pypy3"
pypy.touch()
result = info._try_posix_fallback_executable(base_exe)
assert result == str(pypy)
def test_try_posix_fallback_not_found(tmp_path: Path) -> None:
info = PythonInfo()
info.os = "posix"
info.version_info = VersionInfo(3, 12, 0, "final", 0)
info.implementation = "CPython"
base_exe = str(tmp_path / "python")
assert info._try_posix_fallback_executable(base_exe) is None
def test_version_str() -> None:
assert CURRENT.version_str == ".".join(str(i) for i in sys.version_info[:3])
def test_version_release_str() -> None:
assert CURRENT.version_release_str == ".".join(str(i) for i in sys.version_info[:2])
def test_python_name() -> None:
assert CURRENT.python_name == f"python{sys.version_info.major}.{sys.version_info.minor}"
def test_is_old_virtualenv() -> None:
info = copy.deepcopy(CURRENT)
info.real_prefix = "/some/prefix"
assert info.is_old_virtualenv is True
info.real_prefix = None
assert info.is_old_virtualenv is False
def test_is_venv() -> None:
assert CURRENT.is_venv == (CURRENT.base_prefix is not None)
def test_system_prefix() -> None:
info = copy.deepcopy(CURRENT)
info.real_prefix = "/real"
assert info.system_prefix == "/real"
info.real_prefix = None
info.base_prefix = "/base"
assert info.system_prefix == "/base"
def test_system_exec_prefix() -> None:
info = copy.deepcopy(CURRENT)
info.real_prefix = "/real"
assert info.system_exec_prefix == "/real"
info.real_prefix = None
assert info.system_exec_prefix == info.base_exec_prefix or info.exec_prefix
def test_repr() -> None:
result = repr(CURRENT)
assert "PythonInfo" in result
def test_str() -> None:
result = str(CURRENT)
assert "PythonInfo" in result
assert "spec=" in result
def test_machine_none_platform() -> None:
info = copy.deepcopy(CURRENT)
info.sysconfig_platform = None
assert info.machine == "unknown"
def test_from_json_round_trip() -> None:
json_str = CURRENT.to_json()
restored = PythonInfo.from_json(json_str)
assert restored.version_info == CURRENT.version_info
assert restored.implementation == CURRENT.implementation
def test_from_dict() -> None:
data = CURRENT.to_dict()
restored = PythonInfo.from_dict(data)
assert restored.version_info == CURRENT.version_info
def test_resolve_to_system_circle(mocker: MockerFixture, caplog: pytest.LogCaptureFixture) -> None:
caplog.set_level(logging.DEBUG)
target = copy.deepcopy(CURRENT)
target.system_executable = None
target.real_prefix = None
target.base_prefix = "/prefix_a"
second = copy.deepcopy(CURRENT)
second.system_executable = None
second.real_prefix = None
second.base_prefix = "/prefix_b"
third = copy.deepcopy(CURRENT)
third.system_executable = None
third.real_prefix = None
third.base_prefix = "/prefix_a"
mocker.patch.object(PythonInfo, "discover_exe", side_effect=[second, third])
with pytest.raises(RuntimeError, match="prefixes are causing a circle"):
PythonInfo.resolve_to_system(None, target)
def test_resolve_to_system_single_prefix_self_link(mocker: MockerFixture, caplog: pytest.LogCaptureFixture) -> None:
caplog.set_level(logging.INFO)
target = copy.deepcopy(CURRENT)
target.system_executable = None
target.real_prefix = None
target.base_prefix = "/prefix_a"
second = copy.deepcopy(CURRENT)
second.system_executable = None
second.real_prefix = None
second.base_prefix = "/prefix_a"
mocker.patch.object(PythonInfo, "discover_exe", return_value=second)
result = PythonInfo.resolve_to_system(None, target)
assert result.system_executable is not None
assert any("links back to itself" in r.message for r in caplog.records)
def test_discover_exe_cache_hit() -> None:
info = copy.deepcopy(CURRENT)
cached = copy.deepcopy(CURRENT)
PythonInfo._cache_exe_discovery["/some/prefix", True] = cached
try:
result = info.discover_exe(MagicMock(), prefix="/some/prefix", exact=True)
assert result is cached
finally:
del PythonInfo._cache_exe_discovery["/some/prefix", True]
def test_check_exe_none_path(tmp_path: Path) -> None:
info = copy.deepcopy(CURRENT)
result = info._check_exe(MagicMock(), str(tmp_path), "nonexistent", [], dict(os.environ), exact=True)
assert result is None
def test_satisfies_version_specifier() -> None:
spec = PythonSpec.from_string_spec(f">={sys.version_info.major}.{sys.version_info.minor}")
assert CURRENT.satisfies(spec, impl_must_match=False) is True
def test_satisfies_version_specifier_fails() -> None:
spec = PythonSpec.from_string_spec(f">{sys.version_info.major + 1}")
assert CURRENT.satisfies(spec, impl_must_match=False) is False
@pytest.mark.parametrize(
("version_info", "spec_str", "expected"),
[
pytest.param(VersionInfo(3, 14, 0, "alpha", 1), ">=3.14.0a1", True, id="alpha_match_exact"),
pytest.param(VersionInfo(3, 14, 0, "beta", 1), ">=3.14.0b1", True, id="beta_match_exact"),
pytest.param(VersionInfo(3, 14, 0, "candidate", 1), ">=3.14.0rc1", True, id="rc_match_exact"),
pytest.param(VersionInfo(3, 15, 0, "alpha", 6), ">=3.15", True, id="prerelease_match_major_minor"),
pytest.param(VersionInfo(3, 15, 0, "alpha", 6), ">=3.15.0", False, id="prerelease_not_match_full_precision"),
pytest.param(VersionInfo(3, 15, 0, "alpha", 5), "<3.15.0a6", True, id="earlier_prerelease_less_than"),
pytest.param(VersionInfo(3, 15, 0, "alpha", 6), "<3.15.0a6", False, id="prerelease_not_less_than_itself"),
pytest.param(VersionInfo(3, 15, 0, "alpha", 6), ">=3.15.0a6", True, id="prerelease_match_itself"),
pytest.param(VersionInfo(3, 15, 0, "alpha", 6), ">=3.15.0a7", False, id="prerelease_not_match_later"),
pytest.param(VersionInfo(3, 15, 0, "final", 0), ">=3.15.0a6", True, id="final_greater_than_prerelease"),
pytest.param(VersionInfo(3, 15, 0, "final", 0), "<3.15.0a6", False, id="final_not_less_than_prerelease"),
pytest.param(VersionInfo(3, 15, 0, "final", 0), ">=3.15", True, id="final_match_major_minor"),
pytest.param(VersionInfo(3, 15, 1, "alpha", 1), ">=3.15.0", True, id="later_micro_prerelease_match"),
],
)
def test_satisfies_version_specifier_prerelease(version_info: VersionInfo, spec_str: str, expected: bool) -> None:
info = copy.deepcopy(CURRENT)
info.version_info = version_info
spec = PythonSpec.from_string_spec(spec_str)
assert info.satisfies(spec, impl_must_match=False) is expected
def test_satisfies_path_not_abs_basename_match() -> None:
info = copy.deepcopy(CURRENT)
basename = Path(info.original_executable).stem
spec = PythonSpec.from_string_spec(basename)
assert info.satisfies(spec, impl_must_match=False) is True
def test_satisfies_path_not_abs_basename_no_match() -> None:
info = copy.deepcopy(CURRENT)
spec = PythonSpec.from_string_spec("completely_different_name")
assert info.satisfies(spec, impl_must_match=False) is False
@pytest.mark.skipif(sys.platform == "win32", reason="win32 tested separately")
def test_satisfies_path_win32(mocker: MockerFixture) -> None:
info = copy.deepcopy(CURRENT)
mocker.patch.object(sys, "platform", "win32")
info.original_executable = "/some/path/python.exe"
spec = PythonSpec.from_string_spec("python")
spec.path = "python"
assert info.satisfies(spec, impl_must_match=False) is True
def test_distutils_install() -> None:
info = PythonInfo()
result = info._distutils_install()
assert isinstance(result, dict)
def test_install_path() -> None:
assert isinstance(CURRENT.install_path("purelib"), str)
def test_system_include() -> None:
result = CURRENT.system_include
assert isinstance(result, str)
def test_system_include_fallback(mocker: MockerFixture) -> None:
info = copy.deepcopy(CURRENT)
mocker.patch("os.path.exists", side_effect=lambda p: "include" not in p or "dist" in p.lower())
result = info.system_include
assert isinstance(result, str)
def test_sysconfig_path_missing_key() -> None:
assert not CURRENT.sysconfig_path("nonexistent_key")
def test_sysconfig_path_with_config_var() -> None:
result = CURRENT.sysconfig_path("stdlib", {})
assert isinstance(result, str)
def test_current_system_cached(session_cache: DiskCache) -> None:
PythonInfo._current_system = None
result1 = PythonInfo.current_system(session_cache)
result2 = PythonInfo.current_system(session_cache)
assert result1 is result2
def test_current_cached(session_cache: DiskCache) -> None:
PythonInfo._current = None
result1 = PythonInfo.current(session_cache)
result2 = PythonInfo.current(session_cache)
assert result1 is result2
def test_from_exe_resolve_error(mocker: MockerFixture, caplog: pytest.LogCaptureFixture) -> None:
caplog.set_level(logging.INFO)
fake_info = PythonInfo()
fake_info.original_executable = "/fake/python"
mocker.patch(
"python_discovery._cached_py_info.from_exe",
return_value=fake_info,
)
mocker.patch.object(PythonInfo, "resolve_to_system", side_effect=RuntimeError("test error"))
result = PythonInfo.from_exe(sys.executable, raise_on_error=False, resolve_to_host=True)
assert result is None
assert any("cannot resolve system" in r.message for r in caplog.records)
def test_sysconfig_path_no_config_var() -> None:
result = CURRENT.sysconfig_path("stdlib")
assert isinstance(result, str)
assert len(result) > 0
def test_satisfies_abs_spec_path_falls_through() -> None:
info = copy.deepcopy(CURRENT)
spec = PythonSpec("", None, None, None, None, None, "/some/other/python")
assert spec.is_abs is True
assert info.satisfies(spec, impl_must_match=False) is True
def test_satisfies_abs_spec_path_match() -> None:
info = copy.deepcopy(CURRENT)
spec = PythonSpec("", None, None, None, None, None, info.executable)
assert info.satisfies(spec, impl_must_match=False) is True
def test_current_returns_none_raises(mocker: MockerFixture) -> None:
PythonInfo._current = None
mocker.patch.object(PythonInfo, "from_exe", return_value=None)
with pytest.raises(RuntimeError, match="failed to query current Python interpreter"):
PythonInfo.current()
PythonInfo._current = None
def test_current_system_returns_none_raises(mocker: MockerFixture) -> None:
PythonInfo._current_system = None
mocker.patch.object(PythonInfo, "from_exe", return_value=None)
with pytest.raises(RuntimeError, match="failed to query current system Python interpreter"):
PythonInfo.current_system()
PythonInfo._current_system = None
def test_check_exe_from_exe_returns_none(tmp_path: Path, mocker: MockerFixture) -> None:
info = copy.deepcopy(CURRENT)
exe = tmp_path / "python"
exe.touch()
mocker.patch.object(PythonInfo, "from_exe", return_value=None)
result = info._check_exe(MagicMock(), str(tmp_path), "python", [], dict(os.environ), exact=True)
assert result is None
def test_check_exe_mismatch_not_exact(tmp_path: Path, mocker: MockerFixture) -> None:
info = copy.deepcopy(CURRENT)
exe = tmp_path / "python"
exe.touch()
other = copy.deepcopy(CURRENT)
other.architecture = 32 if info.architecture == 64 else 64
mocker.patch.object(PythonInfo, "from_exe", return_value=other)
discovered: list[PythonInfo] = []
result = info._check_exe(MagicMock(), str(tmp_path), "python", discovered, dict(os.environ), exact=False)
assert result is None
assert len(discovered) == 1
def test_check_exe_mismatch_exact(tmp_path: Path, mocker: MockerFixture) -> None:
info = copy.deepcopy(CURRENT)
exe = tmp_path / "python"
exe.touch()
other = copy.deepcopy(CURRENT)
other.architecture = 32 if info.architecture == 64 else 64
mocker.patch.object(PythonInfo, "from_exe", return_value=other)
discovered: list[PythonInfo] = []
result = info._check_exe(MagicMock(), str(tmp_path), "python", discovered, dict(os.environ), exact=True)
assert result is None
assert len(discovered) == 0
def test_find_possible_exe_names_free_threaded() -> None:
info = copy.deepcopy(CURRENT)
info.free_threaded = True
names = info._find_possible_exe_names()
assert any("t" in n for n in names)
def test_possible_base_python_basename() -> None:
info = copy.deepcopy(CURRENT)
info.executable = "/usr/bin/python"
info.implementation = "CPython"
names = list(info._possible_base())
assert "python" in names
assert "cpython" in names
def test_possible_base_case_sensitive(mocker: MockerFixture) -> None:
info = copy.deepcopy(CURRENT)
info.executable = "/usr/bin/CPython3.12"
info.implementation = "CPython"
mocker.patch("python_discovery._compat.fs_is_case_sensitive", return_value=True)
names = list(info._possible_base())
lower_names = [n for n in names if n.islower()]
upper_names = [n for n in names if n.isupper()]
assert len(lower_names) >= 1
assert len(upper_names) >= 1
def test_possible_base_case_sensitive_upper_equals_base(mocker: MockerFixture) -> None:
info = copy.deepcopy(CURRENT)
info.executable = "/usr/bin/JYTHON"
info.implementation = "JYTHON"
mocker.patch("python_discovery._compat.fs_is_case_sensitive", return_value=True)
names = list(info._possible_base())
assert "jython" in names
assert "JYTHON" in names
def test_resolve_to_system_resolved_from_exe(mocker: MockerFixture, caplog: pytest.LogCaptureFixture) -> None:
caplog.set_level(logging.DEBUG)
target = copy.deepcopy(CURRENT)
target.system_executable = "/some/system/python"
target.executable = "/some/venv/python"
resolved = copy.deepcopy(CURRENT)
resolved.system_executable = "/some/system/python"
resolved.executable = "/some/system/python"
mocker.patch.object(PythonInfo, "from_exe", return_value=resolved)
result = PythonInfo.resolve_to_system(None, target)
assert result.executable == "/some/venv/python"
def test_resolve_to_system_from_exe_returns_none(mocker: MockerFixture) -> None:
target = copy.deepcopy(CURRENT)
target.system_executable = "/some/system/python"
target.executable = "/some/venv/python"
mocker.patch.object(PythonInfo, "from_exe", return_value=None)
result = PythonInfo.resolve_to_system(None, target)
assert result.executable == "/some/venv/python"
python_discovery-1.2.2/tests/test_py_spec.py 0000644 0000000 0000000 00000022112 13615410400 016257 0 ustar 00 from __future__ import annotations
import sys
from typing import TYPE_CHECKING
import pytest
from python_discovery import PythonSpec
from python_discovery._py_info import normalize_isa
from python_discovery._specifier import SimpleSpecifierSet as SpecifierSet
if TYPE_CHECKING:
from pathlib import Path
def test_bad_py_spec() -> None:
text = "python2.3.4.5"
spec = PythonSpec.from_string_spec(text)
assert text in repr(spec)
assert spec.str_spec == text
assert spec.path == text
content = vars(spec)
del content["str_spec"]
del content["path"]
assert all(v is None for v in content.values())
def test_py_spec_first_digit_only_major() -> None:
spec = PythonSpec.from_string_spec("278")
assert spec.major == 2
assert spec.minor == 78
def test_spec_satisfies_path_ok() -> None:
spec = PythonSpec.from_string_spec(sys.executable)
assert spec.satisfies(spec) is True
def test_spec_satisfies_path_nok(tmp_path: pytest.TempPathFactory) -> None:
spec = PythonSpec.from_string_spec(sys.executable)
of = PythonSpec.from_string_spec(str(tmp_path))
assert spec.satisfies(of) is False
def test_spec_satisfies_arch() -> None:
spec_1 = PythonSpec.from_string_spec("python-32")
spec_2 = PythonSpec.from_string_spec("python-64")
assert spec_1.satisfies(spec_1) is True
assert spec_2.satisfies(spec_1) is False
def test_spec_satisfies_free_threaded() -> None:
spec_1 = PythonSpec.from_string_spec("python3.13t")
spec_2 = PythonSpec.from_string_spec("python3.13")
assert spec_1.satisfies(spec_1) is True
assert spec_1.free_threaded is True
assert spec_2.satisfies(spec_1) is False
assert spec_2.free_threaded is False
@pytest.mark.parametrize(
("req", "spec"),
[("py", "python"), ("jython", "jython"), ("CPython", "cpython")],
)
def test_spec_satisfies_implementation_ok(req: str, spec: str) -> None:
spec_1 = PythonSpec.from_string_spec(req)
spec_2 = PythonSpec.from_string_spec(spec)
assert spec_1.satisfies(spec_1) is True
assert spec_2.satisfies(spec_1) is True
def test_spec_satisfies_implementation_nok() -> None:
spec_1 = PythonSpec.from_string_spec("cpython")
spec_2 = PythonSpec.from_string_spec("jython")
assert spec_2.satisfies(spec_1) is False
assert spec_1.satisfies(spec_2) is False
def _version_satisfies_pairs() -> list[tuple[str, str]]:
target: set[tuple[str, str]] = set()
version = tuple(str(i) for i in sys.version_info[0:3])
for threading in (False, True):
for depth in range(len(version) + 1):
req = ".".join(version[0:depth])
for sub in range(depth + 1):
sat = ".".join(version[0:sub])
if sat:
target.add((req, sat))
target.add((sat, req))
if threading and sat and req:
target.add((f"{req}t", f"{sat}t"))
target.add((f"{sat}t", f"{req}t"))
return sorted(target)
@pytest.mark.parametrize(("req", "spec"), _version_satisfies_pairs())
def test_version_satisfies_ok(req: str, spec: str) -> None:
req_spec = PythonSpec.from_string_spec(f"python{req}")
sat_spec = PythonSpec.from_string_spec(f"python{spec}")
assert sat_spec.satisfies(req_spec) is True
def _version_not_satisfies_pairs() -> list[tuple[str, str]]:
target: set[tuple[str, str]] = set()
version = tuple(str(i) for i in sys.version_info[0:3])
for major in range(len(version)):
req = ".".join(version[0 : major + 1])
for minor in range(major + 1):
sat_ver: list[int] = [int(v) for v in sys.version_info[0 : minor + 1]]
for patch in range(minor + 1):
for offset in [1, -1]:
temp = sat_ver.copy()
temp[patch] += offset
if temp[patch] < 0:
continue # pragma: no cover
sat = ".".join(str(i) for i in temp)
target.add((req, sat))
return sorted(target)
@pytest.mark.parametrize(("req", "spec"), _version_not_satisfies_pairs())
def test_version_satisfies_nok(req: str, spec: str) -> None:
req_spec = PythonSpec.from_string_spec(f"python{req}")
sat_spec = PythonSpec.from_string_spec(f"python{spec}")
assert sat_spec.satisfies(req_spec) is False
def test_relative_spec(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.chdir(tmp_path)
a_relative_path = str((tmp_path / "a" / "b").relative_to(tmp_path))
spec = PythonSpec.from_string_spec(a_relative_path)
assert spec.path == a_relative_path
@pytest.mark.parametrize(
("text", "expected"),
[
(">=3.12", ">=3.12"),
("python>=3.12", ">=3.12"),
("cpython!=3.11.*", "!=3.11.*"),
("<=3.13,>=3.12", "<=3.13,>=3.12"),
],
)
def test_specifier_parsing(text: str, expected: str) -> None:
spec = PythonSpec.from_string_spec(text)
assert spec.version_specifier == SpecifierSet.from_string(expected)
def test_specifier_with_implementation() -> None:
spec = PythonSpec.from_string_spec("cpython>=3.12")
assert spec.implementation == "cpython"
assert spec.version_specifier == SpecifierSet.from_string(">=3.12")
def test_specifier_satisfies_with_partial_information() -> None:
spec = PythonSpec.from_string_spec(">=3.12")
candidate = PythonSpec.from_string_spec("python3.12")
assert candidate.satisfies(spec) is True
@pytest.mark.parametrize(
("spec_str", "expected_machine"),
[
pytest.param("cpython3.12-64-arm64", "arm64", id="arm64"),
pytest.param("cpython3.12-64-x86_64", "x86_64", id="x86_64"),
pytest.param("cpython3.12-32-x86", "x86", id="x86"),
pytest.param("cpython3.12-64-aarch64", "arm64", id="aarch64"),
pytest.param("cpython3.12-64-ppc64le", "ppc64le", id="ppc64le"),
pytest.param("cpython3.12-64-s390x", "s390x", id="s390x"),
pytest.param("cpython3.12-64-riscv64", "riscv64", id="riscv64"),
pytest.param("cpython3.12-64", None, id="no-machine"),
pytest.param("cpython3.12", None, id="no-arch-no-machine"),
pytest.param("python3.12-64-arm64", "arm64", id="python-impl"),
],
)
def test_spec_parse_machine(spec_str: str, expected_machine: str | None) -> None:
spec = PythonSpec.from_string_spec(spec_str)
assert spec.machine == expected_machine
@pytest.mark.parametrize(
("spec_str", "expected_arch", "expected_machine"),
[
pytest.param("cpython3.12-64-arm64", 64, "arm64", id="64bit-arm64"),
pytest.param("cpython3.12-32-x86", 32, "x86", id="32bit-x86"),
pytest.param("cpython3.12-64", 64, None, id="64bit-no-machine"),
],
)
def test_spec_parse_arch_and_machine_together(spec_str: str, expected_arch: int, expected_machine: str | None) -> None:
spec = PythonSpec.from_string_spec(spec_str)
assert spec.architecture == expected_arch
assert spec.machine == expected_machine
@pytest.mark.parametrize(
("left", "right", "expected"),
[
pytest.param("cpython3.12-64-arm64", "cpython3.12-64-arm64", True, id="same-machine"),
pytest.param("cpython3.12-64-arm64", "cpython3.12-64-x86_64", False, id="different-machine"),
pytest.param("cpython3.12-64-arm64", "cpython3.12-64", True, id="none-matches-any"),
pytest.param("cpython3.12-64-amd64", "cpython3.12-64-x86_64", True, id="amd64-eq-x86_64"),
pytest.param("cpython3.12-64-aarch64", "cpython3.12-64-arm64", True, id="aarch64-eq-arm64"),
],
)
def test_spec_satisfies_machine(left: str, right: str, expected: bool) -> None:
assert PythonSpec.from_string_spec(left).satisfies(PythonSpec.from_string_spec(right)) is expected
@pytest.mark.parametrize(
("isa", "normalized"),
[
pytest.param("amd64", "x86_64", id="amd64"),
pytest.param("aarch64", "arm64", id="aarch64"),
pytest.param("x86_64", "x86_64", id="x86_64"),
pytest.param("arm64", "arm64", id="arm64"),
pytest.param("x86", "x86", id="x86"),
pytest.param("i386", "x86", id="i386"),
pytest.param("i486", "x86", id="i486"),
pytest.param("i586", "x86", id="i586"),
pytest.param("i686", "x86", id="i686"),
pytest.param("ppc64le", "ppc64le", id="ppc64le"),
pytest.param("powerpc", "ppc", id="powerpc"),
pytest.param("powerpc64", "ppc64", id="powerpc64"),
pytest.param("powerpc64le", "ppc64le", id="powerpc64le"),
pytest.param("riscv64", "riscv64", id="riscv64"),
pytest.param("s390x", "s390x", id="s390x"),
pytest.param("sparcv9", "sparc64", id="sparcv9"),
pytest.param("sparc64", "sparc64", id="sparc64"),
pytest.param("alpha", "alpha", id="alpha-passthrough"),
],
)
def test_normalize_isa(isa: str, normalized: str) -> None:
assert normalize_isa(isa) == normalized
@pytest.mark.parametrize(
("spec_str", "in_repr"),
[
pytest.param("cpython3.12-64-arm64", "machine=arm64", id="with-machine"),
pytest.param("cpython3.12-64", "architecture=64", id="without-machine"),
],
)
def test_spec_repr_machine(spec_str: str, in_repr: str) -> None:
assert in_repr in repr(PythonSpec.from_string_spec(spec_str))
python_discovery-1.2.2/tests/test_py_spec_extra.py 0000644 0000000 0000000 00000010171 13615410400 017464 0 ustar 00 from __future__ import annotations
from unittest.mock import MagicMock
from python_discovery import PythonSpec
def test_specifier_parse_failure_fallback() -> None:
spec = PythonSpec.from_string_spec("not_a_valid_anything_really")
assert spec.path == "not_a_valid_anything_really"
assert spec.version_specifier is None
def test_version_specifier_satisfies_micro() -> None:
spec = PythonSpec.from_string_spec(">=3.12.0")
candidate = PythonSpec("", "CPython", 3, 12, 1, None, None)
assert candidate.satisfies(spec) is True
def test_version_specifier_satisfies_fails_micro() -> None:
spec = PythonSpec.from_string_spec(">=3.13.0")
candidate = PythonSpec("", "CPython", 3, 12, 5, None, None)
assert candidate.satisfies(spec) is False
def test_version_specifier_no_components() -> None:
spec = PythonSpec.from_string_spec(">=3.12")
candidate = PythonSpec("", None, None, None, None, None, None)
assert candidate.satisfies(spec) is True
def test_check_version_specifier_precision() -> None:
spec = PythonSpec.from_string_spec(">=3.12")
candidate = PythonSpec("", "CPython", 3, None, None, None, None)
assert candidate._check_version_specifier(spec) is True
def test_check_version_specifier_precision_micro() -> None:
spec = PythonSpec.from_string_spec(">=3.12.0")
candidate = PythonSpec("", "CPython", 3, 12, None, None, None)
assert candidate._check_version_specifier(spec) is True
def test_check_version_specifier_fails() -> None:
spec = PythonSpec.from_string_spec(">=3.13")
candidate = PythonSpec("", "CPython", 3, 12, 0, None, None)
assert candidate._check_version_specifier(spec) is False
def test_check_version_specifier_none() -> None:
spec = PythonSpec("", None, None, None, None, None, None)
candidate = PythonSpec("", "CPython", 3, 12, 0, None, None)
assert candidate._check_version_specifier(spec) is True
def test_get_required_precision_none() -> None:
from python_discovery._specifier import SimpleSpecifier
specifier = SimpleSpecifier(
spec_str=">=3.12",
operator=">=",
version_str="3.12",
is_wildcard=False,
wildcard_precision=None,
version=None,
)
assert PythonSpec._get_required_precision(specifier) is None
def test_get_required_precision_normal() -> None:
from python_discovery._specifier import SimpleSpecifier
specifier = SimpleSpecifier.from_string(">=3.12.0")
assert PythonSpec._get_required_precision(specifier) == 3
def test_generate_re_no_threaded() -> None:
spec = PythonSpec.from_string_spec("python3.12")
pat = spec.generate_re(windows=False)
assert pat.fullmatch("python3.12") is not None
def test_generate_re_with_threaded() -> None:
spec = PythonSpec.from_string_spec("python3.12t")
pat = spec.generate_re(windows=False)
assert pat.fullmatch("python3.12t") is not None
def test_single_digit_version() -> None:
spec = PythonSpec.from_string_spec("python3")
assert spec.major == 3
assert spec.minor is None
def test_specifier_with_invalid_inner() -> None:
spec = PythonSpec.from_string_spec(">=not_a_version")
assert spec.path is None or spec.version_specifier is not None or spec.path == ">=not_a_version"
def test_two_digit_version() -> None:
spec = PythonSpec.from_string_spec("python312")
assert spec.major == 3
assert spec.minor == 12
def test_single_digit_major_only() -> None:
spec = PythonSpec.from_string_spec("python3")
assert spec.major == 3
assert spec.minor is None
def test_specifier_set_parsed_for_valid_format() -> None:
spec = PythonSpec.from_string_spec("cpython>=3.12")
assert spec.version_specifier is not None
assert spec.implementation == "cpython"
def test_get_required_precision_attribute_error() -> None:
from python_discovery._specifier import SimpleSpecifier
mock_version = MagicMock(spec=[])
specifier = SimpleSpecifier(
spec_str=">=3.12",
operator=">=",
version_str="3.12",
is_wildcard=False,
wildcard_precision=None,
version=mock_version,
)
assert PythonSpec._get_required_precision(specifier) is None
python_discovery-1.2.2/tests/test_specifier.py 0000644 0000000 0000000 00000024474 13615410400 016603 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") # noqa: PLC2801
assert result is NotImplemented
def test_version_hash() -> None:
assert hash(SimpleVersion.from_string("3.11")) == hash(SimpleVersion.from_string("3.11"))
@pytest.mark.parametrize(
("left", "right", "expected"),
[
pytest.param("3.11", "3.12", True, id="release"),
pytest.param("3.14.0a1", "3.14.0", True, id="prerelease-vs-final"),
pytest.param("3.14.0", "3.14.0a1", False, id="final-not-less-than-prerelease"),
pytest.param("3.14.0", "3.14.0", False, id="equal"),
pytest.param("3.14.0a1", "3.14.0b1", True, id="alpha-lt-beta"),
pytest.param("3.14.0b1", "3.14.0rc1", True, id="beta-lt-rc"),
pytest.param("3.14.0a1", "3.14.0a2", True, id="same-type-ordering"),
],
)
def test_version_lt(left: str, right: str, expected: bool) -> None:
assert (SimpleVersion.from_string(left) < SimpleVersion.from_string(right)) is expected
def test_version_lt_not_implemented() -> None:
result = SimpleVersion.from_string("3.11").__lt__("3.12") # noqa: PLC2801
assert result is NotImplemented
def test_version_le() -> None:
assert SimpleVersion.from_string("3.11") <= SimpleVersion.from_string("3.12")
assert SimpleVersion.from_string("3.11") <= SimpleVersion.from_string("3.11")
def test_version_gt() -> None:
assert SimpleVersion.from_string("3.12") > SimpleVersion.from_string("3.11")
def test_version_gt_not_implemented() -> None:
result = SimpleVersion.from_string("3.11").__gt__("3.11") # noqa: PLC2801
assert result is NotImplemented
def test_version_ge() -> None:
assert SimpleVersion.from_string("3.12") >= SimpleVersion.from_string("3.11")
assert SimpleVersion.from_string("3.12") >= SimpleVersion.from_string("3.12")
def test_version_str() -> None:
assert str(SimpleVersion.from_string("3.11")) == "3.11"
def test_version_repr() -> None:
assert repr(SimpleVersion.from_string("3.11")) == "SimpleVersion('3.11')"
# --- SimpleSpecifier ---
def test_specifier_invalid_raises() -> None:
with pytest.raises(ValueError, match="Invalid specifier"):
SimpleSpecifier.from_string("no_operator")
def test_specifier_parse_gte() -> None:
spec = SimpleSpecifier.from_string(">=3.12")
assert spec.operator == ">="
assert spec.version == SimpleVersion.from_string("3.12")
assert spec.is_wildcard is False
def test_specifier_parse_wildcard() -> None:
spec = SimpleSpecifier.from_string("==3.11.*")
assert spec.is_wildcard is True
assert spec.wildcard_precision == 2
@pytest.mark.parametrize(
("spec_str", "version_str", "expected"),
[
pytest.param("==3.11.*", "3.11.5", True, id="wildcard-eq-match"),
pytest.param("==3.11.*", "3.12.0", False, id="wildcard-eq-no-match"),
pytest.param("!=3.11.*", "3.11.5", False, id="wildcard-ne-match"),
pytest.param("!=3.11.*", "3.12.0", True, id="wildcard-ne-no-match"),
pytest.param(">=3.11.*", "3.11.5", False, id="wildcard-unsupported-op"),
pytest.param(">=3.12", "3.12.0", True, id="gte-match"),
pytest.param(">=3.12", "3.13.0", True, id="gte-above"),
pytest.param(">=3.12", "3.11.0", False, id="gte-below"),
pytest.param("<=3.12", "3.12.0", True, id="lte-match"),
pytest.param("<=3.12", "3.11.0", True, id="lte-below"),
pytest.param("<=3.12", "3.13.0", False, id="lte-above"),
pytest.param(">3.12", "3.13.0", True, id="gt-above"),
pytest.param(">3.12", "3.12.0", False, id="gt-equal"),
pytest.param("<3.12", "3.11.0", True, id="lt-below"),
pytest.param("<3.12", "3.12.0", False, id="lt-equal"),
pytest.param("==3.12.0", "3.12.0", True, id="eq-match"),
pytest.param("==3.12.0", "3.12.1", False, id="eq-no-match"),
pytest.param("!=3.12.0", "3.12.0", False, id="ne-match"),
pytest.param("!=3.12.0", "3.12.1", True, id="ne-no-match"),
pytest.param("===3.12", "3.12", True, id="exact-match"),
pytest.param("===3.12", "3.12.0", False, id="exact-no-match"),
pytest.param("~=3.12.0", "3.12.5", True, id="compatible-above"),
pytest.param("~=3.12.0", "3.13.0", False, id="compatible-next-minor"),
pytest.param("~=3.12.0", "3.11.0", False, id="compatible-below"),
pytest.param("~=3.12.0", "3.11.9", False, id="compatible-just-below"),
pytest.param(">=3.12", "not_a_version", False, id="invalid-version"),
],
)
def test_specifier_contains(spec_str: str, version_str: str, expected: bool) -> None:
spec = SimpleSpecifier.from_string(spec_str)
assert spec.contains(version_str) is expected
def test_specifier_contains_version_none() -> None:
spec = SimpleSpecifier(
spec_str=">=3.12",
operator=">=",
version_str="3.12",
is_wildcard=False,
wildcard_precision=None,
version=None,
)
assert spec.contains("3.12") is False
def test_specifier_wildcard_version_none() -> None:
spec = SimpleSpecifier(
spec_str="==3.11.*",
operator="==",
version_str="3.11",
is_wildcard=True,
wildcard_precision=2,
version=None,
)
assert spec.contains("3.11.0") is False
def test_specifier_compatible_release_version_none() -> None:
spec = SimpleSpecifier(
spec_str="~=3.12",
operator="~=",
version_str="3.12",
is_wildcard=False,
wildcard_precision=None,
version=None,
)
assert spec._check_compatible_release(SimpleVersion.from_string("3.12")) is False
def test_specifier_eq() -> None:
assert SimpleSpecifier.from_string(">=3.12") == SimpleSpecifier.from_string(">=3.12")
def test_specifier_eq_not_implemented() -> None:
result = SimpleSpecifier.from_string(">=3.12").__eq__(">=3.12") # noqa: PLC2801
assert result is NotImplemented
def test_specifier_hash() -> None:
assert hash(SimpleSpecifier.from_string(">=3.12")) == hash(SimpleSpecifier.from_string(">=3.12"))
def test_specifier_str() -> None:
assert str(SimpleSpecifier.from_string(">=3.12")) == ">=3.12"
def test_specifier_repr() -> None:
assert repr(SimpleSpecifier.from_string(">=3.12")) == "SimpleSpecifier('>=3.12')"
def test_specifier_version_parse_failure_stores_none() -> None:
spec = SimpleSpecifier.from_string(">=abc.*")
assert spec.version is None
def test_specifier_unknown_operator() -> None:
spec = SimpleSpecifier(
spec_str="??3.12",
operator="??",
version_str="3.12",
is_wildcard=False,
wildcard_precision=None,
version=SimpleVersion.from_string("3.12"),
)
assert spec.contains("3.12.0") is False
# --- SimpleSpecifierSet ---
@pytest.mark.parametrize(
("spec_str", "version_str", "expected"),
[
pytest.param("", "3.12", True, id="empty-always-matches"),
pytest.param(">=3.12", "3.12.0", True, id="single-match"),
pytest.param(">=3.12", "3.11.0", False, id="single-no-match"),
pytest.param(">=3.12,<3.14", "3.12.0", True, id="compound-lower-bound"),
pytest.param(">=3.12,<3.14", "3.13.0", True, id="compound-middle"),
pytest.param(">=3.12,<3.14", "3.14.0", False, id="compound-upper-bound"),
pytest.param(">=3.12,<3.14", "3.11.0", False, id="compound-below"),
],
)
def test_specifier_set_contains(spec_str: str, version_str: str, expected: bool) -> None:
spec_set = SimpleSpecifierSet.from_string(spec_str)
assert spec_set.contains(version_str) is expected
def test_specifier_set_iter() -> None:
spec_set = SimpleSpecifierSet.from_string(">=3.12,<3.14")
specs = list(spec_set)
assert len(specs) == 2
def test_specifier_set_eq() -> None:
assert SimpleSpecifierSet.from_string(">=3.12") == SimpleSpecifierSet.from_string(">=3.12")
def test_specifier_set_eq_not_implemented() -> None:
result = SimpleSpecifierSet.from_string(">=3.12").__eq__(">=3.12") # noqa: PLC2801
assert result is NotImplemented
def test_specifier_set_hash() -> None:
assert hash(SimpleSpecifierSet.from_string(">=3.12")) == hash(SimpleSpecifierSet.from_string(">=3.12"))
def test_specifier_set_str() -> None:
assert str(SimpleSpecifierSet.from_string(">=3.12")) == ">=3.12"
def test_specifier_set_repr() -> None:
assert repr(SimpleSpecifierSet.from_string(">=3.12")) == "SimpleSpecifierSet('>=3.12')"
def test_specifier_set_invalid_specifier_skipped() -> None:
spec_set = SimpleSpecifierSet.from_string(">=3.12, invalid_spec")
assert len(spec_set.specifiers) == 1
def test_specifier_set_contains_no_specifiers() -> None:
spec_set = SimpleSpecifierSet.from_string()
assert spec_set.contains("3.12") is True
def test_specifier_set_empty_item_in_comma_list() -> None:
spec_set = SimpleSpecifierSet.from_string(">=3.12,,<3.14")
assert len(spec_set.specifiers) == 2
def test_specifier_compatible_release_major_only() -> None:
spec = SimpleSpecifier.from_string("~=3")
assert spec.contains("3.0.0") is True
assert spec.contains("3.0.5") is True
python_discovery-1.2.2/tests/py_info/test_py_info.py 0000644 0000000 0000000 00000040610 13615410400 017726 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, NamedTuple
import pytest
from setuptools.dist import Distribution
from python_discovery import DiskCache, PythonInfo, PythonSpec
from python_discovery import _cached_py_info as cached_py_info
from python_discovery._py_info import VersionInfo
if TYPE_CHECKING:
from pytest_mock import MockerFixture
IS_PYPY = PythonInfo.current_system().implementation == "PyPy"
CURRENT = PythonInfo.current_system()
@pytest.mark.graalpy
def test_current_as_json() -> None:
result = CURRENT.to_json()
parsed = json.loads(result)
major, minor, micro, releaselevel, serial = sys.version_info
free_threaded = sysconfig.get_config_var("Py_GIL_DISABLED") == 1
assert parsed["version_info"] == {
"major": major,
"minor": minor,
"micro": micro,
"releaselevel": releaselevel,
"serial": serial,
}
assert parsed["free_threaded"] is free_threaded
def test_bad_exe_py_info_raise(tmp_path: Path, session_cache: DiskCache) -> None:
exe = str(tmp_path)
with pytest.raises(RuntimeError) as context:
PythonInfo.from_exe(exe, session_cache)
msg = str(context.value)
assert "code" in msg
assert exe in msg
def test_bad_exe_py_info_no_raise(
tmp_path: Path,
caplog: pytest.LogCaptureFixture,
capsys: pytest.CaptureFixture[str],
session_cache: DiskCache,
) -> None:
caplog.set_level(logging.NOTSET)
exe = str(tmp_path)
result = PythonInfo.from_exe(exe, session_cache, raise_on_error=False)
assert result is None
out, _ = capsys.readouterr()
assert not out
messages = [r.message for r in caplog.records if r.name != "filelock"]
assert len(messages) == 4
assert "get interpreter info via cmd: " in messages[0]
assert "retrying" in messages[1]
assert "get interpreter info via cmd: " in messages[2]
assert str(exe) in messages[3]
assert "code" in messages[3]
@pytest.mark.parametrize(
"spec",
itertools.chain(
[sys.executable],
[
f"{impl}{'.'.join(str(i) for i in ver)}{'t' if CURRENT.free_threaded else ''}{arch}"
for impl, ver, arch in itertools.product(
(
[CURRENT.implementation]
+ (["python"] if CURRENT.implementation == "CPython" else [])
+ (
[CURRENT.implementation.lower()]
if CURRENT.implementation != CURRENT.implementation.lower()
else []
)
),
[sys.version_info[0 : i + 1] for i in range(3)],
["", f"-{CURRENT.architecture}"],
)
],
),
)
def test_satisfy_py_info(spec: str) -> None:
parsed_spec = PythonSpec.from_string_spec(spec)
matches = CURRENT.satisfies(parsed_spec, impl_must_match=True)
assert matches is True
def test_satisfy_not_arch() -> None:
parsed_spec = PythonSpec.from_string_spec(
f"{CURRENT.implementation}-{64 if CURRENT.architecture == 32 else 32}",
)
matches = CURRENT.satisfies(parsed_spec, impl_must_match=True)
assert matches is False
def test_satisfy_not_threaded() -> None:
parsed_spec = PythonSpec.from_string_spec(
f"{CURRENT.implementation}{CURRENT.version_info.major}{'' if CURRENT.free_threaded else 't'}",
)
matches = CURRENT.satisfies(parsed_spec, impl_must_match=True)
assert matches is False
def _generate_not_match_current_interpreter_version() -> list[str]:
result: list[str] = []
for depth in range(3):
ver: list[int] = [int(part) for part in sys.version_info[0 : depth + 1]]
for idx in range(len(ver)):
for offset in [-1, 1]:
temp = ver.copy()
temp[idx] += offset
result.append(".".join(str(part) for part in temp))
return result
_NON_MATCH_VER = _generate_not_match_current_interpreter_version()
@pytest.mark.parametrize("spec", _NON_MATCH_VER)
def test_satisfy_not_version(spec: str) -> None:
parsed_spec = PythonSpec.from_string_spec(f"{CURRENT.implementation}{spec}")
matches = CURRENT.satisfies(parsed_spec, impl_must_match=True)
assert matches is False
def test_py_info_cached_error(mocker: MockerFixture, tmp_path: Path, session_cache: DiskCache) -> None:
spy = mocker.spy(cached_py_info, "_run_subprocess")
with pytest.raises(RuntimeError):
PythonInfo.from_exe(str(tmp_path), session_cache)
with pytest.raises(RuntimeError):
PythonInfo.from_exe(str(tmp_path), session_cache)
assert spy.call_count == 2
def test_py_info_cache_clear(mocker: MockerFixture, session_cache: DiskCache) -> None:
result = PythonInfo.from_exe(sys.executable, session_cache)
assert result is not None
PythonInfo.clear_cache(session_cache)
assert not cached_py_info._CACHE
spy = mocker.spy(cached_py_info, "_run_subprocess")
info = PythonInfo.from_exe(sys.executable, session_cache)
assert info is not None
native_difference = 1 if info.system_executable == info.executable else 0
assert spy.call_count + native_difference >= 1
class PyInfoMock(NamedTuple):
implementation: str
architecture: int
version_info: VersionInfo
@pytest.mark.parametrize(
("target", "position", "discovered"),
[
(
PyInfoMock("CPython", 64, VersionInfo(3, 6, 8, "final", 0)),
0,
[
PyInfoMock("CPython", 64, VersionInfo(3, 6, 9, "final", 0)),
PyInfoMock("PyPy", 64, VersionInfo(3, 6, 8, "final", 0)),
],
),
(
PyInfoMock("CPython", 64, VersionInfo(3, 6, 8, "final", 0)),
0,
[
PyInfoMock("CPython", 64, VersionInfo(3, 6, 9, "final", 0)),
PyInfoMock("CPython", 32, VersionInfo(3, 6, 9, "final", 0)),
],
),
(
PyInfoMock("CPython", 64, VersionInfo(3, 8, 1, "final", 0)),
0,
[
PyInfoMock("CPython", 32, VersionInfo(2, 7, 12, "rc", 2)),
PyInfoMock("PyPy", 64, VersionInfo(3, 8, 1, "final", 0)),
],
),
],
)
def test_system_executable_no_exact_match(
target: PyInfoMock,
discovered: list[PyInfoMock],
position: int,
*,
tmp_path: Path,
mocker: MockerFixture,
caplog: pytest.LogCaptureFixture,
session_cache: DiskCache,
) -> None:
caplog.set_level(logging.DEBUG)
def _make_py_info(of: PyInfoMock) -> PythonInfo:
base = copy.deepcopy(CURRENT)
base.implementation = of.implementation
base.version_info = of.version_info
base.architecture = of.architecture
return base
discovered_with_path: dict[str, PythonInfo] = {}
names: list[str] = []
selected = None
for pos, i in enumerate(discovered):
path = tmp_path / str(pos)
path.write_text("", encoding="utf-8")
py_info = _make_py_info(i)
py_info.system_executable = CURRENT.system_executable
py_info.executable = CURRENT.system_executable
py_info.base_executable = str(path) # ty: ignore[unresolved-attribute]
if pos == position:
selected = py_info
discovered_with_path[str(path)] = py_info
names.append(path.name)
target_py_info = _make_py_info(target)
mocker.patch.object(target_py_info, "_find_possible_exe_names", return_value=names)
mocker.patch.object(target_py_info, "_find_possible_folders", return_value=[str(tmp_path)])
def func(exe_path: str, _cache: object = None, **_kwargs: object) -> PythonInfo:
return discovered_with_path[exe_path]
mocker.patch.object(target_py_info, "from_exe", side_effect=func)
target_py_info.real_prefix = str(tmp_path)
target_py_info.system_executable = None
target_py_info.executable = str(tmp_path)
mapped = target_py_info.resolve_to_system(session_cache, target_py_info)
assert mapped.system_executable == CURRENT.system_executable
found = discovered_with_path[mapped.base_executable]
assert found is selected
assert caplog.records[0].msg == "discover exe for %s in %s"
for record in caplog.records[1:-1]:
assert record.message.startswith("refused interpreter ")
assert record.levelno == logging.DEBUG
warn_similar = caplog.records[-1]
assert warn_similar.levelno == logging.DEBUG
assert warn_similar.msg.startswith("no exact match found, chosen most similar")
def test_py_info_ignores_distutils_config(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
raw = f"""
[install]
prefix={tmp_path}{os.sep}prefix
install_purelib={tmp_path}{os.sep}purelib
install_platlib={tmp_path}{os.sep}platlib
install_headers={tmp_path}{os.sep}headers
install_scripts={tmp_path}{os.sep}scripts
install_data={tmp_path}{os.sep}data
"""
(tmp_path / "setup.cfg").write_text(dedent(raw), encoding="utf-8")
monkeypatch.chdir(tmp_path)
py_info = PythonInfo.from_exe(sys.executable)
assert py_info is not None
distutils = py_info.distutils_install
for key, value in distutils.items(): # pragma: no cover # distutils_install is empty with "venv" scheme
assert not value.startswith(str(tmp_path)), f"{key}={value}"
def test_discover_exe_on_path_non_spec_name_match(mocker: MockerFixture) -> None:
suffixed_name = f"python{CURRENT.version_info.major}.{CURRENT.version_info.minor}m"
if sys.platform == "win32": # pragma: win32 cover
suffixed_name += Path(CURRENT.original_executable).suffix
spec = PythonSpec.from_string_spec(suffixed_name)
mocker.patch.object(CURRENT, "original_executable", str(Path(CURRENT.executable).parent / suffixed_name))
assert CURRENT.satisfies(spec, impl_must_match=True) is True
def test_discover_exe_on_path_non_spec_name_not_match(mocker: MockerFixture) -> None:
suffixed_name = f"python{CURRENT.version_info.major}.{CURRENT.version_info.minor}m"
if sys.platform == "win32": # pragma: win32 cover
suffixed_name += Path(CURRENT.original_executable).suffix
spec = PythonSpec.from_string_spec(suffixed_name)
mocker.patch.object(
CURRENT,
"original_executable",
str(Path(CURRENT.executable).parent / f"e{suffixed_name}"),
)
assert CURRENT.satisfies(spec, impl_must_match=True) is False
@pytest.mark.skipif(IS_PYPY, reason="setuptools distutils patching does not work")
def test_py_info_setuptools() -> None:
assert Distribution
PythonInfo()
@pytest.mark.usefixtures("_skip_if_test_in_system")
def test_py_info_to_system_raises( # pragma: no cover # skipped in venv environments
session_cache: DiskCache,
mocker: MockerFixture,
caplog: pytest.LogCaptureFixture,
) -> None:
caplog.set_level(logging.DEBUG)
mocker.patch.object(PythonInfo, "_find_possible_folders", return_value=[])
result = PythonInfo.from_exe(sys.executable, cache=session_cache, raise_on_error=False)
assert result is None
log = caplog.records[-1]
assert log.levelno == logging.INFO
expected = f"ignore {sys.executable} due cannot resolve system due to RuntimeError('failed to detect "
assert expected in log.message
def test_sysconfig_vars_include_shared_lib_keys() -> None:
for key in ("Py_ENABLE_SHARED", "INSTSONAME", "LIBDIR"):
assert key in CURRENT.sysconfig_vars
def test_py_info_has_sysconfig_platform() -> None:
assert hasattr(CURRENT, "sysconfig_platform")
assert CURRENT.sysconfig_platform is not None
assert isinstance(CURRENT.sysconfig_platform, str)
assert len(CURRENT.sysconfig_platform) > 0
def test_py_info_machine_property() -> None:
machine = CURRENT.machine
assert machine is not None
assert isinstance(machine, str)
assert len(machine) > 0
assert machine == machine.lower(), f"machine value should be lowercase: {machine}"
def test_py_info_machine_in_spec() -> None:
spec = CURRENT.spec
assert CURRENT.machine in spec
assert f"-{CURRENT.architecture}-{CURRENT.machine}" in spec
def test_py_info_sysconfig_platform_matches_sysconfig() -> None:
assert CURRENT.sysconfig_platform == sysconfig.get_platform()
@pytest.mark.parametrize(
("platform", "expected"),
[
pytest.param("win32", "x86", id="win32"),
pytest.param("win-amd64", "x86_64", id="win-amd64"),
pytest.param("win-arm64", "arm64", id="win-arm64"),
pytest.param("linux-x86_64", "x86_64", id="linux-x86_64"),
pytest.param("linux-aarch64", "arm64", id="linux-aarch64"),
pytest.param("linux-riscv64", "riscv64", id="linux-riscv64"),
pytest.param("linux-ppc64le", "ppc64le", id="linux-ppc64le"),
pytest.param("linux-s390x", "s390x", id="linux-s390x"),
pytest.param("macosx-14.0-arm64", "arm64", id="macos-arm64"),
pytest.param("macosx-14.0-x86_64", "x86_64", id="macos-x86_64"),
],
)
def test_py_info_machine_derivation(platform: str, expected: str) -> None:
info = copy.deepcopy(CURRENT)
info.sysconfig_platform = platform
assert info.machine == expected
@pytest.mark.parametrize("runtime_isa", ["arm64", "x86_64"])
def test_py_info_machine_derivation_universal2(mocker: MockerFixture, runtime_isa: str) -> None:
info = copy.deepcopy(CURRENT)
info.sysconfig_platform = "macosx-11.0-universal2"
mocker.patch("python_discovery._py_info.platform.machine", return_value=runtime_isa)
assert info.machine == runtime_isa
def test_py_info_satisfies_with_machine() -> None:
threaded = "t" if CURRENT.free_threaded else ""
spec_str = (
f"{CURRENT.implementation}{CURRENT.version_info.major}{threaded}-{CURRENT.architecture}-{CURRENT.machine}"
)
parsed_spec = PythonSpec.from_string_spec(spec_str)
assert CURRENT.satisfies(parsed_spec, impl_must_match=True) is True
def test_py_info_satisfies_not_machine() -> None:
other_machine = "arm64" if CURRENT.machine != "arm64" else "x86_64"
spec_str = f"{CURRENT.implementation}-{CURRENT.architecture}-{other_machine}"
parsed_spec = PythonSpec.from_string_spec(spec_str)
assert CURRENT.satisfies(parsed_spec, impl_must_match=True) is False
def test_py_info_satisfies_no_machine_in_spec() -> None:
threaded = "t" if CURRENT.free_threaded else ""
spec_str = f"{CURRENT.implementation}{CURRENT.version_info.major}{threaded}-{CURRENT.architecture}"
parsed_spec = PythonSpec.from_string_spec(spec_str)
assert parsed_spec.machine is None
assert CURRENT.satisfies(parsed_spec, impl_must_match=True) is True
@pytest.mark.parametrize(
("platform", "spec_machine"),
[
pytest.param("linux-x86_64", "amd64", id="amd64-matches-x86_64"),
pytest.param("macosx-14.0-arm64", "aarch64", id="aarch64-matches-arm64"),
],
)
def test_py_info_satisfies_machine_cross_os_normalization(platform: str, spec_machine: str) -> None:
info = copy.deepcopy(CURRENT)
info.sysconfig_platform = platform
spec = PythonSpec.from_string_spec(f"{info.implementation}-{info.architecture}-{spec_machine}")
assert info.satisfies(spec, impl_must_match=True) is True
def test_py_info_to_dict_includes_sysconfig_platform() -> None:
data = CURRENT.to_dict()
assert "sysconfig_platform" in data
assert data["sysconfig_platform"] == sysconfig.get_platform()
def test_py_info_json_round_trip() -> None:
json_str = CURRENT.to_json()
parsed = json.loads(json_str)
assert "sysconfig_platform" in parsed
restored = PythonInfo.from_json(json_str)
assert restored.sysconfig_platform == CURRENT.sysconfig_platform
assert restored.machine == CURRENT.machine
@pytest.mark.parametrize(
("target_platform", "discovered_platforms", "expected_idx"),
[
pytest.param("linux-x86_64", ["linux-aarch64", "linux-x86_64"], 1, id="x86_64-over-aarch64"),
pytest.param("macosx-14.0-arm64", ["macosx-14.0-x86_64", "macosx-14.0-arm64"], 1, id="arm64-over-x86_64"),
],
)
def test_select_most_likely_prefers_machine_match(
target_platform: str,
discovered_platforms: list[str],
expected_idx: int,
) -> None:
target = copy.deepcopy(CURRENT)
target.sysconfig_platform = target_platform
discovered = [copy.deepcopy(CURRENT) for _ in discovered_platforms]
for d, plat in zip(discovered, discovered_platforms):
d.sysconfig_platform = plat
result = PythonInfo._select_most_likely(discovered, target)
assert result.sysconfig_platform == discovered_platforms[expected_idx]
python_discovery-1.2.2/tests/py_info/test_py_info_exe_based_of.py 0000644 0000000 0000000 00000005362 13615410400 022416 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.2.2/tests/windows/conftest.py 0000644 0000000 0000000 00000015432 13615410400 017104 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) # noqa: S102
return loc["enum_collect"], loc["value_collect"], loc["key_open"], loc["hive_open"] # type: ignore[return-value]
class _Key:
def __init__(self, value: object) -> None:
self.value = value
def __enter__(self) -> Self:
return self
def __exit__(
self, exc_type: type[BaseException] | None, exc_val: BaseException | None, exc_tb: TracebackType | None
) -> None:
return None
def _make_enum_key(enum_collect: dict[object, dict[int, object]]) -> object:
def _enum_key(key: object, at: int) -> str:
key_id = key.value if isinstance(key, _Key) else key
result = enum_collect[key_id][at]
if isinstance(result, OSError):
raise result
return result # type: ignore[return-value]
return _enum_key
def _make_query_value_ex(value_collect: dict[object, dict[str, object]]) -> object:
def _query_value_ex(key: object, value_name: str) -> object:
key_id = key.value if isinstance(key, _Key) else key
result = value_collect[key_id][value_name]
if isinstance(result, OSError):
raise result
return result
return _query_value_ex
def _make_open_key_ex(key_open: dict[object, dict[str, object]], hive_open: dict[tuple[object, ...], object]) -> object:
@contextmanager
def _open_key_ex(*args: object) -> Generator[_Key | object]:
if len(args) == 2:
key, value = args
key_id = key.value if isinstance(key, _Key) else key
result = _Key(key_open[key_id][value])
elif len(args) == 4:
result = hive_open[args]
else:
raise RuntimeError
value = result.value if isinstance(result, _Key) else result
if isinstance(value, OSError):
raise value
yield result
return _open_key_ex
@pytest.fixture
def _mock_registry(monkeypatch: pytest.MonkeyPatch) -> None:
if sys.platform != "win32":
winreg = _create_winreg_mock()
monkeypatch.setitem(sys.modules, "winreg", winreg)
else:
import winreg
enum_collect, value_collect, key_open, hive_open = _load_registry_data(winreg)
monkeypatch.setattr(winreg, "EnumKey", _make_enum_key(enum_collect))
monkeypatch.setattr(winreg, "QueryValueEx", _make_query_value_ex(value_collect))
monkeypatch.setattr(winreg, "OpenKeyEx", _make_open_key_ex(key_open, hive_open))
real_exists = os.path.exists
def _mock_exists(path: str) -> bool:
if isinstance(path, str) and ("\\" in path or path.startswith(("C:", "Z:"))):
return True
return real_exists(path)
monkeypatch.setattr("os.path.exists", _mock_exists)
def _mock_pyinfo(major: int, minor: int, arch: int, exe: str, threaded: bool = False) -> MagicMock:
from python_discovery._py_info import VersionInfo
info = MagicMock()
info.base_prefix = str(Path(exe).parent)
info.executable = info.original_executable = info.system_executable = exe
info.implementation = "CPython"
info.architecture = arch
info.version_info = VersionInfo(major, minor, 0, "final", 0)
info.free_threaded = threaded
info.sysconfig_platform = "win-amd64" if arch == 64 else "win32"
info.machine = "x86_64" if arch == 64 else "x86"
def satisfies(spec: object, _impl_must_match: bool = False) -> bool:
if spec.implementation is not None and spec.implementation.lower() != "cpython":
return False
if spec.architecture is not None and spec.architecture != arch:
return False
if spec.free_threaded is not None and spec.free_threaded != threaded:
return False
if spec.major is not None and spec.major != major:
return False
return not (spec.minor is not None and spec.minor != minor)
info.satisfies = satisfies
return info
@pytest.fixture
def _populate_pyinfo_cache(monkeypatch: pytest.MonkeyPatch) -> None:
from python_discovery._cached_py_info import _CACHE
python_core_path = "C:\\Users\\user\\AppData\\Local\\Programs\\Python"
interpreters = [
("ContinuumAnalytics", 3, 10, 32, False, "C:\\Users\\user\\Miniconda3\\python.exe"),
("ContinuumAnalytics", 3, 10, 64, False, "C:\\Users\\user\\Miniconda3-64\\python.exe"),
("PythonCore", 3, 9, 64, False, f"{python_core_path}\\Python36\\python.exe"),
("PythonCore", 3, 9, 64, False, f"{python_core_path}\\Python36\\python.exe"),
("PythonCore", 3, 5, 64, False, f"{python_core_path}\\Python35\\python.exe"),
("PythonCore", 3, 9, 64, False, f"{python_core_path}\\Python36\\python.exe"),
("PythonCore", 3, 7, 32, False, f"{python_core_path}\\Python37-32\\python.exe"),
("PythonCore", 3, 12, 64, False, f"{python_core_path}\\Python312\\python.exe"),
("PythonCore", 3, 13, 64, True, f"{python_core_path}\\Python313\\python3.13t.exe"),
("PythonCore", 2, 7, 64, False, "C:\\Python27\\python.exe"),
("PythonCore", 3, 4, 64, False, "C:\\Python34\\python.exe"),
("CompanyA", 3, 6, 64, False, "Z:\\CompanyA\\Python\\3.6\\python.exe"),
]
for _, major, minor, arch, threaded, exe in interpreters:
info = _mock_pyinfo(major, minor, arch, exe, threaded)
monkeypatch.setitem(_CACHE, Path(info.executable), info)
python_discovery-1.2.2/tests/windows/test_windows.py 0000644 0000000 0000000 00000003361 13615410400 020006 0 ustar 00 from __future__ import annotations
import sys
import pytest
from python_discovery import PythonSpec
@pytest.mark.skipif(sys.platform != "win32", reason="propose_interpreters calls from_exe with Windows paths")
@pytest.mark.usefixtures("_mock_registry")
@pytest.mark.usefixtures("_populate_pyinfo_cache")
@pytest.mark.parametrize(
("string_spec", "expected_exe"),
[
("python3.10", "C:\\Users\\user\\Miniconda3-64\\python.exe"),
("cpython3.10", "C:\\Users\\user\\Miniconda3-64\\python.exe"),
("python3.12", "C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python312\\python.exe"),
("cpython3.12", "C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python312\\python.exe"),
("python", "C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python313\\python3.13t.exe"),
("cpython", "C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python313\\python3.13t.exe"),
("python3", "C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python312\\python.exe"),
("cpython3", "C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python312\\python.exe"),
("python3.6", "Z:\\CompanyA\\Python\\3.6\\python.exe"),
("cpython3.6", "Z:\\CompanyA\\Python\\3.6\\python.exe"),
("3t", "C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python313\\python3.13t.exe"),
("python3.13t", "C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python313\\python3.13t.exe"),
],
)
def test_propose_interpreters(string_spec: str, expected_exe: str) -> None:
from python_discovery._windows import propose_interpreters
spec = PythonSpec.from_string_spec(string_spec)
interpreter = next(propose_interpreters(spec=spec, cache=None, env={}))
assert interpreter.executable == expected_exe
python_discovery-1.2.2/tests/windows/test_windows_pep514.py 0000644 0000000 0000000 00000013277 13615410400 021113 0 ustar 00 from __future__ import annotations
import sys
import textwrap
import pytest
@pytest.mark.usefixtures("_mock_registry")
def test_pep514_discovers_interpreters() -> None:
from python_discovery._windows._pep514 import discover_pythons
interpreters = list(discover_pythons())
assert len(interpreters) == 12
companies = {i[0] for i in interpreters}
assert "ContinuumAnalytics" in companies
assert "PythonCore" in companies
assert "CompanyA" in companies
@pytest.mark.usefixtures("_mock_registry")
def test_pep514_parse_functions() -> None:
from python_discovery._windows._pep514 import parse_arch, parse_version
assert parse_arch("64bit") == 64
assert parse_arch("32bit") == 32
with pytest.raises(ValueError, match="invalid format"):
parse_arch("magic")
with pytest.raises(ValueError, match="arch is not string"):
parse_arch(100)
assert parse_version("3.12") == (3, 12, None)
assert parse_version("3.12.1") == (3, 12, 1)
assert parse_version("3") == (3, None, None)
with pytest.raises(ValueError, match="invalid format"):
parse_version("3.X")
with pytest.raises(ValueError, match="version is not string"):
parse_version(2778)
@pytest.mark.skipif(sys.platform != "win32", reason="path joining differs on POSIX")
@pytest.mark.usefixtures("_mock_registry")
def test_pep514() -> None:
from python_discovery._windows._pep514 import discover_pythons
interpreters = list(discover_pythons())
assert interpreters == [
("ContinuumAnalytics", 3, 10, 32, False, "C:\\Users\\user\\Miniconda3\\python.exe", None),
("ContinuumAnalytics", 3, 10, 64, False, "C:\\Users\\user\\Miniconda3-64\\python.exe", None),
(
"PythonCore",
3,
9,
64,
False,
"C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python39\\python.exe",
None,
),
(
"PythonCore",
3,
9,
64,
False,
"C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python39\\python.exe",
None,
),
(
"PythonCore",
3,
8,
64,
False,
"C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python38\\python.exe",
None,
),
(
"PythonCore",
3,
9,
64,
False,
"C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python39\\python.exe",
None,
),
(
"PythonCore",
3,
10,
32,
False,
"C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python310-32\\python.exe",
None,
),
(
"PythonCore",
3,
12,
64,
False,
"C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python312\\python.exe",
None,
),
(
"PythonCore",
3,
13,
64,
True,
"C:\\Users\\user\\AppData\\Local\\Programs\\Python\\Python313\\python3.13t.exe",
None,
),
("CompanyA", 3, 6, 64, False, "Z:\\CompanyA\\Python\\3.6\\python.exe", None),
("PythonCore", 2, 7, 64, False, "C:\\Python27\\python.exe", None),
("PythonCore", 3, 7, 64, False, "C:\\Python37\\python.exe", None),
]
@pytest.mark.skipif(sys.platform != "win32", reason="path joining differs on POSIX")
@pytest.mark.usefixtures("_mock_registry")
def test_pep514_run(capsys: pytest.CaptureFixture[str], caplog: pytest.LogCaptureFixture) -> None:
from python_discovery._windows import _pep514 as pep514
pep514._run()
out, err = capsys.readouterr()
py = r"C:\Users\user\AppData\Local\Programs\Python"
expected = textwrap.dedent(
rf"""
('CompanyA', 3, 6, 64, False, 'Z:\\CompanyA\\Python\\3.6\\python.exe', None)
('ContinuumAnalytics', 3, 10, 32, False, 'C:\\Users\\user\\Miniconda3\\python.exe', None)
('ContinuumAnalytics', 3, 10, 64, False, 'C:\\Users\\user\\Miniconda3-64\\python.exe', None)
('PythonCore', 2, 7, 64, False, 'C:\\Python27\\python.exe', None)
('PythonCore', 3, 10, 32, False, '{py}\\Python310-32\\python.exe', None)
('PythonCore', 3, 12, 64, False, '{py}\\Python312\\python.exe', None)
('PythonCore', 3, 13, 64, True, '{py}\\Python313\\python3.13t.exe', None)
('PythonCore', 3, 7, 64, False, 'C:\\Python37\\python.exe', None)
('PythonCore', 3, 8, 64, False, '{py}\\Python38\\python.exe', None)
('PythonCore', 3, 9, 64, False, '{py}\\Python39\\python.exe', None)
('PythonCore', 3, 9, 64, False, '{py}\\Python39\\python.exe', None)
('PythonCore', 3, 9, 64, False, '{py}\\Python39\\python.exe', None)
""",
).strip()
assert out.strip() == expected
assert not err
prefix = "PEP-514 violation in Windows Registry at "
expected_logs = [
f"{prefix}HKEY_CURRENT_USER/PythonCore/3.1/SysArchitecture error: invalid format magic",
f"{prefix}HKEY_CURRENT_USER/PythonCore/3.2/SysArchitecture error: arch is not string: 100",
f"{prefix}HKEY_CURRENT_USER/PythonCore/3.3 error: no ExecutablePath or default for it",
f"{prefix}HKEY_CURRENT_USER/PythonCore/3.3 error: could not load exe with value None",
f"{prefix}HKEY_CURRENT_USER/PythonCore/3.11/InstallPath error: missing",
f"{prefix}HKEY_CURRENT_USER/PythonCore/3.12/SysVersion error: invalid format magic",
f"{prefix}HKEY_CURRENT_USER/PythonCore/3.X/SysVersion error: version is not string: 2778",
f"{prefix}HKEY_CURRENT_USER/PythonCore/3.X error: invalid format 3.X",
]
assert caplog.messages == expected_logs
python_discovery-1.2.2/tests/windows/winreg_mock_values.py 0000644 0000000 0000000 00000016722 13615410400 021145 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.2.2/.gitignore 0000644 0000000 0000000 00000000077 13615410400 014040 0 ustar 00 *.pyc
*.egg-info
dist/
.tox/
/src/python_discovery/_version.py
python_discovery-1.2.2/LICENSE 0000644 0000000 0000000 00000001777 13615410400 013065 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.2.2/README.md 0000644 0000000 0000000 00000004157 13615410400 013332 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)
## What is python-discovery?
`python-discovery` is a library for discovering Python interpreters installed on your machine. You may have multiple
Python versions from system packages, [pyenv](https://github.com/pyenv/pyenv), [mise](https://mise.jdx.dev/),
[asdf](https://asdf-vm.com/), [uv](https://docs.astral.sh/uv/), or the Windows registry (PEP 514). This library finds
the right one for you.
Give it a requirement like `python3.12` or `>=3.11,<3.13`, and it searches all known locations, verifies each candidate,
and returns detailed metadata about the match. Results are cached to disk so repeated lookups are fast.
## Usage
```python
from pathlib import Path
from python_discovery import DiskCache, get_interpreter
cache = DiskCache(root=Path("~/.cache/python-discovery").expanduser())
result = get_interpreter("python3.12", cache=cache)
if result is not None:
print(result.executable) # /usr/bin/python3.12
print(result.implementation) # CPython
print(result.version_info[:3]) # (3, 12, 1)
```
The `get_interpreter()` function accepts various specification formats:
- Absolute path: `/usr/bin/python3.12`
- Version: `3.12`
- Implementation prefix: `cpython3.12`
- PEP 440 specifier: `>=3.10`, `>=3.11,<3.13`
## Documentation
Full documentation is available at [python-discovery.readthedocs.io](https://python-discovery.readthedocs.io/en/latest/)
python_discovery-1.2.2/pyproject.toml 0000644 0000000 0000000 00000012346 13615410400 014766 0 ustar 00 [build-system]
build-backend = "hatchling.build"
requires = [
"hatch-vcs>=0.5",
"hatchling>=1.28",
]
[project]
name = "python-discovery"
description = "Python interpreter discovery"
readme = "README.md"
keywords = [
"discovery",
"interpreter",
"python",
]
license.file = "LICENSE"
maintainers = [
{ name = "Bernát Gábor", email = "gaborjbernat@gmail.com" },
]
requires-python = ">=3.8"
classifiers = [
"Development Status :: 5 - Production/Stable",
"Intended Audience :: Developers",
"License :: OSI Approved :: MIT License",
"Operating System :: MacOS :: MacOS X",
"Operating System :: Microsoft :: Windows",
"Operating System :: POSIX",
"Programming Language :: Python :: 3 :: Only",
"Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
"Programming Language :: Python :: Implementation :: CPython",
"Topic :: Software Development :: Libraries",
"Topic :: Utilities",
]
dynamic = [
"version",
]
dependencies = [
"filelock>=3.15.4",
"platformdirs<5,>=4.3.6",
]
optional-dependencies.docs = [
"furo>=2025.12.19",
"sphinx>=9.1",
"sphinx-autodoc-typehints>=3.6.3",
"sphinxcontrib-mermaid>=2",
]
optional-dependencies.testing = [
"covdefaults>=2.3",
"coverage>=7.5.4",
"pytest>=8.3.5",
"pytest-mock>=3.14",
"setuptools>=75.1",
]
urls.Changelog = "https://github.com/tox-dev/python-discovery/releases"
urls.Documentation = "https://python-discovery.readthedocs.io"
urls.Homepage = "https://github.com/tox-dev/python-discovery"
urls.Source = "https://github.com/tox-dev/python-discovery"
urls.Tracker = "https://github.com/tox-dev/python-discovery/issues"
[tool.hatch]
version.source = "vcs"
[tool.ruff]
line-length = 120
format.preview = true
format.docstring-code-line-length = 100
format.docstring-code-format = true
lint.select = [
"ALL",
]
lint.ignore = [
"COM812", # Conflict with formatter
"CPY", # No copyright statements
"D203", # `one-blank-line-before-class` and `no-blank-line-before-class` are incompatible
"D212", # `multi-line-summary-first-line` and `multi-line-summary-second-line` are incompatible
"DOC201", # `return` is not documented in docstring
"DOC402", # `yield` is not documented in docstring
"DOC501", # `raises` is not documented in docstring
"ISC001", # Conflict with formatter
"S104", # Possible binding to all interface
]
lint.per-file-ignores."docs/**/*.py" = [
"INP001", # no __init__.py in docs directory
]
lint.per-file-ignores."src/python_discovery/_discovery.py" = [
"PTH", # shim resolution uses string-based os.path for consistency with env variables
]
lint.per-file-ignores."src/python_discovery/_py_info.py" = [
"PTH", # must use os.path — file runs as subprocess script with only stdlib
]
lint.per-file-ignores."src/python_discovery/_windows/_pep514.py" = [
"PTH", # os.path.exists is monkeypatched in tests; pathlib.Path.exists bypasses the mock
]
lint.per-file-ignores."tests/**/*.py" = [
"D", # don't care about documentation in tests
"FBT", # don't care about booleans as positional arguments in tests
"INP001", # no implicit namespace
"PLC0415", # imports inside test functions (conditional on mocking)
"PLC2701", # private imports needed to test internal APIs
"PLR0913", # too many arguments (pytest fixtures)
"PLR2004", # Magic value used in comparison
"S101", # asserts allowed in tests
"S404", # subprocess import
"S603", # `subprocess` call: check for execution of untrusted input
"SLF001", # private member access needed to test internals
]
lint.per-file-ignores."tests/windows/winreg_mock_values.py" = [
"F821", # undefined name (winreg available only on Windows)
]
lint.isort = { known-first-party = [
"python_discovery",
], required-imports = [
"from __future__ import annotations",
] }
lint.preview = true
[tool.codespell]
builtin = "clear,usage,en-GB_to_en-US"
write-changes = true
count = true
[tool.pyproject-fmt]
max_supported_python = "3.14"
[tool.ty]
src.exclude = [ "tests/windows/winreg_mock_values.py" ]
[[tool.ty.overrides]]
include = [ "src/python_discovery/_py_info.py", "src/python_discovery/_py_spec.py" ]
rules.unused-ignore-comment = "ignore"
rules.invalid-argument-type = "ignore"
rules.invalid-return-type = "ignore"
rules.no-matching-overload = "ignore"
[[tool.ty.overrides]]
include = [ "tests/**/*.py" ]
rules.unused-ignore-comment = "ignore"
rules.invalid-argument-type = "ignore"
rules.no-matching-overload = "ignore"
rules.unresolved-attribute = "ignore"
[tool.pytest]
ini_options.markers = [ "graalpy" ]
[tool.coverage]
run.branch = true
run.dynamic_context = "test_function"
run.parallel = true
run.plugins = [
"covdefaults",
]
run.source = [
"python_discovery",
"tests",
]
paths.source = [
"src",
".tox*/*/lib/python*/site-packages",
".tox*/pypy*/site-packages",
".tox*\\*\\Lib\\site-packages",
"*/src",
"*\\src",
]
report.fail_under = 100
report.omit = [
"src/python_discovery/_windows/*",
"tests/windows/*",
]
report.partial_branches = [
"assert any\\(",
]
report.show_missing = true
html.show_contexts = true
html.skip_covered = false
python_discovery-1.2.2/PKG-INFO 0000644 0000000 0000000 00000012434 13615410400 013145 0 ustar 00 Metadata-Version: 2.4
Name: python-discovery
Version: 1.2.2
Summary: Python interpreter discovery
Project-URL: Changelog, https://github.com/tox-dev/python-discovery/releases
Project-URL: Documentation, https://python-discovery.readthedocs.io
Project-URL: Homepage, https://github.com/tox-dev/python-discovery
Project-URL: Source, https://github.com/tox-dev/python-discovery
Project-URL: Tracker, https://github.com/tox-dev/python-discovery/issues
Maintainer-email: Bernát Gábor
License: Permission is hereby granted, free of charge, to any person obtaining a
copy of this software and associated documentation files (the
"Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be included
in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
License-File: LICENSE
Keywords: discovery,interpreter,python
Classifier: Development Status :: 5 - Production/Stable
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: MacOS :: MacOS X
Classifier: Operating System :: Microsoft :: Windows
Classifier: Operating System :: POSIX
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.8
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Programming Language :: Python :: Implementation :: CPython
Classifier: Topic :: Software Development :: Libraries
Classifier: Topic :: Utilities
Requires-Python: >=3.8
Requires-Dist: filelock>=3.15.4
Requires-Dist: platformdirs<5,>=4.3.6
Provides-Extra: docs
Requires-Dist: furo>=2025.12.19; extra == 'docs'
Requires-Dist: sphinx-autodoc-typehints>=3.6.3; extra == 'docs'
Requires-Dist: sphinx>=9.1; extra == 'docs'
Requires-Dist: sphinxcontrib-mermaid>=2; extra == 'docs'
Provides-Extra: testing
Requires-Dist: covdefaults>=2.3; extra == 'testing'
Requires-Dist: coverage>=7.5.4; extra == 'testing'
Requires-Dist: pytest-mock>=3.14; extra == 'testing'
Requires-Dist: pytest>=8.3.5; extra == 'testing'
Requires-Dist: setuptools>=75.1; extra == 'testing'
Description-Content-Type: text/markdown
# [`python-discovery`](https://python-discovery.readthedocs.io/en/latest/)
[](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)
## What is python-discovery?
`python-discovery` is a library for discovering Python interpreters installed on your machine. You may have multiple
Python versions from system packages, [pyenv](https://github.com/pyenv/pyenv), [mise](https://mise.jdx.dev/),
[asdf](https://asdf-vm.com/), [uv](https://docs.astral.sh/uv/), or the Windows registry (PEP 514). This library finds
the right one for you.
Give it a requirement like `python3.12` or `>=3.11,<3.13`, and it searches all known locations, verifies each candidate,
and returns detailed metadata about the match. Results are cached to disk so repeated lookups are fast.
## Usage
```python
from pathlib import Path
from python_discovery import DiskCache, get_interpreter
cache = DiskCache(root=Path("~/.cache/python-discovery").expanduser())
result = get_interpreter("python3.12", cache=cache)
if result is not None:
print(result.executable) # /usr/bin/python3.12
print(result.implementation) # CPython
print(result.version_info[:3]) # (3, 12, 1)
```
The `get_interpreter()` function accepts various specification formats:
- Absolute path: `/usr/bin/python3.12`
- Version: `3.12`
- Implementation prefix: `cpython3.12`
- PEP 440 specifier: `>=3.10`, `>=3.11,<3.13`
## Documentation
Full documentation is available at [python-discovery.readthedocs.io](https://python-discovery.readthedocs.io/en/latest/)