├── .github └── workflows │ ├── ci.yml │ └── release.yml ├── .gitignore ├── .pre-commit-config.yaml ├── LICENSE ├── Makefile ├── README.md ├── pdm.lock ├── pyproject.toml ├── src └── findpython │ ├── __init__.py │ ├── __main__.py │ ├── finder.py │ ├── pep514tools │ ├── __init__.py │ ├── __main__.py │ ├── _registry.py │ └── environment.py │ ├── providers │ ├── __init__.py │ ├── asdf.py │ ├── base.py │ ├── macos.py │ ├── path.py │ ├── pyenv.py │ ├── rye.py │ ├── uv.py │ └── winreg.py │ ├── python.py │ └── utils.py └── tests ├── __init__.py ├── conftest.py ├── test_cli.py ├── test_finder.py ├── test_posix.py └── test_utils.py /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | pull_request: 5 | paths-ignore: 6 | - "*.md" 7 | push: 8 | branches: 9 | - main 10 | paths-ignore: 11 | - "*.md" 12 | 13 | jobs: 14 | Testing: 15 | runs-on: ${{ matrix.os }} 16 | strategy: 17 | matrix: 18 | python-version: [3.8, 3.9, "3.10", "3.11", "3.12", "3.13"] 19 | os: [ubuntu-latest, windows-latest] 20 | arch: [x64] 21 | include: 22 | - python-version: "3.12" 23 | os: windows-latest 24 | arch: x86 25 | - python-version: "3.8" 26 | os: macos-13 27 | arch: x64 28 | - python-version: "3.9" 29 | os: macos-13 30 | arch: x64 31 | - python-version: "3.10" 32 | os: macos-latest 33 | arch: arm64 34 | - python-version: "3.11" 35 | os: macos-latest 36 | arch: arm64 37 | - python-version: "3.12" 38 | os: macos-latest 39 | arch: arm64 40 | - python-version: "3.13" 41 | os: macos-latest 42 | arch: arm64 43 | 44 | steps: 45 | - uses: actions/checkout@v4 46 | - name: Set up PDM 47 | uses: pdm-project/setup-pdm@v4 48 | with: 49 | python-version: ${{ matrix.python-version }} 50 | architecture: ${{ matrix.arch }} 51 | cache: "true" 52 | 53 | - name: Install packages 54 | run: pdm install 55 | - name: Run Integration 56 | run: pdm run findpython --all -v 57 | - name: Run Tests 58 | run: pdm run pytest tests 59 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - "*" 7 | 8 | jobs: 9 | release-pypi: 10 | name: release-pypi 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v4 15 | with: 16 | fetch-depth: 0 17 | - uses: actions/setup-node@v4 18 | with: 19 | node-version: 20 20 | 21 | - run: npx changelogithub 22 | continue-on-error: true 23 | env: 24 | GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} 25 | - uses: actions/setup-python@v4 26 | with: 27 | python-version: "3.10" 28 | - name: Build artifacts 29 | run: | 30 | pipx run build 31 | - name: Test Build 32 | run: | 33 | python -m venv fresh_env 34 | . fresh_env/bin/activate 35 | pip install dist/*.whl 36 | findpython --all 37 | - name: Upload to Pypi 38 | run: | 39 | pipx run twine upload --username __token__ --password ${{ secrets.PYPI_TOKEN }} dist/* 40 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .pdm-python 2 | *.py[cod] 3 | __pycache__/ 4 | venv/ 5 | .venv/ 6 | .tox/ 7 | .vscode/ 8 | /build/ 9 | /dist/ 10 | *.egg-info/ 11 | /target/ 12 | .pdm-build/ 13 | /src/findpython/__version__.py 14 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | exclude: > 2 | (?x)^( 3 | \.eggs| 4 | \.git| 5 | \.mypy_cache| 6 | \.tox| 7 | \.pyre_configuration| 8 | \.venv| 9 | build| 10 | dist| 11 | src/findpython/_vendor/.*\.py| 12 | src/findpython/pep514tools/_registry\.py 13 | )$ 14 | 15 | repos: 16 | - repo: https://github.com/astral-sh/ruff-pre-commit 17 | rev: 'v0.9.7' 18 | hooks: 19 | - id: ruff 20 | args: [--fix, --exit-non-zero-on-fix, --show-fixes] 21 | 22 | - repo: https://github.com/ambv/black 23 | rev: 24.8.0 24 | hooks: 25 | - id: black 26 | 27 | - repo: https://github.com/pre-commit/mirrors-mypy 28 | rev: v1.4.1 29 | hooks: 30 | - id: mypy 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022-present Frost Ming 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | checkfiles = src/ tests/ 2 | 3 | help: 4 | @echo "FindPython development makefile" 5 | @echo 6 | @echo "Usage: make " 7 | @echo "Targets:" 8 | @echo " up Updates dev/test dependencies" 9 | @echo " deps Ensure dev/test dependencies are installed" 10 | @echo " check Checks that build is sane" 11 | @echo " test Runs all tests" 12 | @echo " style Auto-formats the code" 13 | @echo " lint Auto-formats the code and check type hints" 14 | 15 | up: 16 | pdm update --verbose 17 | 18 | deps: 19 | ifeq ($(wildcard .venv),) 20 | pdm install --verbose 21 | else 22 | pdm install 23 | endif 24 | 25 | _check: 26 | pdm run ruff format --check $(checkfiles) 27 | pdm run ruff check $(checkfiles) 28 | pdm run mypy $(checkfiles) 29 | check: deps _build _check 30 | 31 | _style: 32 | pdm run ruff format $(checkfiles) 33 | pdm run ruff check --fix $(checkfiles) 34 | style: deps _style 35 | 36 | _lint: 37 | pdm run ruff format $(checkfiles) 38 | pdm run ruff check --fix $(checkfiles) 39 | pdm run mypy $(checkfiles) 40 | lint: deps _build _lint 41 | 42 | _test: 43 | pdm run pytest -s tests 44 | test: deps _test 45 | 46 | _build: 47 | rm -fR dist/ 48 | pdm build 49 | build: deps _build 50 | 51 | # Usage:: 52 | # make venv version=3.12 53 | venv: 54 | pdm venv create $(version) 55 | pdm run pip install --upgrade pip 56 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # FindPython 2 | 3 | _A utility to find python versions on your system._ 4 | 5 | [![Tests](https://github.com/frostming/findpython/actions/workflows/ci.yml/badge.svg)](https://github.com/frostming/findpython/actions/workflows/ci.yml) 6 | [![PyPI](https://img.shields.io/pypi/v/findpython?logo=python&logoColor=%23cccccc&style=flat-square)](https://pypi.org/project/findpython) 7 | [![PyPI - Python Version](https://img.shields.io/pypi/pyversions/findpython?logo=python&logoColor=%23cccccc&style=flat-square)](https://pypi.org/project/findpython) 8 | [![pdm-managed](https://img.shields.io/badge/pdm-managed-blueviolet?style=flat-square)](https://github.com/frostming/findpython) 9 | 10 | ## Description 11 | 12 | This library is a rewrite of [pythonfinder] project by [@techalchemy][techalchemy]. 13 | It simplifies the whole code structure while preserving most of the original features. 14 | 15 | [pythonfinder]: https://github.com/sarugaku/pythonfinder 16 | [techalchemy]: https://github.com/techalchemy 17 | 18 | ## Installation 19 | 20 | FindPython is installable via any kind of package manager including `pip`: 21 | 22 | ```bash 23 | pip install findpython 24 | ``` 25 | 26 |
27 | Expand this section to see findpython's availability in the package ecosystem 28 | 29 | 30 | Packaging status 31 | 32 |
33 | 34 | ## Usage 35 | 36 | ```python 37 | >>> import findpython 38 | >>> findpython.find(3, 9) # Find by major and minor version 39 | , architecture='64bit', major=3, minor=9, patch=10> 40 | >>> findpython.find("3.9") # Find by version string 41 | , architecture='64bit', major=3, minor=9, patch=10> 42 | >>> findpython.find("3.9-32") # Find by version string and architecture 43 | , architecture='32bit', major=3, minor=9, patch=10> 44 | >>> findpython.find(name="python3") # Find by executable name 45 | , architecture='64bit', major=3, minor=10, patch=2> 46 | >>> findpython.find("python3") # Find by executable name without keyword argument, same as above 47 | , architecture='64bit', major=3, minor=10, patch=2> 48 | >>> findpython.find_all(major=3, minor=9) # Same arguments as `find()`, but return all matches 49 | [, architecture='64bit', major=3, minor=9, patch=10>, , architecture='64bit', major=3, minor=9, patch=10>, , architecture='64bit', major=3, minor=9, patch=9>, , architecture='64bit', major=3, minor=9, patch=5>, , architecture='64bit', major=3, minor=9, patch=5>] 50 | ``` 51 | 52 | ## CLI Usage 53 | 54 | In addition, FindPython provides a CLI interface to find python versions: 55 | 56 | ``` 57 | usage: findpython [-h] [-V] [-a] [--resolve-symlink] [-v] [--no-same-file] [--no-same-python] [--providers PROVIDERS] 58 | [version_spec] 59 | 60 | A utility to find python versions on your system 61 | 62 | positional arguments: 63 | version_spec Python version spec or name 64 | 65 | options: 66 | -h, --help show this help message and exit 67 | -V, --version show program's version number and exit 68 | -a, --all Show all matching python versions 69 | --resolve-symlink Resolve all symlinks 70 | -v, --verbose Verbose output 71 | --no-same-file Eliminate the duplicated results with the same file contents 72 | --no-same-python Eliminate the duplicated results with the same sys.executable 73 | --providers PROVIDERS 74 | Select provider(s) to use 75 | ``` 76 | 77 | ## Integration 78 | 79 | FindPython finds Python from the following places: 80 | 81 | - `PATH` environment variable 82 | - pyenv install root 83 | - asdf python install root 84 | - [rye](https://rye-up.com) toolchain install root 85 | - [uv](https://docs.astral.sh/uv/) toolchain install root 86 | - `/Library/Frameworks/Python.framework/Versions` (MacOS) 87 | - Windows registry (Windows only) 88 | 89 | ## License 90 | 91 | FindPython is released under MIT License. 92 | -------------------------------------------------------------------------------- /pdm.lock: -------------------------------------------------------------------------------- 1 | # This file is @generated by PDM. 2 | # It is not intended for manual editing. 3 | 4 | [metadata] 5 | groups = ["default", "tests"] 6 | strategy = ["inherit_metadata"] 7 | lock_version = "4.5.0" 8 | content_hash = "sha256:a279fe9d01b3a467d5cc6a13d1b17b2d163850cbce221ddb750028acaaef90cf" 9 | 10 | [[metadata.targets]] 11 | requires_python = ">=3.8" 12 | 13 | [[package]] 14 | name = "colorama" 15 | version = "0.4.6" 16 | requires_python = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" 17 | summary = "Cross-platform colored terminal text." 18 | groups = ["tests"] 19 | marker = "sys_platform == \"win32\"" 20 | files = [ 21 | {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, 22 | {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, 23 | ] 24 | 25 | [[package]] 26 | name = "exceptiongroup" 27 | version = "1.2.2" 28 | requires_python = ">=3.7" 29 | summary = "Backport of PEP 654 (exception groups)" 30 | groups = ["tests"] 31 | marker = "python_version < \"3.11\"" 32 | files = [ 33 | {file = "exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b"}, 34 | {file = "exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc"}, 35 | ] 36 | 37 | [[package]] 38 | name = "iniconfig" 39 | version = "2.0.0" 40 | requires_python = ">=3.7" 41 | summary = "brain-dead simple config-ini parsing" 42 | groups = ["tests"] 43 | files = [ 44 | {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, 45 | {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, 46 | ] 47 | 48 | [[package]] 49 | name = "packaging" 50 | version = "24.2" 51 | requires_python = ">=3.8" 52 | summary = "Core utilities for Python packages" 53 | groups = ["default", "tests"] 54 | files = [ 55 | {file = "packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759"}, 56 | {file = "packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f"}, 57 | ] 58 | 59 | [[package]] 60 | name = "pluggy" 61 | version = "1.5.0" 62 | requires_python = ">=3.8" 63 | summary = "plugin and hook calling mechanisms for python" 64 | groups = ["tests"] 65 | files = [ 66 | {file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"}, 67 | {file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"}, 68 | ] 69 | 70 | [[package]] 71 | name = "pytest" 72 | version = "8.3.4" 73 | requires_python = ">=3.8" 74 | summary = "pytest: simple powerful testing with Python" 75 | groups = ["tests"] 76 | dependencies = [ 77 | "colorama; sys_platform == \"win32\"", 78 | "exceptiongroup>=1.0.0rc8; python_version < \"3.11\"", 79 | "iniconfig", 80 | "packaging", 81 | "pluggy<2,>=1.5", 82 | "tomli>=1; python_version < \"3.11\"", 83 | ] 84 | files = [ 85 | {file = "pytest-8.3.4-py3-none-any.whl", hash = "sha256:50e16d954148559c9a74109af1eaf0c945ba2d8f30f0a3d3335edde19788b6f6"}, 86 | {file = "pytest-8.3.4.tar.gz", hash = "sha256:965370d062bce11e73868e0335abac31b4d3de0e82f4007408d242b4f8610761"}, 87 | ] 88 | 89 | [[package]] 90 | name = "tomli" 91 | version = "2.2.1" 92 | requires_python = ">=3.8" 93 | summary = "A lil' TOML parser" 94 | groups = ["tests"] 95 | marker = "python_version < \"3.11\"" 96 | files = [ 97 | {file = "tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249"}, 98 | {file = "tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6"}, 99 | {file = "tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a"}, 100 | {file = "tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee"}, 101 | {file = "tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e"}, 102 | {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4"}, 103 | {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106"}, 104 | {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8"}, 105 | {file = "tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff"}, 106 | {file = "tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b"}, 107 | {file = "tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea"}, 108 | {file = "tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8"}, 109 | {file = "tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192"}, 110 | {file = "tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222"}, 111 | {file = "tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77"}, 112 | {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6"}, 113 | {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd"}, 114 | {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e"}, 115 | {file = "tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98"}, 116 | {file = "tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4"}, 117 | {file = "tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7"}, 118 | {file = "tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c"}, 119 | {file = "tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13"}, 120 | {file = "tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281"}, 121 | {file = "tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272"}, 122 | {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140"}, 123 | {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2"}, 124 | {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744"}, 125 | {file = "tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec"}, 126 | {file = "tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69"}, 127 | {file = "tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc"}, 128 | {file = "tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff"}, 129 | ] 130 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "findpython" 3 | description = "A utility to find python versions on your system" 4 | authors = [ 5 | {name = "Frost Ming", email = "mianghong@gmail.com"}, 6 | ] 7 | dependencies = [ 8 | "packaging>=20", 9 | ] 10 | requires-python = ">=3.8" 11 | license = {text = "MIT"} 12 | readme = "README.md" 13 | dynamic = ["version"] 14 | 15 | classifiers = [ 16 | "Programming Language :: Python :: 3", 17 | "Programming Language :: Python :: 3.8", 18 | "Programming Language :: Python :: 3.9", 19 | "Programming Language :: Python :: 3.10", 20 | "Programming Language :: Python :: 3.11", 21 | "Programming Language :: Python :: 3.12", 22 | "Programming Language :: Python :: 3.13", 23 | ] 24 | 25 | [project.urls] 26 | Homepage = "https://github.com/frostming/findpython" 27 | 28 | [project.scripts] 29 | findpython = "findpython.__main__:main" 30 | 31 | [tool.pdm.version] 32 | source = "scm" 33 | write_to = "findpython/__version__.py" 34 | write_template = "__version__ = \"{}\"\n" 35 | 36 | [tool.pdm.build] 37 | package-dir = "src" 38 | 39 | [tool.pdm.dev-dependencies] 40 | tests = ["pytest"] 41 | 42 | [tool.pdm.scripts] 43 | test = "pytest tests" 44 | 45 | [build-system] 46 | requires = ["pdm-backend"] 47 | build-backend = "pdm.backend" 48 | 49 | [tool.black] 50 | line-length = 90 51 | include = '\.pyi?$' 52 | exclude = ''' 53 | /( 54 | \.eggs 55 | | \.git 56 | | \.hg 57 | | \.mypy_cache 58 | | \.tox 59 | | \.venv 60 | | build 61 | | dist 62 | | src/pythonfinder/_vendor 63 | ) 64 | ''' 65 | 66 | [tool.ruff] 67 | line-length = 90 68 | src = ["src"] 69 | exclude = ["tests/fixtures"] 70 | target-version = "py38" 71 | 72 | [tool.ruff.lint] 73 | select = [ 74 | "B", # flake8-bugbear 75 | "C4", # flake8-comprehensions 76 | "E", # pycodestyle 77 | "F", # pyflakes 78 | "PGH", # pygrep-hooks 79 | "RUF", # ruff 80 | "W", # pycodestyle 81 | "YTT", # flake8-2020 82 | ] 83 | extend-ignore = ["B018", "B019"] 84 | 85 | [tool.ruff.lint.mccabe] 86 | max-complexity = 10 87 | 88 | [tool.ruff.lint.isort] 89 | known-first-party = ["findpython"] 90 | 91 | [[tool.mypy.overrides]] 92 | module = "_winreg" 93 | ignore_missing_imports = true 94 | -------------------------------------------------------------------------------- /src/findpython/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | FindPython 3 | ~~~~~~~~~~ 4 | A utility to find python versions on your system 5 | """ 6 | 7 | from __future__ import annotations 8 | 9 | from typing import TYPE_CHECKING, TypeVar 10 | 11 | from findpython.finder import Finder 12 | from findpython.providers import ALL_PROVIDERS 13 | from findpython.providers.base import BaseProvider 14 | from findpython.python import PythonVersion 15 | 16 | 17 | def find(*args, **kwargs) -> PythonVersion | None: 18 | """ 19 | Return the Python version that is closest to the given version criteria. 20 | 21 | :param major: The major version or the version string or the name to match. 22 | :param minor: The minor version to match. 23 | :param patch: The micro version to match. 24 | :param pre: Whether the python is a prerelease. 25 | :param dev: Whether the python is a devrelease. 26 | :param name: The name of the python. 27 | :param architecture: The architecture of the python. 28 | :return: a Python object or None 29 | """ 30 | return Finder().find(*args, **kwargs) 31 | 32 | 33 | def find_all(*args, **kwargs) -> list[PythonVersion]: 34 | """ 35 | Return all Python versions matching the given version criteria. 36 | 37 | :param major: The major version or the version string or the name to match. 38 | :param minor: The minor version to match. 39 | :param patch: The micro version to match. 40 | :param pre: Whether the python is a prerelease. 41 | :param dev: Whether the python is a devrelease. 42 | :param name: The name of the python. 43 | :param architecture: The architecture of the python. 44 | :return: a list of PythonVersion objects 45 | """ 46 | return Finder().find_all(*args, **kwargs) 47 | 48 | 49 | if TYPE_CHECKING: 50 | P = TypeVar("P", bound=type[BaseProvider]) 51 | 52 | 53 | def register_provider(provider: P) -> P: 54 | """ 55 | Register a provider to use when finding python versions. 56 | 57 | :param provider: A provider class 58 | """ 59 | ALL_PROVIDERS[provider.name()] = provider 60 | return provider 61 | 62 | 63 | __all__ = ["Finder", "PythonVersion", "find", "find_all", "register_provider"] 64 | -------------------------------------------------------------------------------- /src/findpython/__main__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | import sys 5 | from argparse import ArgumentParser 6 | 7 | from findpython import Finder 8 | from findpython.__version__ import __version__ 9 | 10 | logger = logging.getLogger("findpython") 11 | 12 | 13 | def setup_logger(level: int = logging.DEBUG) -> None: 14 | """ 15 | Setup the logger. 16 | """ 17 | handler = logging.StreamHandler() 18 | handler.setFormatter(logging.Formatter("%(name)s-%(levelname)s: %(message)s")) 19 | logger.addHandler(handler) 20 | logger.setLevel(level) 21 | 22 | 23 | def split_str(value: str) -> list[str]: 24 | return value.split(",") 25 | 26 | 27 | def cli(argv: list[str] | None = None) -> int: 28 | """ 29 | Command line interface for findpython. 30 | """ 31 | parser = ArgumentParser( 32 | "findpython", description="A utility to find python versions on your system" 33 | ) 34 | parser.add_argument( 35 | "-V", "--version", action="version", version=f"%(prog)s {__version__}" 36 | ) 37 | parser.add_argument( 38 | "-a", "--all", action="store_true", help="Show all matching python versions" 39 | ) 40 | parser.add_argument("--path", action="store_true", help="Show the path of the python") 41 | parser.add_argument( 42 | "--resolve-symlink", action="store_true", help="Resolve all symlinks" 43 | ) 44 | parser.add_argument("-v", "--verbose", action="store_true", help="Verbose output") 45 | parser.add_argument( 46 | "--no-same-file", 47 | action="store_true", 48 | help="Eliminate the duplicated results with the same file contents", 49 | ) 50 | parser.add_argument( 51 | "--no-same-python", 52 | action="store_true", 53 | help="Eliminate the duplicated results with the same sys.executable", 54 | ) 55 | parser.add_argument( 56 | "--pre", "--prereleases", action="store_true", help="Allow prereleases" 57 | ) 58 | parser.add_argument("--providers", type=split_str, help="Select provider(s) to use") 59 | parser.add_argument("version_spec", nargs="?", help="Python version spec or name") 60 | 61 | args = parser.parse_args(argv) 62 | if args.verbose: 63 | setup_logger() 64 | 65 | finder = Finder( 66 | resolve_symlinks=args.resolve_symlink, 67 | no_same_file=args.no_same_file, 68 | selected_providers=args.providers, 69 | ) 70 | if args.all: 71 | find_func = finder.find_all 72 | else: 73 | find_func = finder.find # type: ignore[assignment] 74 | 75 | python_versions = find_func(args.version_spec, allow_prereleases=args.pre) 76 | if not python_versions: 77 | print("No matching python version found", file=sys.stderr) 78 | return 1 79 | if not isinstance(python_versions, list): 80 | python_versions = [python_versions] 81 | print("Found matching python versions:", file=sys.stderr) 82 | for python_version in python_versions: 83 | print(python_version.executable if args.path else python_version) 84 | return 0 85 | 86 | 87 | def main() -> None: 88 | """ 89 | Main function. 90 | """ 91 | sys.exit(cli()) 92 | 93 | 94 | if __name__ == "__main__": 95 | main() 96 | -------------------------------------------------------------------------------- /src/findpython/finder.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | import operator 5 | from typing import Callable, Iterable 6 | 7 | from findpython.providers import ALL_PROVIDERS, BaseProvider 8 | from findpython.python import PythonVersion 9 | from findpython.utils import get_suffix_preference, parse_major 10 | 11 | logger = logging.getLogger("findpython") 12 | 13 | 14 | class Finder: 15 | """Find python versions on the system. 16 | 17 | :param resolve_symlinks: Whether to resolve symlinks. 18 | :param no_same_file: Whether to deduplicate with the python executable content. 19 | :param no_same_interpreter: Whether to deduplicate with the python executable path. 20 | """ 21 | 22 | def __init__( 23 | self, 24 | resolve_symlinks: bool = False, 25 | no_same_file: bool = False, 26 | no_same_interpreter: bool = False, 27 | selected_providers: list[str] | None = None, 28 | ) -> None: 29 | self.resolve_symlinks = resolve_symlinks 30 | self.no_same_file = no_same_file 31 | self.no_same_interpreter = no_same_interpreter 32 | self._providers = self.setup_providers(selected_providers) 33 | 34 | def setup_providers( 35 | self, 36 | selected_providers: list[str] | None = None, 37 | ) -> list[BaseProvider]: 38 | providers: list[BaseProvider] = [] 39 | allowed_providers = ALL_PROVIDERS 40 | if selected_providers is not None: 41 | allowed_providers = {name: ALL_PROVIDERS[name] for name in selected_providers} 42 | for provider_class in allowed_providers.values(): 43 | provider = provider_class.create() 44 | if provider is None: 45 | logger.debug("Provider %s is not available", provider_class.__name__) 46 | else: 47 | providers.append(provider) 48 | return providers 49 | 50 | def add_provider(self, provider: BaseProvider, pos: int | None = None) -> None: 51 | """Add provider to the provider list. 52 | If pos is given, it will be inserted at the given position. 53 | """ 54 | if pos is not None: 55 | self._providers.insert(pos, provider) 56 | else: 57 | self._providers.append(provider) 58 | 59 | def find_all( 60 | self, 61 | major: int | str | None = None, 62 | minor: int | None = None, 63 | patch: int | None = None, 64 | pre: bool | None = None, 65 | dev: bool | None = None, 66 | name: str | None = None, 67 | architecture: str | None = None, 68 | allow_prereleases: bool = False, 69 | implementation: str | None = None, 70 | ) -> list[PythonVersion]: 71 | """ 72 | Return all Python versions matching the given version criteria. 73 | 74 | :param major: The major version or the version string or the name to match. 75 | :param minor: The minor version to match. 76 | :param patch: The micro version to match. 77 | :param pre: Whether the python is a prerelease. 78 | :param dev: Whether the python is a devrelease. 79 | :param name: The name of the python. 80 | :param architecture: The architecture of the python. 81 | :param allow_prereleases: Whether to allow prereleases. 82 | :param implementation: The implementation of the python. E.g. "cpython", "pypy". 83 | :return: a list of PythonVersion objects 84 | """ 85 | if allow_prereleases and (pre is False or dev is False): 86 | raise ValueError( 87 | "If allow_prereleases is True, pre and dev must not be False." 88 | ) 89 | if isinstance(major, str): 90 | if any(v is not None for v in (minor, patch, pre, dev, name)): 91 | raise ValueError( 92 | "If major is a string, minor, patch, pre, dev and name " 93 | "must not be specified." 94 | ) 95 | version_dict = parse_major(major) 96 | if version_dict is not None: 97 | major = version_dict["major"] 98 | minor = version_dict["minor"] 99 | patch = version_dict["patch"] 100 | pre = version_dict["pre"] 101 | dev = version_dict["dev"] 102 | if allow_prereleases: 103 | pre = pre or None 104 | dev = dev or None 105 | architecture = version_dict["architecture"] 106 | implementation = version_dict["implementation"] 107 | else: 108 | name, major = major, None 109 | 110 | version_matcher = operator.methodcaller( 111 | "matches", 112 | major, 113 | minor, 114 | patch, 115 | pre, 116 | dev, 117 | name, 118 | architecture, 119 | implementation, 120 | ) 121 | # Deduplicate with the python executable path 122 | matched_python = set(self._find_all_python_versions()) 123 | return self._dedup(matched_python, version_matcher) 124 | 125 | def find( 126 | self, 127 | major: int | str | None = None, 128 | minor: int | None = None, 129 | patch: int | None = None, 130 | pre: bool | None = None, 131 | dev: bool | None = None, 132 | name: str | None = None, 133 | architecture: str | None = None, 134 | allow_prereleases: bool = False, 135 | implementation: str | None = None, 136 | ) -> PythonVersion | None: 137 | """ 138 | Return the Python version that is closest to the given version criteria. 139 | 140 | :param major: The major version or the version string or the name to match. 141 | :param minor: The minor version to match. 142 | :param patch: The micro version to match. 143 | :param pre: Whether the python is a prerelease. 144 | :param dev: Whether the python is a devrelease. 145 | :param name: The name of the python. 146 | :param architecture: The architecture of the python. 147 | :param allow_prereleases: Whether to allow prereleases. 148 | :param implementation: The implementation of the python. E.g. "cpython", "pypy". 149 | :return: a Python object or None 150 | """ 151 | return next( 152 | iter( 153 | self.find_all( 154 | major, 155 | minor, 156 | patch, 157 | pre, 158 | dev, 159 | name, 160 | architecture, 161 | allow_prereleases, 162 | implementation, 163 | ) 164 | ), 165 | None, 166 | ) 167 | 168 | def _find_all_python_versions(self) -> Iterable[PythonVersion]: 169 | """Find all python versions on the system.""" 170 | for provider in self._providers: 171 | yield from provider.find_pythons() 172 | 173 | def _dedup( 174 | self, 175 | python_versions: Iterable[PythonVersion], 176 | version_matcher: Callable[[PythonVersion], bool], 177 | ) -> list[PythonVersion]: 178 | def dedup_key(python_version: PythonVersion) -> str: 179 | if self.no_same_interpreter: 180 | return python_version.interpreter.as_posix() 181 | if self.no_same_file: 182 | return python_version.binary_hash() 183 | if self.resolve_symlinks and not python_version.keep_symlink: 184 | return python_version.real_path.as_posix() 185 | return python_version.executable.as_posix() 186 | 187 | def sort_key(python_version: PythonVersion) -> tuple[int, int, int]: 188 | return ( 189 | python_version.executable.is_symlink(), 190 | get_suffix_preference(python_version.name), 191 | -len(python_version.executable.as_posix()), 192 | ) 193 | 194 | result: dict[str, PythonVersion] = {} 195 | 196 | for python_version in sorted(python_versions, key=sort_key): 197 | key = dedup_key(python_version) 198 | if ( 199 | key not in result 200 | and python_version.is_valid() 201 | and version_matcher(python_version) 202 | ): 203 | result[key] = python_version 204 | return sorted(result.values(), reverse=True) 205 | -------------------------------------------------------------------------------- /src/findpython/pep514tools/__init__.py: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------------------------------- 2 | # Copyright (c) Steve Dower 3 | # All rights reserved. 4 | # 5 | # Distributed under the terms of the MIT License 6 | # ------------------------------------------------------------------------- 7 | 8 | __author__ = "Steve Dower " 9 | __version__ = "0.1.0" 10 | 11 | from findpython.pep514tools.environment import find, findall, findone 12 | 13 | __all__ = ["find", "findall", "findone"] 14 | -------------------------------------------------------------------------------- /src/findpython/pep514tools/__main__.py: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------------------------------- 2 | # Copyright (c) Steve Dower 3 | # All rights reserved. 4 | # 5 | # Distributed under the terms of the MIT License 6 | # ------------------------------------------------------------------------- 7 | -------------------------------------------------------------------------------- /src/findpython/pep514tools/_registry.py: -------------------------------------------------------------------------------- 1 | # mypy: disable-error-code="attr-defined" 2 | # ------------------------------------------------------------------------- 3 | # Copyright (c) Steve Dower 4 | # All rights reserved. 5 | # 6 | # Distributed under the terms of the MIT License 7 | # ------------------------------------------------------------------------- 8 | 9 | __all__ = [ 10 | "REGISTRY_SOURCE_CU", 11 | "REGISTRY_SOURCE_LM", 12 | "REGISTRY_SOURCE_LM_WOW6432", 13 | "open_source", 14 | ] 15 | 16 | import re 17 | from itertools import count 18 | 19 | try: 20 | import winreg 21 | except ImportError: 22 | import _winreg as winreg # type:ignore[no-redef] 23 | 24 | REGISTRY_SOURCE_LM = 1 25 | REGISTRY_SOURCE_LM_WOW6432 = 2 26 | REGISTRY_SOURCE_CU = 3 27 | 28 | _REG_KEY_INFO = { 29 | REGISTRY_SOURCE_LM: ( 30 | winreg.HKEY_LOCAL_MACHINE, 31 | r"Software\Python", 32 | winreg.KEY_WOW64_64KEY, 33 | ), 34 | REGISTRY_SOURCE_LM_WOW6432: ( 35 | winreg.HKEY_LOCAL_MACHINE, 36 | r"Software\Python", 37 | winreg.KEY_WOW64_32KEY, 38 | ), 39 | REGISTRY_SOURCE_CU: (winreg.HKEY_CURRENT_USER, r"Software\Python", 0), 40 | } 41 | 42 | 43 | def get_value_from_tuple(value, vtype): 44 | if vtype == winreg.REG_SZ: 45 | if "\0" in value: 46 | return value[: value.index("\0")] 47 | return value 48 | return None 49 | 50 | 51 | def join(x, y): 52 | return x + "\\" + y 53 | 54 | 55 | _VALID_ATTR = re.compile("^[a-z_]+$") 56 | _VALID_KEY = re.compile("^[A-Za-z]+$") 57 | _KEY_TO_ATTR = re.compile("([A-Z]+[a-z]+)") 58 | 59 | 60 | class PythonWrappedDict(object): 61 | @staticmethod 62 | def _attr_to_key(attr): 63 | if not attr: 64 | return "" 65 | if not _VALID_ATTR.match(attr): 66 | return attr 67 | return "".join(c.capitalize() for c in attr.split("_")) 68 | 69 | @staticmethod 70 | def _key_to_attr(key): 71 | if not key: 72 | return "" 73 | if not _VALID_KEY.match(key): 74 | return key 75 | return "_".join(k for k in _KEY_TO_ATTR.split(key) if k).lower() 76 | 77 | def __init__(self, d): 78 | self._d = d 79 | 80 | def __getattr__(self, attr): 81 | if attr.startswith("_"): 82 | return object.__getattribute__(self, attr) 83 | 84 | if attr == "value": 85 | attr = "" 86 | 87 | key = self._attr_to_key(attr) 88 | try: 89 | return self._d[key] 90 | except Exception: 91 | pass 92 | raise AttributeError(attr) 93 | 94 | def __setattr__(self, attr, value): 95 | if attr.startswith("_"): 96 | return object.__setattr__(self, attr, value) 97 | 98 | if attr == "value": 99 | attr = "" 100 | self._d[self._attr_to_key(attr)] = value 101 | 102 | def __dir__(self): 103 | k2a = self._key_to_attr 104 | return list(map(k2a, self._d)) 105 | 106 | def _setdefault(self, key, value): 107 | self._d.setdefault(key, value) 108 | 109 | def _items(self): 110 | return self._d.items() 111 | 112 | def __repr__(self): 113 | k2a = self._key_to_attr 114 | return ( 115 | "info(" 116 | + ", ".join("{}={!r}".format(k2a(k), v) for k, v in self._d.items()) 117 | + ")" 118 | ) 119 | 120 | 121 | class RegistryAccessor(object): 122 | def __init__(self, root, subkey, flags): 123 | self._root = root 124 | self.subkey = subkey 125 | _, _, self.name = subkey.rpartition("\\") 126 | self._flags = flags 127 | 128 | def __iter__(self): 129 | subkey_names = [] 130 | try: 131 | with winreg.OpenKeyEx( 132 | self._root, self.subkey, 0, winreg.KEY_READ | self._flags 133 | ) as key: 134 | for i in count(): 135 | subkey_names.append(winreg.EnumKey(key, i)) 136 | except OSError: 137 | pass 138 | return iter(self[k] for k in subkey_names) 139 | 140 | def __getitem__(self, key): 141 | return RegistryAccessor(self._root, join(self.subkey, key), self._flags) 142 | 143 | def get_value(self, value_name): 144 | try: 145 | with winreg.OpenKeyEx( 146 | self._root, self.subkey, 0, winreg.KEY_READ | self._flags 147 | ) as key: 148 | return get_value_from_tuple(*winreg.QueryValueEx(key, value_name)) 149 | except OSError: 150 | return None 151 | 152 | def get_all_values(self): 153 | schema = {} 154 | for subkey in self: 155 | schema[subkey.name] = subkey.get_all_values() 156 | 157 | key = winreg.OpenKeyEx(self._root, self.subkey, 0, winreg.KEY_READ | self._flags) 158 | try: 159 | with key: 160 | for i in count(): 161 | vname, value, vtype = winreg.EnumValue(key, i) 162 | value = get_value_from_tuple(value, vtype) 163 | if value: 164 | schema[vname or ""] = value 165 | except OSError: 166 | pass 167 | 168 | return PythonWrappedDict(schema) 169 | 170 | def set_value(self, value_name, value): 171 | with winreg.CreateKeyEx( 172 | self._root, self.subkey, 0, winreg.KEY_WRITE | self._flags 173 | ) as key: 174 | if value is None: 175 | winreg.DeleteValue(key, value_name) 176 | elif isinstance(value, str): 177 | winreg.SetValueEx(key, value_name, 0, winreg.REG_SZ, value) 178 | else: 179 | raise TypeError("cannot write {} to registry".format(type(value))) 180 | 181 | def _set_all_values(self, rootkey, name, info, errors): 182 | with winreg.CreateKeyEx(rootkey, name, 0, winreg.KEY_WRITE | self._flags) as key: 183 | for k, v in info: 184 | if isinstance(v, PythonWrappedDict): 185 | self._set_all_values(key, k, v._items(), errors) 186 | elif isinstance(v, dict): 187 | self._set_all_values(key, k, v.items(), errors) 188 | elif v is None: 189 | winreg.DeleteValue(key, k) 190 | elif isinstance(v, str): 191 | winreg.SetValueEx(key, k, 0, winreg.REG_SZ, v) 192 | else: 193 | errors.append("cannot write {} to registry".format(type(v))) 194 | 195 | def set_all_values(self, info): 196 | errors = [] 197 | if isinstance(info, PythonWrappedDict): 198 | items = info._items() 199 | elif isinstance(info, dict): 200 | items = info.items() 201 | else: 202 | raise TypeError("info must be a dictionary") 203 | 204 | self._set_all_values(self._root, self.subkey, items, errors) 205 | if len(errors) == 1: 206 | raise ValueError(errors[0]) 207 | elif errors: 208 | raise ValueError(errors) 209 | 210 | def delete(self): 211 | for k in self: 212 | k.delete() 213 | try: 214 | key = winreg.OpenKeyEx(self._root, None, 0, winreg.KEY_READ | self._flags) 215 | except OSError: 216 | return 217 | with key: 218 | winreg.DeleteKeyEx(key, self.subkey) 219 | 220 | 221 | def open_source(registry_source): 222 | info = _REG_KEY_INFO.get(registry_source) 223 | if not info: 224 | raise ValueError("unsupported registry source") 225 | root, subkey, flags = info 226 | return RegistryAccessor(root, subkey, flags) 227 | -------------------------------------------------------------------------------- /src/findpython/pep514tools/environment.py: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------------------------------- 2 | # Copyright (c) Steve Dower 3 | # All rights reserved. 4 | # 5 | # Distributed under the terms of the MIT License 6 | # ------------------------------------------------------------------------- 7 | 8 | __all__ = ["Environment", "find", "findall", "findone"] 9 | 10 | import sys 11 | 12 | from findpython.pep514tools._registry import ( 13 | REGISTRY_SOURCE_CU, 14 | REGISTRY_SOURCE_LM, 15 | REGISTRY_SOURCE_LM_WOW6432, 16 | open_source, 17 | ) 18 | 19 | # These tags are treated specially when the Company is 'PythonCore' 20 | _PYTHONCORE_COMPATIBILITY_TAGS = { 21 | "2.0", 22 | "2.1", 23 | "2.2", 24 | "2.3", 25 | "2.4", 26 | "2.5", 27 | "2.6", 28 | "2.7", 29 | "3.0", 30 | "3.1", 31 | "3.2", 32 | "3.3", 33 | "3.4", 34 | } 35 | 36 | _IS_64BIT_OS = None 37 | 38 | 39 | def _is_64bit_os(): 40 | global _IS_64BIT_OS 41 | if _IS_64BIT_OS is None: 42 | if sys.maxsize > 2**32: 43 | import platform 44 | 45 | _IS_64BIT_OS = platform.machine() == "AMD64" 46 | else: 47 | _IS_64BIT_OS = False 48 | return _IS_64BIT_OS 49 | 50 | 51 | class Environment(object): 52 | def __init__(self, source, company, tag, guessed_arch=None): 53 | self._source = source 54 | self.company = company 55 | self.tag = tag 56 | self._guessed_arch = guessed_arch 57 | self._orig_info = company, tag 58 | self.info = {} 59 | 60 | def load(self): 61 | if not self._source: 62 | raise ValueError("Environment not initialized with a source") 63 | self.info = info = self._source[self.company][self.tag].get_all_values() 64 | if self.company == "PythonCore": 65 | info._setdefault("DisplayName", "Python " + self.tag) 66 | info._setdefault("SupportUrl", "http://www.python.org/") 67 | info._setdefault("Version", self.tag[:3]) 68 | info._setdefault("SysVersion", self.tag[:3]) 69 | if self._guessed_arch: 70 | info._setdefault("SysArchitecture", self._guessed_arch) 71 | 72 | def save(self, copy=False): 73 | if not self._source: 74 | raise ValueError("Environment not initialized with a source") 75 | if (self.company, self.tag) != self._orig_info: 76 | if not copy: 77 | self._source[self._orig_info[0]][self._orig_info[1]].delete() 78 | self._orig_info = self.company, self.tag 79 | 80 | src = self._source[self.company][self.tag] 81 | src.set_all_values(self.info) 82 | 83 | self.info = src.get_all_values() 84 | 85 | def delete(self): 86 | if (self.company, self.tag) != self._orig_info: 87 | raise ValueError( 88 | "cannot delete Environment when company/tag have been modified" 89 | ) 90 | 91 | if not self._source: 92 | raise ValueError("Environment not initialized with a source") 93 | self._source.delete() 94 | 95 | def __repr__(self): 96 | return "".format(self.company, self.tag) 97 | 98 | 99 | def _get_sources(include_per_machine=True, include_per_user=True): 100 | if _is_64bit_os(): 101 | if include_per_user: 102 | yield open_source(REGISTRY_SOURCE_CU), None 103 | if include_per_machine: 104 | yield open_source(REGISTRY_SOURCE_LM), "64bit" 105 | yield open_source(REGISTRY_SOURCE_LM_WOW6432), "32bit" 106 | else: 107 | if include_per_user: 108 | yield open_source(REGISTRY_SOURCE_CU), "32bit" 109 | if include_per_machine: 110 | yield open_source(REGISTRY_SOURCE_LM), "32bit" 111 | 112 | 113 | def findall(include_per_machine=True, include_per_user=True): 114 | for src, arch in _get_sources( 115 | include_per_machine=include_per_machine, include_per_user=include_per_user 116 | ): 117 | for company in src: 118 | for tag in company: 119 | try: 120 | env = Environment(src, company.name, tag.name, arch) 121 | env.load() 122 | except OSError: 123 | pass 124 | else: 125 | yield env 126 | 127 | 128 | def find( 129 | company_or_tag, 130 | tag=None, 131 | include_per_machine=True, 132 | include_per_user=True, 133 | maxcount=None, 134 | ): 135 | if not tag: 136 | env = Environment(None, "PythonCore", company_or_tag) 137 | else: 138 | env = Environment(None, company_or_tag, tag) 139 | 140 | results = [] 141 | for src, arch in _get_sources( 142 | include_per_machine=include_per_machine, include_per_user=include_per_user 143 | ): 144 | try: 145 | env._source = src 146 | env._guessed_arch = arch 147 | env.load() 148 | except OSError: 149 | pass 150 | else: 151 | results.append(env) 152 | return results 153 | 154 | 155 | def findone(company_or_tag, tag=None, include_per_machine=True, include_per_user=True): 156 | found = find(company_or_tag, tag, include_per_machine, include_per_user, maxcount=1) 157 | if found: 158 | return found[0] 159 | -------------------------------------------------------------------------------- /src/findpython/providers/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | This package contains all the providers for the pythonfinder module. 3 | """ 4 | 5 | from __future__ import annotations 6 | 7 | from findpython.providers.asdf import AsdfProvider 8 | from findpython.providers.base import BaseProvider 9 | from findpython.providers.macos import MacOSProvider 10 | from findpython.providers.path import PathProvider 11 | from findpython.providers.pyenv import PyenvProvider 12 | from findpython.providers.rye import RyeProvider 13 | from findpython.providers.uv import UvProvider 14 | from findpython.providers.winreg import WinregProvider 15 | 16 | _providers: list[type[BaseProvider]] = [ 17 | # General: 18 | PathProvider, 19 | # Tool Specific: 20 | AsdfProvider, 21 | PyenvProvider, 22 | RyeProvider, 23 | UvProvider, 24 | # Windows only: 25 | WinregProvider, 26 | # MacOS only: 27 | MacOSProvider, 28 | ] 29 | 30 | ALL_PROVIDERS = {cls.name(): cls for cls in _providers} 31 | 32 | __all__ = [cls.__name__ for cls in _providers] + ["ALL_PROVIDERS", "BaseProvider"] 33 | -------------------------------------------------------------------------------- /src/findpython/providers/asdf.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import os 4 | from pathlib import Path 5 | from typing import TYPE_CHECKING 6 | 7 | from findpython.providers.base import BaseProvider 8 | from findpython.python import PythonVersion 9 | 10 | if TYPE_CHECKING: 11 | import sys 12 | from typing import Iterable 13 | 14 | if sys.version_info >= (3, 11): 15 | from typing import Self 16 | else: 17 | from typing_extensions import Self 18 | 19 | 20 | class AsdfProvider(BaseProvider): 21 | """A provider that finds python installed with asdf""" 22 | 23 | def __init__(self, root: Path) -> None: 24 | self.root = root 25 | 26 | @classmethod 27 | def create(cls) -> Self | None: 28 | asdf_root = os.path.expanduser( 29 | os.path.expandvars(os.getenv("ASDF_DATA_DIR", "~/.asdf")) 30 | ) 31 | if not os.path.exists(asdf_root): 32 | return None 33 | return cls(Path(asdf_root)) 34 | 35 | def find_pythons(self) -> Iterable[PythonVersion]: 36 | python_dir = self.root / "installs/python" 37 | if not python_dir.exists(): 38 | return 39 | for version in python_dir.iterdir(): 40 | if version.is_dir(): 41 | bindir = version / "bin" 42 | if not bindir.exists(): 43 | bindir = version 44 | yield from self.find_pythons_from_path(bindir, True) 45 | -------------------------------------------------------------------------------- /src/findpython/providers/base.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import abc 4 | import logging 5 | from pathlib import Path 6 | from typing import TYPE_CHECKING 7 | 8 | from findpython.python import PythonVersion 9 | from findpython.utils import path_is_python, safe_iter_dir 10 | 11 | if TYPE_CHECKING: 12 | import sys 13 | from typing import Callable, Iterable 14 | 15 | if sys.version_info >= (3, 11): 16 | from typing import Self 17 | else: 18 | from typing_extensions import Self 19 | 20 | logger = logging.getLogger("findpython") 21 | 22 | 23 | class BaseProvider(metaclass=abc.ABCMeta): 24 | """The base class for python providers""" 25 | 26 | version_maker: Callable[..., PythonVersion] = PythonVersion 27 | 28 | @classmethod 29 | def name(cls) -> str: 30 | """Configuration name for this provider. 31 | 32 | By default, the lowercase class name with 'provider' removed. 33 | """ 34 | self_name = cls.__name__.lower() 35 | if self_name.endswith("provider"): 36 | self_name = self_name[: -len("provider")] 37 | return self_name 38 | 39 | @classmethod 40 | @abc.abstractmethod 41 | def create(cls) -> Self | None: 42 | """Return an instance of the provider or None if it is not available""" 43 | pass 44 | 45 | @abc.abstractmethod 46 | def find_pythons(self) -> Iterable[PythonVersion]: 47 | """Return the python versions found by the provider""" 48 | pass 49 | 50 | @classmethod 51 | def find_pythons_from_path( 52 | cls, path: Path, as_interpreter: bool = False 53 | ) -> Iterable[PythonVersion]: 54 | """A general helper method to return pythons under a given path. 55 | 56 | :param path: The path to search for pythons 57 | :param as_interpreter: Use the path as the interpreter path. 58 | If the pythons might be a wrapper script, don't set this to True. 59 | :returns: An iterable of PythonVersion objects 60 | """ 61 | return ( 62 | cls.version_maker( 63 | child.absolute(), 64 | _interpreter=child.absolute() if as_interpreter else None, 65 | ) 66 | for child in safe_iter_dir(path) 67 | if path_is_python(child) 68 | ) 69 | -------------------------------------------------------------------------------- /src/findpython/providers/macos.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from pathlib import Path 4 | from typing import TYPE_CHECKING 5 | 6 | from findpython.providers.base import BaseProvider 7 | from findpython.python import PythonVersion 8 | 9 | if TYPE_CHECKING: 10 | import sys 11 | from typing import Iterable 12 | 13 | if sys.version_info >= (3, 11): 14 | from typing import Self 15 | else: 16 | from typing_extensions import Self 17 | 18 | 19 | class MacOSProvider(BaseProvider): 20 | """A provider that finds python from macos typical install base 21 | with python.org installer. 22 | """ 23 | 24 | INSTALL_BASE = Path("/Library/Frameworks/Python.framework/Versions/") 25 | 26 | @classmethod 27 | def create(cls) -> Self | None: 28 | if not cls.INSTALL_BASE.exists(): 29 | return None 30 | return cls() 31 | 32 | def find_pythons(self) -> Iterable[PythonVersion]: 33 | for version in self.INSTALL_BASE.iterdir(): 34 | if version.is_dir(): 35 | yield from self.find_pythons_from_path(version / "bin", True) 36 | -------------------------------------------------------------------------------- /src/findpython/providers/path.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import os 4 | from dataclasses import dataclass 5 | from pathlib import Path 6 | from typing import TYPE_CHECKING 7 | 8 | from findpython.providers.base import BaseProvider 9 | from findpython.python import PythonVersion 10 | 11 | if TYPE_CHECKING: 12 | import sys 13 | from typing import Iterable 14 | 15 | if sys.version_info >= (3, 11): 16 | from typing import Self 17 | else: 18 | from typing_extensions import Self 19 | 20 | 21 | @dataclass 22 | class PathProvider(BaseProvider): 23 | """A provider that finds Python from PATH env.""" 24 | 25 | paths: list[Path] 26 | 27 | @classmethod 28 | def create(cls) -> Self | None: 29 | paths = [Path(path) for path in os.getenv("PATH", "").split(os.pathsep) if path] 30 | return cls(paths) 31 | 32 | def find_pythons(self) -> Iterable[PythonVersion]: 33 | for path in self.paths: 34 | yield from self.find_pythons_from_path(path) 35 | -------------------------------------------------------------------------------- /src/findpython/providers/pyenv.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import os 4 | from pathlib import Path 5 | from typing import TYPE_CHECKING 6 | 7 | from findpython.providers.base import BaseProvider 8 | from findpython.python import PythonVersion 9 | 10 | if TYPE_CHECKING: 11 | import sys 12 | from typing import Iterable 13 | 14 | if sys.version_info >= (3, 11): 15 | from typing import Self 16 | else: 17 | from typing_extensions import Self 18 | 19 | 20 | class PyenvProvider(BaseProvider): 21 | """A provider that finds python installed with pyenv""" 22 | 23 | def __init__(self, root: Path) -> None: 24 | self.root = root 25 | 26 | @classmethod 27 | def create(cls) -> Self | None: 28 | pyenv_root = os.path.expanduser( 29 | os.path.expandvars(os.getenv("PYENV_ROOT", "~/.pyenv")) 30 | ) 31 | if not os.path.exists(pyenv_root): 32 | return None 33 | return cls(Path(pyenv_root)) 34 | 35 | def find_pythons(self) -> Iterable[PythonVersion]: 36 | versions_path = self.root.joinpath("versions") 37 | if versions_path.exists(): 38 | for version in versions_path.iterdir(): 39 | if version.is_dir(): 40 | bindir = version / "bin" 41 | if not bindir.exists(): 42 | bindir = version 43 | yield from self.find_pythons_from_path(bindir, True) 44 | -------------------------------------------------------------------------------- /src/findpython/providers/rye.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import os 4 | from pathlib import Path 5 | from typing import TYPE_CHECKING 6 | 7 | from findpython.providers.base import BaseProvider 8 | from findpython.python import PythonVersion 9 | from findpython.utils import WINDOWS, safe_iter_dir 10 | 11 | if TYPE_CHECKING: 12 | import sys 13 | from typing import Iterable 14 | 15 | if sys.version_info >= (3, 11): 16 | from typing import Self 17 | else: 18 | from typing_extensions import Self 19 | 20 | 21 | class RyeProvider(BaseProvider): 22 | def __init__(self, root: Path) -> None: 23 | self.root = root 24 | 25 | @classmethod 26 | def create(cls) -> Self | None: 27 | root = Path(os.getenv("RYE_PY_ROOT", "~/.rye/py")).expanduser() 28 | return cls(root) 29 | 30 | def find_pythons(self) -> Iterable[PythonVersion]: 31 | if not self.root.exists(): 32 | return 33 | for child in safe_iter_dir(self.root): 34 | for intermediate in ("", "install/"): 35 | if WINDOWS: 36 | python_bin = child / (intermediate + "python.exe") 37 | else: 38 | python_bin = child / (intermediate + "bin/python3") 39 | if python_bin.exists(): 40 | yield self.version_maker(python_bin, _interpreter=python_bin) 41 | break 42 | -------------------------------------------------------------------------------- /src/findpython/providers/uv.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import os 4 | import typing as t 5 | from pathlib import Path 6 | 7 | from findpython.providers.rye import RyeProvider 8 | from findpython.utils import WINDOWS 9 | 10 | 11 | class UvProvider(RyeProvider): 12 | @classmethod 13 | def create(cls) -> t.Self | None: 14 | # See uv#13877(https://github.com/astral-sh/uv/issues/13877) 15 | if WINDOWS: 16 | default_root_str = os.getenv("APPDATA") 17 | else: 18 | default_root_str = "~/.local/share" 19 | assert default_root_str is not None 20 | root_str = os.getenv("UV_PYTHON_INSTALL_DIR") 21 | if root_str is None: 22 | root_str = os.getenv("XDG_DATA_HOME") 23 | if root_str is None: 24 | root_str = default_root_str 25 | root = Path(root_str).expanduser() / "uv" / "python" 26 | else: 27 | root = Path(root_str).expanduser() 28 | return cls(root) 29 | -------------------------------------------------------------------------------- /src/findpython/providers/winreg.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import platform 4 | from pathlib import Path 5 | from typing import TYPE_CHECKING 6 | 7 | from packaging.version import Version 8 | 9 | from findpython.providers.base import BaseProvider 10 | from findpython.python import PythonVersion 11 | from findpython.utils import WINDOWS 12 | 13 | if TYPE_CHECKING: 14 | import sys 15 | from typing import Iterable 16 | 17 | if sys.version_info >= (3, 11): 18 | from typing import Self 19 | else: 20 | from typing_extensions import Self 21 | 22 | SYS_ARCHITECTURE = platform.architecture()[0] 23 | 24 | 25 | class WinregProvider(BaseProvider): 26 | """A provider that finds Python from the winreg.""" 27 | 28 | @classmethod 29 | def create(cls) -> Self | None: 30 | if not WINDOWS: 31 | return None 32 | return cls() 33 | 34 | def find_pythons(self) -> Iterable[PythonVersion]: 35 | from findpython.pep514tools import findall as pep514_findall 36 | 37 | env_versions = pep514_findall() 38 | for version in env_versions: 39 | install_path = getattr(version.info, "install_path", None) 40 | if install_path is None: 41 | continue 42 | try: 43 | path = Path(install_path.executable_path) 44 | except AttributeError: 45 | continue 46 | if path.exists(): 47 | py_version = getattr(version.info, "version", None) 48 | parse_version: Version | None = None 49 | if py_version: 50 | try: 51 | parse_version = Version(py_version) 52 | except ValueError: 53 | pass 54 | py_ver = self.version_maker( 55 | path, 56 | parse_version, 57 | getattr(version.info, "sys_architecture", SYS_ARCHITECTURE), 58 | path, 59 | ) 60 | yield py_ver 61 | -------------------------------------------------------------------------------- /src/findpython/python.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import dataclasses as dc 4 | import logging 5 | import os 6 | import subprocess 7 | from functools import lru_cache 8 | from pathlib import Path 9 | 10 | from packaging.version import InvalidVersion, Version 11 | 12 | from findpython.utils import get_binary_hash 13 | 14 | logger = logging.getLogger("findpython") 15 | GET_VERSION_TIMEOUT = float(os.environ.get("FINDPYTHON_GET_VERSION_TIMEOUT", 5)) 16 | 17 | 18 | @lru_cache(maxsize=1024) 19 | def _run_script(executable: str, script: str, timeout: float | None = None) -> str: 20 | """Run a script and return the output.""" 21 | command = [executable, "-EsSc", script] 22 | logger.debug("Running script: %s", command) 23 | return subprocess.run( 24 | command, 25 | stdout=subprocess.PIPE, 26 | stderr=subprocess.DEVNULL, 27 | timeout=timeout, 28 | check=True, 29 | text=True, 30 | ).stdout 31 | 32 | 33 | @dc.dataclass 34 | class PythonVersion: 35 | """The single Python version object found by pythonfinder.""" 36 | 37 | executable: Path 38 | _version: Version | None = None 39 | _architecture: str | None = None 40 | _interpreter: Path | None = None 41 | keep_symlink: bool = False 42 | 43 | def is_valid(self) -> bool: 44 | """Return True if the python is not broken.""" 45 | try: 46 | v = self._get_version() 47 | except ( 48 | OSError, 49 | subprocess.CalledProcessError, 50 | subprocess.TimeoutExpired, 51 | InvalidVersion, 52 | ): 53 | return False 54 | if self._version is None: 55 | self._version = v 56 | return True 57 | 58 | @property 59 | def real_path(self) -> Path: 60 | """Resolve the symlink if possible and return the real path.""" 61 | try: 62 | return self.executable.resolve() 63 | except OSError: 64 | return self.executable 65 | 66 | @property 67 | def implementation(self) -> str: 68 | """Return the implementation of the python.""" 69 | script = "import platform; print(platform.python_implementation())" 70 | return _run_script(str(self.executable), script).strip() 71 | 72 | @property 73 | def name(self) -> str: 74 | """Return the name of the python.""" 75 | return self.executable.name 76 | 77 | @property 78 | def interpreter(self) -> Path: 79 | if self._interpreter is None: 80 | self._interpreter = Path(self._get_interpreter()) 81 | return self._interpreter 82 | 83 | @property 84 | def version(self) -> Version: 85 | """Return the version of the python.""" 86 | if self._version is None: 87 | self._version = self._get_version() 88 | return self._version 89 | 90 | @property 91 | def major(self) -> int: 92 | """Return the major version of the python.""" 93 | return self.version.major 94 | 95 | @property 96 | def minor(self) -> int: 97 | """Return the minor version of the python.""" 98 | return self.version.minor 99 | 100 | @property 101 | def patch(self) -> int: 102 | """Return the micro version of the python.""" 103 | return self.version.micro 104 | 105 | @property 106 | def is_prerelease(self) -> bool: 107 | """Return True if the python is a prerelease.""" 108 | return self.version.is_prerelease 109 | 110 | @property 111 | def is_devrelease(self) -> bool: 112 | """Return True if the python is a devrelease.""" 113 | return self.version.is_devrelease 114 | 115 | @property 116 | def architecture(self) -> str: 117 | if not self._architecture: 118 | self._architecture = self._get_architecture() 119 | return self._architecture 120 | 121 | def binary_hash(self) -> str: 122 | """Return the binary hash of the python.""" 123 | return get_binary_hash(self.real_path) 124 | 125 | def matches( 126 | self, 127 | major: int | None = None, 128 | minor: int | None = None, 129 | patch: int | None = None, 130 | pre: bool | None = None, 131 | dev: bool | None = None, 132 | name: str | None = None, 133 | architecture: str | None = None, 134 | implementation: str | None = None, 135 | ) -> bool: 136 | """ 137 | Return True if the python matches the provided criteria. 138 | 139 | :param major: The major version to match. 140 | :type major: int 141 | :param minor: The minor version to match. 142 | :type minor: int 143 | :param patch: The micro version to match. 144 | :type patch: int 145 | :param pre: Whether the python is a prerelease. 146 | :type pre: bool 147 | :param dev: Whether the python is a devrelease. 148 | :type dev: bool 149 | :param name: The name of the python. 150 | :type name: str 151 | :param architecture: The architecture of the python. 152 | :type architecture: str 153 | :param implementation: The implementation of the python. 154 | :type implementation: str 155 | :return: Whether the python matches the provided criteria. 156 | :rtype: bool 157 | """ 158 | if major is not None and self.major != major: 159 | return False 160 | if minor is not None and self.minor != minor: 161 | return False 162 | if patch is not None and self.patch != patch: 163 | return False 164 | if pre is not None and self.is_prerelease != pre: 165 | return False 166 | if dev is not None and self.is_devrelease != dev: 167 | return False 168 | if name is not None and self.name != name: 169 | return False 170 | if architecture is not None and self.architecture != architecture: 171 | return False 172 | if ( 173 | implementation is not None 174 | and self.implementation.lower() != implementation.lower() 175 | ): 176 | return False 177 | return True 178 | 179 | def __hash__(self) -> int: 180 | return hash(self.executable) 181 | 182 | def __repr__(self) -> str: 183 | attrs = ( 184 | "executable", 185 | "version", 186 | "architecture", 187 | "implementation", 188 | "major", 189 | "minor", 190 | "patch", 191 | ) 192 | return "".format( 193 | ", ".join(f"{attr}={getattr(self, attr)!r}" for attr in attrs) 194 | ) 195 | 196 | def __str__(self) -> str: 197 | return f"{self.implementation:>9}@{self.version}: {self.executable}" 198 | 199 | def _get_version(self) -> Version: 200 | """Get the version of the python.""" 201 | script = "import platform; print(platform.python_version())" 202 | version = _run_script( 203 | str(self.executable), script, timeout=GET_VERSION_TIMEOUT 204 | ).strip() 205 | # Dev builds may produce version like `3.11.0+` and packaging.version 206 | # will reject it. Here we just remove the part after `+` 207 | # since it isn't critical for version comparison. 208 | version = version.split("+")[0] 209 | return Version(version) 210 | 211 | def _get_architecture(self) -> str: 212 | script = "import platform; print(platform.architecture()[0])" 213 | return _run_script(str(self.executable), script).strip() 214 | 215 | def _get_interpreter(self) -> str: 216 | script = "import sys; print(sys.executable)" 217 | return _run_script(str(self.executable), script).strip() 218 | 219 | def __lt__(self, other: PythonVersion) -> bool: 220 | """Sort by the version, then by length of the executable path.""" 221 | return ( 222 | self.version, 223 | int(self.architecture.startswith("64bit")), 224 | len(self.executable.as_posix()), 225 | ) < ( 226 | other.version, 227 | int(other.architecture.startswith("64bit")), 228 | len(other.executable.as_posix()), 229 | ) 230 | -------------------------------------------------------------------------------- /src/findpython/utils.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import errno 4 | import hashlib 5 | import os 6 | import re 7 | import sys 8 | from functools import lru_cache 9 | from pathlib import Path 10 | from typing import TYPE_CHECKING, cast 11 | 12 | if TYPE_CHECKING: 13 | from typing import Generator, Sequence, TypedDict 14 | 15 | VERSION_RE = re.compile( 16 | r"(?:(?P\w+)@)?(?P\d+)(?:\.(?P\d+)(?:\.(?P[0-9]+))?)?\.?" 17 | r"(?:(?P[abc]|rc|dev)(?:(?P\d+(?:\.\d+)*))?)" 18 | r"?(?P(\.post(?P\d+))?(\.dev(?P\d+))?)?" 19 | r"(?:-(?P32|64))?" 20 | ) 21 | WINDOWS = sys.platform == "win32" 22 | MACOS = sys.platform == "darwin" 23 | PYTHON_IMPLEMENTATIONS = ( 24 | "python", 25 | "ironpython", 26 | "jython", 27 | "pypy", 28 | "anaconda", 29 | "miniconda", 30 | "stackless", 31 | "activepython", 32 | "pyston", 33 | "micropython", 34 | ) 35 | if WINDOWS: 36 | KNOWN_EXTS: Sequence[str] = (".exe", "", ".py", ".bat") 37 | else: 38 | KNOWN_EXTS = ("", ".sh", ".bash", ".csh", ".zsh", ".fish", ".py") 39 | PY_MATCH_STR = ( 40 | r"((?P{0})(?:\d(?:\.?\d\d?[cpm]{{0,3}})?)?" 41 | r"(?:(?<=\d)-[\d\.]+)*(?!w))(?P{1})$".format( 42 | "|".join(PYTHON_IMPLEMENTATIONS), 43 | "|".join(KNOWN_EXTS), 44 | ) 45 | ) 46 | RE_MATCHER = re.compile(PY_MATCH_STR) 47 | 48 | 49 | def safe_iter_dir(path: Path) -> Generator[Path, None, None]: 50 | """Iterate over a directory, returning an empty iterator if the path 51 | is not a directory or is not readable. 52 | """ 53 | if not os.access(str(path), os.R_OK) or not path.is_dir(): 54 | return 55 | try: 56 | yield from path.iterdir() 57 | except OSError as exc: 58 | if exc.errno == errno.EACCES: 59 | return 60 | raise 61 | 62 | 63 | @lru_cache(maxsize=1024) 64 | def path_is_known_executable(path: Path) -> bool: 65 | """ 66 | Returns whether a given path is a known executable from known executable extensions 67 | or has the executable bit toggled. 68 | 69 | :param path: The path to the target executable. 70 | :type path: :class:`~Path` 71 | :return: True if the path has chmod +x, or is a readable, known executable extension. 72 | :rtype: bool 73 | """ 74 | try: 75 | return ( 76 | path.is_file() 77 | and os.access(str(path), os.R_OK) 78 | and (path.suffix in KNOWN_EXTS or os.access(str(path), os.X_OK)) 79 | ) 80 | except OSError: 81 | return False 82 | 83 | 84 | @lru_cache(maxsize=1024) 85 | def looks_like_python(name: str) -> bool: 86 | """ 87 | Determine whether the supplied filename looks like a possible name of python. 88 | 89 | :param str name: The name of the provided file. 90 | :return: Whether the provided name looks like python. 91 | :rtype: bool 92 | """ 93 | if not any(name.lower().startswith(py_name) for py_name in PYTHON_IMPLEMENTATIONS): 94 | return False 95 | match = RE_MATCHER.match(name) 96 | return bool(match) 97 | 98 | 99 | @lru_cache(maxsize=1024) 100 | def path_is_python(path: Path) -> bool: 101 | """ 102 | Determine whether the supplied path is a executable and looks like 103 | a possible path to python. 104 | 105 | :param path: The path to an executable. 106 | :type path: :class:`~Path` 107 | :return: Whether the provided path is an executable path to python. 108 | :rtype: bool 109 | """ 110 | return looks_like_python(path.name) and path_is_known_executable(path) 111 | 112 | 113 | @lru_cache(maxsize=1024) 114 | def get_binary_hash(path: Path) -> str: 115 | """Return the MD5 hash of the given file.""" 116 | hasher = hashlib.md5() 117 | with path.open("rb") as f: 118 | for chunk in iter(lambda: f.read(4096), b""): 119 | hasher.update(chunk) 120 | return hasher.hexdigest() 121 | 122 | 123 | if TYPE_CHECKING: 124 | 125 | class VersionDict(TypedDict): 126 | pre: bool 127 | dev: bool 128 | major: int | None 129 | minor: int | None 130 | patch: int | None 131 | architecture: str | None 132 | implementation: str | None 133 | 134 | 135 | def parse_major(version: str) -> VersionDict | None: 136 | """Parse the version dict from the version string""" 137 | match = VERSION_RE.match(version) 138 | if not match: 139 | return None 140 | rv = match.groupdict() 141 | rv["pre"] = bool(rv.pop("prerel")) 142 | rv["dev"] = bool(rv.pop("dev")) 143 | for int_values in ("major", "minor", "patch"): 144 | if rv[int_values] is not None: 145 | rv[int_values] = int(rv[int_values]) 146 | if rv["architecture"]: 147 | rv["architecture"] = f"{rv['architecture']}bit" 148 | return cast("VersionDict", rv) 149 | 150 | 151 | def get_suffix_preference(name: str) -> int: 152 | for i, suffix in enumerate(KNOWN_EXTS): 153 | if suffix and name.endswith(suffix): 154 | return i 155 | return KNOWN_EXTS.index("") 156 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frostming/findpython/195e191982ba591c8e6ef90beef05d76e56de9c5/tests/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from pathlib import Path 4 | from unittest.mock import PropertyMock 5 | 6 | import pytest 7 | from packaging.version import parse 8 | 9 | from findpython.providers import ALL_PROVIDERS, PathProvider 10 | from findpython.python import PythonVersion 11 | 12 | 13 | class _MockRegistry: 14 | def __init__(self) -> None: 15 | self.versions: dict[Path, PythonVersion] = {} 16 | 17 | def add_python( 18 | self, 19 | executable, 20 | version=None, 21 | architecture="64bit", 22 | interpreter=None, 23 | keep_symlink=False, 24 | ) -> PythonVersion: 25 | if version is not None: 26 | version = parse(version) 27 | executable = Path(executable) 28 | if interpreter is None: 29 | interpreter = executable 30 | executable.parent.mkdir(parents=True, exist_ok=True) 31 | executable.touch(exist_ok=True) 32 | executable.chmod(0o744) 33 | py_ver = PythonVersion( 34 | executable, version, architecture, interpreter, keep_symlink 35 | ) 36 | if version is not None: 37 | py_ver._get_version = lambda: version # type:ignore[method-assign] 38 | self.versions[executable] = py_ver 39 | return py_ver 40 | 41 | def version_maker(self, executable, *args, **kwargs) -> PythonVersion: 42 | return self.versions[executable] 43 | 44 | 45 | @pytest.fixture() 46 | def mocked_python(tmp_path, monkeypatch) -> _MockRegistry: 47 | mocked = _MockRegistry() 48 | for python in [ 49 | (tmp_path / "python3.7", "3.7.0"), 50 | (tmp_path / "python3.8", "3.8.0"), 51 | (tmp_path / "python3.9", "3.9.0"), 52 | ]: 53 | mocked.add_python(*python) 54 | monkeypatch.setattr( 55 | "findpython.providers.base.BaseProvider.version_maker", mocked.version_maker 56 | ) 57 | monkeypatch.setattr( 58 | "findpython.python.PythonVersion.implementation", 59 | PropertyMock(return_value="CPython"), 60 | ) 61 | ALL_PROVIDERS.clear() 62 | ALL_PROVIDERS["path"] = PathProvider 63 | monkeypatch.setenv("PATH", str(tmp_path)) 64 | return mocked 65 | 66 | 67 | @pytest.fixture(params=[False, True]) 68 | def switch(request) -> bool: 69 | return request.param 70 | -------------------------------------------------------------------------------- /tests/test_cli.py: -------------------------------------------------------------------------------- 1 | from findpython.__main__ import cli 2 | 3 | 4 | def test_cli_find_pythons(mocked_python, capsys): 5 | retcode = cli(["--all"]) 6 | assert retcode == 0 7 | out, _ = capsys.readouterr() 8 | lines = out.strip().splitlines() 9 | for version, line in zip(("3.9", "3.8", "3.7"), lines): 10 | assert line.lstrip().startswith(f"CPython@{version}.0") 11 | 12 | 13 | def test_cli_find_python_by_version(mocked_python, capsys, tmp_path): 14 | retcode = cli(["3.8"]) 15 | assert retcode == 0 16 | out, _ = capsys.readouterr() 17 | line = out.strip() 18 | assert line.startswith("CPython@3.8.0") 19 | assert line.endswith(str(tmp_path / "python3.8")) 20 | -------------------------------------------------------------------------------- /tests/test_finder.py: -------------------------------------------------------------------------------- 1 | import os 2 | from pathlib import Path 3 | 4 | import pytest 5 | from packaging.version import Version 6 | 7 | from findpython import Finder, register_provider 8 | from findpython.providers.pyenv import PyenvProvider 9 | 10 | 11 | def test_find_pythons(mocked_python, tmp_path): 12 | finder = Finder() 13 | all_pythons = finder.find_all() 14 | assert len(all_pythons) == 3 15 | assert all_pythons[0].executable == Path(tmp_path / "python3.9") 16 | assert all_pythons[0].version == Version("3.9.0") 17 | assert all_pythons[1].executable == Path(tmp_path / "python3.8") 18 | assert all_pythons[1].version == Version("3.8.0") 19 | assert all_pythons[2].executable == Path(tmp_path / "python3.7") 20 | assert all_pythons[2].version == Version("3.7.0") 21 | 22 | 23 | def test_find_python_by_version(mocked_python, tmp_path): 24 | finder = Finder() 25 | python = finder.find(3, 8) 26 | assert python.executable == Path(tmp_path / "python3.8") 27 | assert python.version == Version("3.8.0") 28 | 29 | assert finder.find("3.8") == python 30 | assert finder.find("3.8.0") == python 31 | assert finder.find("python3.8") == python 32 | 33 | 34 | def test_find_python_by_version_not_found(mocked_python, tmp_path): 35 | finder = Finder() 36 | python = finder.find(3, 10) 37 | assert python is None 38 | 39 | 40 | def test_find_python_by_architecture(mocked_python, tmp_path): 41 | python = mocked_python.add_python( 42 | tmp_path / "python38", "3.8.0", architecture="32bit" 43 | ) 44 | finder = Finder() 45 | assert finder.find(3, 8, architecture="32bit") == python 46 | assert finder.find(3, 8, architecture="64bit").executable == tmp_path / "python3.8" 47 | 48 | 49 | def test_find_python_with_prerelease(mocked_python, tmp_path): 50 | python = mocked_python.add_python(tmp_path / "python3.10", "3.10.0.a1") 51 | finder = Finder() 52 | assert python == finder.find(pre=True) 53 | 54 | 55 | def test_find_python_with_devrelease(mocked_python, tmp_path): 56 | python = mocked_python.add_python(tmp_path / "python3.10", "3.10.0.dev1") 57 | finder = Finder() 58 | assert python == finder.find(dev=True) 59 | 60 | 61 | def test_find_python_with_non_existing_path(mocked_python, monkeypatch): 62 | monkeypatch.setenv("PATH", "/non/existing/path" + os.pathsep + os.environ["PATH"]) 63 | finder = Finder() 64 | all_pythons = finder.find_all() 65 | assert len(all_pythons) == 3 66 | 67 | 68 | def test_find_python_exclude_invalid(mocked_python, tmp_path): 69 | python = mocked_python.add_python(tmp_path / "python3.10") 70 | finder = Finder() 71 | all_pythons = finder.find_all() 72 | assert len(all_pythons) == 3 73 | assert python not in all_pythons 74 | 75 | 76 | def test_find_python_deduplicate_same_file(mocked_python, tmp_path, switch): 77 | for i, python in enumerate(mocked_python.versions): 78 | python.write_bytes(str(i).encode()) 79 | 80 | new_python = mocked_python.add_python(tmp_path / "python3", "3.9.0") 81 | new_python.executable.write_bytes(b"0") 82 | 83 | finder = Finder(no_same_file=switch) 84 | all_pythons = finder.find_all() 85 | assert len(all_pythons) == (3 if switch else 4) 86 | assert (new_python in all_pythons) is not switch 87 | 88 | 89 | @pytest.mark.skipif(os.name == "nt", reason="Not supported on Windows") 90 | def test_find_python_deduplicate_symlinks(mocked_python, tmp_path): 91 | python = mocked_python.add_python(tmp_path / "python3.9", "3.9.0") 92 | (tmp_path / "python3").symlink_to(python.executable) 93 | symlink1 = mocked_python.add_python(tmp_path / "python3", "3.9.0") 94 | (tmp_path / "python").symlink_to(python.executable) 95 | symlink2 = mocked_python.add_python(tmp_path / "python", "3.9.0", keep_symlink=True) 96 | finder = Finder(resolve_symlinks=True) 97 | all_pythons = finder.find_all() 98 | assert python in all_pythons 99 | assert symlink1 not in all_pythons 100 | assert symlink2 in all_pythons 101 | 102 | 103 | def test_find_python_deduplicate_same_interpreter(mocked_python, tmp_path, switch): 104 | if os.name == "nt": 105 | suffix = ".bat" 106 | else: 107 | suffix = ".sh" 108 | python = mocked_python.add_python( 109 | tmp_path / f"python{suffix}", "3.9.0", interpreter=tmp_path / "python3.9" 110 | ) 111 | 112 | finder = Finder(no_same_interpreter=switch) 113 | all_pythons = finder.find_all() 114 | assert len(all_pythons) == (3 if switch else 4) 115 | assert (python in all_pythons) is not switch 116 | 117 | 118 | def test_find_python_from_pyenv(mocked_python, tmp_path, monkeypatch): 119 | register_provider(PyenvProvider) 120 | python = mocked_python.add_python( 121 | tmp_path / ".pyenv/versions/3.8/bin/python", "3.8.0" 122 | ) 123 | monkeypatch.setenv("PYENV_ROOT", str(tmp_path / ".pyenv")) 124 | pythons = Finder().find_all(3, 8) 125 | assert len(pythons) == 2 126 | assert python in pythons 127 | 128 | 129 | def test_find_python_skips_empty_pyenv(mocked_python, tmp_path, monkeypatch): 130 | register_provider(PyenvProvider) 131 | pyenv_path = Path(tmp_path / ".pyenv") 132 | pyenv_path.mkdir() 133 | monkeypatch.setenv("PYENV_ROOT", str(pyenv_path)) 134 | all_pythons = Finder().find_all() 135 | assert len(all_pythons) == 3 136 | -------------------------------------------------------------------------------- /tests/test_posix.py: -------------------------------------------------------------------------------- 1 | import stat 2 | import sys 3 | from pathlib import Path 4 | 5 | import pytest 6 | 7 | from findpython import register_provider 8 | from findpython.finder import Finder 9 | from findpython.providers.asdf import AsdfProvider 10 | from findpython.providers.pyenv import PyenvProvider 11 | from findpython.providers.rye import RyeProvider 12 | from findpython.providers.uv import UvProvider 13 | 14 | if sys.platform == "win32": 15 | pytest.skip("Skip POSIX tests on Windows", allow_module_level=True) 16 | 17 | 18 | def test_find_python_resolve_symlinks(mocked_python, tmp_path, switch): 19 | link = Path(tmp_path / "python") 20 | link.symlink_to(Path(tmp_path / "python3.7")) 21 | python = mocked_python.add_python(link, "3.7.0") 22 | finder = Finder(resolve_symlinks=switch) 23 | all_pythons = finder.find_all() 24 | assert len(all_pythons) == (3 if switch else 4) 25 | assert (python in all_pythons) is not switch 26 | 27 | 28 | def test_find_python_from_asdf(mocked_python, tmp_path, monkeypatch): 29 | register_provider(AsdfProvider) 30 | python = mocked_python.add_python( 31 | tmp_path / ".asdf/installs/python/3.8/bin/python", "3.8.0" 32 | ) 33 | monkeypatch.setenv("ASDF_DATA_DIR", str(tmp_path / ".asdf")) 34 | pythons = Finder().find_all(3, 8) 35 | assert len(pythons) == 2 36 | assert python in pythons 37 | 38 | 39 | def test_find_python_exclude_unreadable(mocked_python, tmp_path): 40 | python = Path(tmp_path / "python3.8") 41 | python.chmod(python.stat().st_mode & ~stat.S_IRUSR) 42 | try: 43 | finder = Finder() 44 | all_pythons = finder.find_all() 45 | assert len(all_pythons) == 2, all_pythons 46 | assert python not in [version.executable for version in all_pythons] 47 | finally: 48 | python.chmod(0o744) 49 | 50 | 51 | def test_find_python_from_provider(mocked_python, tmp_path, monkeypatch): 52 | register_provider(AsdfProvider) 53 | register_provider(PyenvProvider) 54 | python38 = mocked_python.add_python( 55 | tmp_path / ".asdf/installs/python/3.8/bin/python", "3.8.0" 56 | ) 57 | python381 = mocked_python.add_python( 58 | tmp_path / ".pyenv/versions/3.8.1/bin/python", "3.8.1" 59 | ) 60 | python382 = mocked_python.add_python( 61 | tmp_path / ".asdf/installs/python/3.8.2/bin/python", "3.8.2" 62 | ) 63 | monkeypatch.setenv("ASDF_DATA_DIR", str(tmp_path / ".asdf")) 64 | monkeypatch.setenv("PYENV_ROOT", str(tmp_path / ".pyenv")) 65 | 66 | pythons = Finder(selected_providers=["pyenv", "asdf"]).find_all(3, 8) 67 | assert len(pythons) == 3 68 | assert python38 in pythons 69 | assert python381 in pythons 70 | assert python382 in pythons 71 | 72 | asdf_pythons = Finder(selected_providers=["asdf"]).find_all(3, 8) 73 | assert len(asdf_pythons) == 2 74 | assert python38 in asdf_pythons 75 | assert python382 in asdf_pythons 76 | 77 | pyenv_pythons = Finder(selected_providers=["pyenv"]).find_all(3, 8) 78 | assert len(pyenv_pythons) == 1 79 | assert python381 in pyenv_pythons 80 | 81 | 82 | def test_find_python_from_rye_provider(mocked_python, tmp_path, monkeypatch): 83 | python310 = mocked_python.add_python( 84 | tmp_path / ".rye/py/cpython@3.10.9/install/bin/python3", "3.10.9" 85 | ) 86 | python311 = mocked_python.add_python( 87 | tmp_path / ".rye/py/cpython@3.11.8/bin/python3", "3.11.8" 88 | ) 89 | monkeypatch.setenv("HOME", str(tmp_path)) 90 | 91 | register_provider(RyeProvider) 92 | find_310 = Finder(selected_providers=["rye"]).find_all(3, 10) 93 | assert python310 in find_310 94 | 95 | find_311 = Finder(selected_providers=["rye"]).find_all(3, 11) 96 | assert python311 in find_311 97 | 98 | 99 | def test_find_python_from_uv_provider(mocked_python, tmp_path, monkeypatch): 100 | python310 = mocked_python.add_python( 101 | tmp_path / ".local/share/uv/python/cpython@3.10.9/install/bin/python3", "3.10.9" 102 | ) 103 | python311 = mocked_python.add_python( 104 | tmp_path / ".local/share/uv/python/cpython@3.11.8/bin/python3", "3.11.8" 105 | ) 106 | monkeypatch.setenv("HOME", str(tmp_path)) 107 | 108 | register_provider(UvProvider) 109 | find_310 = Finder(selected_providers=["uv"]).find_all(3, 10) 110 | assert python310 in find_310 111 | 112 | find_311 = Finder(selected_providers=["uv"]).find_all(3, 11) 113 | assert python311 in find_311 114 | 115 | monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path / "xdg")) 116 | python310_xdg = mocked_python.add_python( 117 | tmp_path / "xdg/uv/python/cpython@3.10.9/install/bin/python3", "3.10.9" 118 | ) 119 | find_310 = Finder(selected_providers=["uv"]).find_all(3, 10) 120 | assert python310_xdg in find_310 121 | 122 | monkeypatch.setenv("UV_PYTHON_INSTALL_DIR", str(tmp_path / "uv_dir")) 123 | python311_uv_dir = mocked_python.add_python( 124 | tmp_path / "uv_dir/cpython@3.11.8/bin/python3", "3.11.8" 125 | ) 126 | find_311 = Finder(selected_providers=["uv"]).find_all(3, 11) 127 | assert python311_uv_dir in find_311 128 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from findpython.utils import WINDOWS, looks_like_python 4 | 5 | matrix = [ 6 | ("python", True), 7 | ("python3", True), 8 | ("python38", True), 9 | ("python3.8", True), 10 | ("python3.10", True), 11 | ("python310", True), 12 | ("python3.6m", True), 13 | ("python3.6.8m", False), 14 | ("anaconda3.3", True), 15 | ("python-3.8.10", False), 16 | ("unknown-2.0.0", False), 17 | ("python3.8.unknown", False), 18 | ("python38.bat", WINDOWS), 19 | ("python38.exe", WINDOWS), 20 | ("python38.sh", not WINDOWS), 21 | ("python38.csh", not WINDOWS), 22 | ] 23 | 24 | 25 | @pytest.mark.parametrize("name, expected", matrix) 26 | def test_looks_like_python(name, expected): 27 | assert looks_like_python(name) == expected 28 | --------------------------------------------------------------------------------