pax_global_header00006660000000000000000000000064144544040600014513gustar00rootroot0000000000000052 comment=ebe580527fd718abac82993132569c68302a0c4e findpython-0.3.1/000077500000000000000000000000001445440406000136765ustar00rootroot00000000000000findpython-0.3.1/.github/000077500000000000000000000000001445440406000152365ustar00rootroot00000000000000findpython-0.3.1/.github/workflows/000077500000000000000000000000001445440406000172735ustar00rootroot00000000000000findpython-0.3.1/.github/workflows/ci.yml000066400000000000000000000015611445440406000204140ustar00rootroot00000000000000name: Tests on: pull_request: paths-ignore: - "*.md" push: branches: - main paths-ignore: - "*.md" jobs: Testing: runs-on: ${{ matrix.os }} strategy: matrix: python-version: [3.7, 3.8, 3.9, "3.10", "3.11"] os: [ubuntu-latest, windows-latest, macos-latest] arch: [x64] include: - python-version: "3.10" os: windows-latest arch: x86 steps: - uses: actions/checkout@v3 - name: Set up PDM uses: pdm-project/setup-pdm@v3 with: python-version: ${{ matrix.python-version }} architecture: ${{ matrix.arch }} cache: "true" - name: Install packages run: pdm install - name: Run Integration run: pdm run findpython --all -v - name: Run Tests run: pdm run pytest tests findpython-0.3.1/.github/workflows/release.yml000066400000000000000000000016361445440406000214440ustar00rootroot00000000000000name: Release on: push: tags: - "*" jobs: release-pypi: name: release-pypi runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 with: fetch-depth: 0 - uses: actions/setup-node@v3 with: node-version: 16 - run: npx changelogithub continue-on-error: true env: GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} - uses: actions/setup-python@v4 with: python-version: "3.10" - name: Build artifacts run: | pip install build python -m build - name: Test Build run: | python -m venv fresh_env . fresh_env/bin/activate pip install dist/*.whl findpython --all - name: Upload to Pypi run: | pip install twine twine upload --username __token__ --password ${{ secrets.PYPI_TOKEN }} dist/* findpython-0.3.1/.gitignore000066400000000000000000000001501445440406000156620ustar00rootroot00000000000000.pdm-python *.py[cod] __pycache__/ venv/ .tox/ .vscode/ /build/ /dist/ *.egg-info/ /target/ .pdm-build/ findpython-0.3.1/.pre-commit-config.yaml000066400000000000000000000011171445440406000201570ustar00rootroot00000000000000exclude: > (?x)^( \.eggs| \.git| \.mypy_cache| \.tox| \.pyre_configuration| \.venv| build| dist| src/findpython/_vendor/.*\.py| src/findpython/pep514tools/_registry\.py )$ repos: - repo: https://github.com/astral-sh/ruff-pre-commit rev: 'v0.0.276' hooks: - id: ruff args: [--fix, --exit-non-zero-on-fix, --show-fixes] - repo: https://github.com/ambv/black rev: 23.3.0 hooks: - id: black - repo: https://github.com/pre-commit/mirrors-mypy rev: v1.4.1 hooks: - id: mypy findpython-0.3.1/LICENSE000066400000000000000000000020631445440406000147040ustar00rootroot00000000000000MIT License Copyright (c) 2022-present Frost Ming 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. findpython-0.3.1/README.md000066400000000000000000000110461445440406000151570ustar00rootroot00000000000000# FindPython _A utility to find python versions on your system._ [![Tests](https://github.com/frostming/findpython/actions/workflows/ci.yml/badge.svg)](https://github.com/frostming/findpython/actions/workflows/ci.yml) [![PyPI](https://img.shields.io/pypi/v/findpython?logo=python&logoColor=%23cccccc&style=flat-square)](https://pypi.org/project/findpython) [![PyPI - Python Version](https://img.shields.io/pypi/pyversions/findpython?logo=python&logoColor=%23cccccc&style=flat-square)](https://pypi.org/project/findpython) [![pdm-managed](https://img.shields.io/badge/pdm-managed-blueviolet?style=flat-square)](https://github.com/frostming/findpython) ## Description This library is a rewrite of [pythonfinder] project by [@techalchemy][techalchemy]. It simplifies the whole code structure while preserving most of the original features. [pythonfinder]: https://github.com/sarugaku/pythonfinder [techalchemy]: https://github.com/techalchemy ## Installation FindPython is installable via any kind of package manager including `pip`: ```bash pip install findpython ```
Expand this section to see findpython's availability in the package ecosystem Packaging status
## Usage ```python >>> import findpython >>> findpython.find(3, 9) # Find by major and minor version , architecture='64bit', major=3, minor=9, patch=10> >>> findpython.find("3.9") # Find by version string , architecture='64bit', major=3, minor=9, patch=10> >>> findpython.find("3.9-32") # Find by version string and architecture , architecture='32bit', major=3, minor=9, patch=10> >>> findpython.find(name="python3") # Find by executable name , architecture='64bit', major=3, minor=10, patch=2> >>> findpython.find("python3") # Find by executable name without keyword argument, same as above , architecture='64bit', major=3, minor=10, patch=2> >>> findpython.find_all(major=3, minor=9) # Same arguments as `find()`, but return all matches [, 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>] ``` ## CLI Usage In addition, FindPython provides a CLI interface to find python versions: ``` usage: findpython [-h] [-V] [-a] [--resolve-symlink] [-v] [--no-same-file] [--no-same-python] [--providers PROVIDERS] [version_spec] A utility to find python versions on your system positional arguments: version_spec Python version spec or name options: -h, --help show this help message and exit -V, --version show program's version number and exit -a, --all Show all matching python versions --resolve-symlink Resolve all symlinks -v, --verbose Verbose output --no-same-file Eliminate the duplicated results with the same file contents --no-same-python Eliminate the duplicated results with the same sys.executable --providers PROVIDERS Select provider(s) to use ``` ## Integration FindPython finds Python from the following places: - `PATH` environment variable - pyenv install root - asdf python install root - [rye](https://rye-up.com) toolchain install root - `/Library/Frameworks/Python.framework/Versions` (MacOS) - Windows registry (Windows only) ## License FindPython is released under MIT License. findpython-0.3.1/pdm.lock000066400000000000000000000165421445440406000153400ustar00rootroot00000000000000# This file is @generated by PDM. # It is not intended for manual editing. [[package]] name = "attrs" version = "22.1.0" requires_python = ">=3.5" summary = "Classes Without Boilerplate" [[package]] name = "colorama" version = "0.4.5" requires_python = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" summary = "Cross-platform colored terminal text." [[package]] name = "exceptiongroup" version = "1.1.0" requires_python = ">=3.7" summary = "Backport of PEP 654 (exception groups)" [[package]] name = "importlib-metadata" version = "4.12.0" requires_python = ">=3.7" summary = "Read metadata from Python packages" dependencies = [ "typing-extensions>=3.6.4; python_version < \"3.8\"", "zipp>=0.5", ] [[package]] name = "iniconfig" version = "1.1.1" summary = "iniconfig: brain-dead simple config-ini parsing" [[package]] name = "packaging" version = "23.0" requires_python = ">=3.7" summary = "Core utilities for Python packages" [[package]] name = "pluggy" version = "1.0.0" requires_python = ">=3.6" summary = "plugin and hook calling mechanisms for python" dependencies = [ "importlib-metadata>=0.12; python_version < \"3.8\"", ] [[package]] name = "pytest" version = "7.2.1" requires_python = ">=3.7" summary = "pytest: simple powerful testing with Python" dependencies = [ "attrs>=19.2.0", "colorama; sys_platform == \"win32\"", "exceptiongroup>=1.0.0rc8; python_version < \"3.11\"", "importlib-metadata>=0.12; python_version < \"3.8\"", "iniconfig", "packaging", "pluggy<2.0,>=0.12", "tomli>=1.0.0; python_version < \"3.11\"", ] [[package]] name = "tomli" version = "2.0.1" requires_python = ">=3.7" summary = "A lil' TOML parser" [[package]] name = "typing-extensions" version = "4.3.0" requires_python = ">=3.7" summary = "Backported and Experimental Type Hints for Python 3.7+" [[package]] name = "zipp" version = "3.8.1" requires_python = ">=3.7" summary = "Backport of pathlib-compatible object wrapper for zip files" [metadata] lock_version = "4.1" content_hash = "sha256:6eb2efcbb7606a63e2aac268217a24edd3c8b7d908d0a537d3f896fc525b0a03" [metadata.files] "attrs 22.1.0" = [ {url = "https://files.pythonhosted.org/packages/1a/cb/c4ffeb41e7137b23755a45e1bfec9cbb76ecf51874c6f1d113984ecaa32c/attrs-22.1.0.tar.gz", hash = "sha256:29adc2665447e5191d0e7c568fde78b21f9672d344281d0c6e1ab085429b22b6"}, {url = "https://files.pythonhosted.org/packages/f2/bc/d817287d1aa01878af07c19505fafd1165cd6a119e9d0821ca1d1c20312d/attrs-22.1.0-py2.py3-none-any.whl", hash = "sha256:86efa402f67bf2df34f51a335487cf46b1ec130d02b8d39fd248abfd30da551c"}, ] "colorama 0.4.5" = [ {url = "https://files.pythonhosted.org/packages/2b/65/24d033a9325ce42ccbfa3ca2d0866c7e89cc68e5b9d92ecaba9feef631df/colorama-0.4.5.tar.gz", hash = "sha256:e6c6b4334fc50988a639d9b98aa429a0b57da6e17b9a44f0451f930b6967b7a4"}, {url = "https://files.pythonhosted.org/packages/77/8b/7550e87b2d308a1b711725dfaddc19c695f8c5fa413c640b2be01662f4e6/colorama-0.4.5-py2.py3-none-any.whl", hash = "sha256:854bf444933e37f5824ae7bfc1e98d5bce2ebe4160d46b5edf346a89358e99da"}, ] "exceptiongroup 1.1.0" = [ {url = "https://files.pythonhosted.org/packages/15/ab/dd27fb742b19a9d020338deb9ab9a28796524081bca880ac33c172c9a8f6/exceptiongroup-1.1.0.tar.gz", hash = "sha256:bcb67d800a4497e1b404c2dd44fca47d3b7a5e5433dbab67f96c1a685cdfdf23"}, {url = "https://files.pythonhosted.org/packages/e8/14/9c6a7e5f12294ccd6975a45e02899ed25468cd7c2c86f3d9725f387f9f5f/exceptiongroup-1.1.0-py3-none-any.whl", hash = "sha256:327cbda3da756e2de031a3107b81ab7b3770a602c4d16ca618298c526f4bec1e"}, ] "importlib-metadata 4.12.0" = [ {url = "https://files.pythonhosted.org/packages/1a/16/441080c907df829016729e71d8bdd42d99b9bdde48b01492ed08912c0aa9/importlib_metadata-4.12.0.tar.gz", hash = "sha256:637245b8bab2b6502fcbc752cc4b7a6f6243bb02b31c5c26156ad103d3d45670"}, {url = "https://files.pythonhosted.org/packages/d2/a2/8c239dc898138f208dd14b441b196e7b3032b94d3137d9d8453e186967fc/importlib_metadata-4.12.0-py3-none-any.whl", hash = "sha256:7401a975809ea1fdc658c3aa4f78cc2195a0e019c5cbc4c06122884e9ae80c23"}, ] "iniconfig 1.1.1" = [ {url = "https://files.pythonhosted.org/packages/23/a2/97899f6bd0e873fed3a7e67ae8d3a08b21799430fb4da15cfedf10d6e2c2/iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"}, {url = "https://files.pythonhosted.org/packages/9b/dd/b3c12c6d707058fa947864b67f0c4e0c39ef8610988d7baea9578f3c48f3/iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"}, ] "packaging 23.0" = [ {url = "https://files.pythonhosted.org/packages/47/d5/aca8ff6f49aa5565df1c826e7bf5e85a6df852ee063600c1efa5b932968c/packaging-23.0.tar.gz", hash = "sha256:b6ad297f8907de0fa2fe1ccbd26fdaf387f5f47c7275fedf8cce89f99446cf97"}, {url = "https://files.pythonhosted.org/packages/ed/35/a31aed2993e398f6b09a790a181a7927eb14610ee8bbf02dc14d31677f1c/packaging-23.0-py3-none-any.whl", hash = "sha256:714ac14496c3e68c99c29b00845f7a2b85f3bb6f1078fd9f72fd20f0570002b2"}, ] "pluggy 1.0.0" = [ {url = "https://files.pythonhosted.org/packages/9e/01/f38e2ff29715251cf25532b9082a1589ab7e4f571ced434f98d0139336dc/pluggy-1.0.0-py2.py3-none-any.whl", hash = "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3"}, {url = "https://files.pythonhosted.org/packages/a1/16/db2d7de3474b6e37cbb9c008965ee63835bba517e22cdb8c35b5116b5ce1/pluggy-1.0.0.tar.gz", hash = "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159"}, ] "pytest 7.2.1" = [ {url = "https://files.pythonhosted.org/packages/cc/02/8f59bf194c9a1ceac6330850715e9ec11e21e2408a30a596c65d54cf4d2a/pytest-7.2.1-py3-none-any.whl", hash = "sha256:c7c6ca206e93355074ae32f7403e8ea12163b1163c976fee7d4d84027c162be5"}, {url = "https://files.pythonhosted.org/packages/e5/6c/f3a15217ac72912c28c5d7a7a8e87ff6d6475c9530595ae9f0f8dedd8dd8/pytest-7.2.1.tar.gz", hash = "sha256:d45e0952f3727241918b8fd0f376f5ff6b301cc0777c6f9a556935c92d8a7d42"}, ] "tomli 2.0.1" = [ {url = "https://files.pythonhosted.org/packages/97/75/10a9ebee3fd790d20926a90a2547f0bf78f371b2f13aa822c759680ca7b9/tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, {url = "https://files.pythonhosted.org/packages/c0/3f/d7af728f075fb08564c5949a9c95e44352e23dee646869fa104a3b2060a3/tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, ] "typing-extensions 4.3.0" = [ {url = "https://files.pythonhosted.org/packages/9e/1d/d128169ff58c501059330f1ad96ed62b79114a2eb30b8238af63a2e27f70/typing_extensions-4.3.0.tar.gz", hash = "sha256:e6d2677a32f47fc7eb2795db1dd15c1f34eff616bcaf2cfb5e997f854fa1c4a6"}, {url = "https://files.pythonhosted.org/packages/ed/d6/2afc375a8d55b8be879d6b4986d4f69f01115e795e36827fd3a40166028b/typing_extensions-4.3.0-py3-none-any.whl", hash = "sha256:25642c956049920a5aa49edcdd6ab1e06d7e5d467fc00e0506c44ac86fbfca02"}, ] "zipp 3.8.1" = [ {url = "https://files.pythonhosted.org/packages/3b/e3/fb79a1ea5f3a7e9745f688855d3c673f2ef7921639a380ec76f7d4d83a85/zipp-3.8.1.tar.gz", hash = "sha256:05b45f1ee8f807d0cc928485ca40a07cb491cf092ff587c0df9cb1fd154848d2"}, {url = "https://files.pythonhosted.org/packages/f0/36/639d6742bcc3ffdce8b85c31d79fcfae7bb04b95f0e5c4c6f8b206a038cc/zipp-3.8.1-py3-none-any.whl", hash = "sha256:47c40d7fe183a6f21403a199b3e4192cca5774656965b0a4988ad2f8feb5f009"}, ] findpython-0.3.1/pyproject.toml000066400000000000000000000031221445440406000166100ustar00rootroot00000000000000[project] name = "findpython" description = "A utility to find python versions on your system" authors = [ {name = "Frost Ming", email = "mianghong@gmail.com"}, ] dependencies = [ "packaging>=20", ] requires-python = ">=3.7" license = {text = "MIT"} readme = "README.md" dynamic = ["version"] classifiers = [ "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", ] [project.urls] Homepage = "https://github.com/frostming/findpython" [project.scripts] findpython = "findpython.__main__:main" [tool.pdm.version] source = "scm" write_to = "findpython/__version__.py" write_template = "__version__ = \"{}\"\n" [tool.pdm.build] package-dir = "src" [tool.pdm.dev-dependencies] tests = ["pytest"] [build-system] requires = ["pdm-backend"] build-backend = "pdm.backend" [tool.black] line-length = 90 include = '\.pyi?$' exclude = ''' /( \.eggs | \.git | \.hg | \.mypy_cache | \.tox | \.venv | build | dist | src/pythonfinder/_vendor ) ''' [tool.ruff] line-length = 90 select = [ "B", # flake8-bugbear "C4", # flake8-comprehensions "E", # pycodestyle "F", # pyflakes "PGH", # pygrep-hooks "RUF", # ruff "W", # pycodestyle "YTT", # flake8-2020 ] extend-ignore = ["B018", "B019"] src = ["src"] exclude = ["tests/fixtures"] target-version = "py37" [tool.ruff.mccabe] max-complexity = 10 [tool.ruff.isort] known-first-party = ["findpython"] findpython-0.3.1/src/000077500000000000000000000000001445440406000144655ustar00rootroot00000000000000findpython-0.3.1/src/findpython/000077500000000000000000000000001445440406000166475ustar00rootroot00000000000000findpython-0.3.1/src/findpython/__init__.py000066400000000000000000000036101445440406000207600ustar00rootroot00000000000000""" FindPython ~~~~~~~~~~ A utility to find python versions on your system """ from __future__ import annotations from typing import TYPE_CHECKING, TypeVar from findpython.finder import Finder from findpython.providers import ALL_PROVIDERS from findpython.providers.base import BaseProvider from findpython.python import PythonVersion def find(*args, **kwargs) -> PythonVersion | None: """ Return the Python version that is closest to the given version criteria. :param major: The major version or the version string or the name to match. :param minor: The minor version to match. :param patch: The micro version to match. :param pre: Whether the python is a prerelease. :param dev: Whether the python is a devrelease. :param name: The name of the python. :param architecture: The architecture of the python. :return: a Python object or None """ return Finder().find(*args, **kwargs) def find_all(*args, **kwargs) -> list[PythonVersion]: """ Return all Python versions matching the given version criteria. :param major: The major version or the version string or the name to match. :param minor: The minor version to match. :param patch: The micro version to match. :param pre: Whether the python is a prerelease. :param dev: Whether the python is a devrelease. :param name: The name of the python. :param architecture: The architecture of the python. :return: a list of PythonVersion objects """ return Finder().find_all(*args, **kwargs) if TYPE_CHECKING: P = TypeVar("P", bound=type[BaseProvider]) def register_provider(provider: P) -> P: """ Register a provider to use when finding python versions. :param provider: A provider class """ ALL_PROVIDERS[provider.name()] = provider return provider __all__ = ["Finder", "find", "find_all", "PythonVersion", "register_provider"] findpython-0.3.1/src/findpython/__main__.py000066400000000000000000000050441445440406000207440ustar00rootroot00000000000000from __future__ import annotations import logging import sys from argparse import ArgumentParser from findpython import Finder from findpython.__version__ import __version__ logger = logging.getLogger("findpython") def setup_logger(level: int = logging.DEBUG) -> None: """ Setup the logger. """ handler = logging.StreamHandler() handler.setFormatter(logging.Formatter("%(name)s-%(levelname)s: %(message)s")) logger.addHandler(handler) logger.setLevel(level) def split_str(value: str) -> list[str]: return value.split(",") def cli(argv: list[str] | None = None) -> int: """ Command line interface for findpython. """ parser = ArgumentParser( "findpython", description="A utility to find python versions on your system" ) parser.add_argument( "-V", "--version", action="version", version=f"%(prog)s {__version__}" ) parser.add_argument( "-a", "--all", action="store_true", help="Show all matching python versions" ) parser.add_argument( "--resolve-symlink", action="store_true", help="Resolve all symlinks" ) parser.add_argument("-v", "--verbose", action="store_true", help="Verbose output") parser.add_argument( "--no-same-file", action="store_true", help="Eliminate the duplicated results with the same file contents", ) parser.add_argument( "--no-same-python", action="store_true", help="Eliminate the duplicated results with the same sys.executable", ) parser.add_argument("--providers", type=split_str, help="Select provider(s) to use") parser.add_argument("version_spec", nargs="?", help="Python version spec or name") args = parser.parse_args(argv) if args.verbose: setup_logger() finder = Finder( resolve_symlinks=args.resolve_symlink, no_same_file=args.no_same_file, selected_providers=args.providers, ) if args.all: find_func = finder.find_all else: find_func = finder.find # type: ignore[assignment] python_versions = find_func(args.version_spec) if not python_versions: print("No matching python version found", file=sys.stderr) return 1 if not isinstance(python_versions, list): python_versions = [python_versions] print("Found matching python versions:", file=sys.stderr) for python_version in python_versions: print(python_version) return 0 def main() -> None: """ Main function. """ sys.exit(cli()) if __name__ == "__main__": main() findpython-0.3.1/src/findpython/__version__.py000066400000000000000000000000261445440406000215000ustar00rootroot00000000000000__version__ = "0.0.0" findpython-0.3.1/src/findpython/finder.py000066400000000000000000000150131445440406000204700ustar00rootroot00000000000000from __future__ import annotations import logging import operator from typing import Callable, Iterable from findpython.providers import ALL_PROVIDERS, BaseProvider from findpython.python import PythonVersion from findpython.utils import get_suffix_preference, parse_major logger = logging.getLogger("findpython") class Finder: """Find python versions on the system. :param resolve_symlinks: Whether to resolve symlinks. :param no_same_file: Whether to deduplicate with the python executable content. :param no_same_interpreter: Whether to deduplicate with the python executable path. """ def __init__( self, resolve_symlinks: bool = False, no_same_file: bool = False, no_same_interpreter: bool = False, selected_providers: list[str] | None = None, ) -> None: self.resolve_symlinks = resolve_symlinks self.no_same_file = no_same_file self.no_same_interpreter = no_same_interpreter self._providers = self.setup_providers(selected_providers) def setup_providers( self, selected_providers: list[str] | None = None, ) -> list[BaseProvider]: providers: list[BaseProvider] = [] allowed_providers = ALL_PROVIDERS if selected_providers is not None: allowed_providers = {name: ALL_PROVIDERS[name] for name in selected_providers} for provider_class in allowed_providers.values(): provider = provider_class.create() if provider is None: logger.debug("Provider %s is not available", provider_class.__name__) else: providers.append(provider) return providers def add_provider(self, provider: BaseProvider, pos: int | None = None) -> None: """Add provider to the provider list. If pos is given, it will be inserted at the given position. """ if pos is not None: self._providers.insert(pos, provider) else: self._providers.append(provider) def find_all( self, major: int | str | None = None, minor: int | None = None, patch: int | None = None, pre: bool | None = None, dev: bool | None = None, name: str | None = None, architecture: str | None = None, ) -> list[PythonVersion]: """ Return all Python versions matching the given version criteria. :param major: The major version or the version string or the name to match. :param minor: The minor version to match. :param patch: The micro version to match. :param pre: Whether the python is a prerelease. :param dev: Whether the python is a devrelease. :param name: The name of the python. :param architecture: The architecture of the python. :param from_provider: Providers to use (default: use all). :return: a list of PythonVersion objects """ if isinstance(major, str): if any(v is not None for v in (minor, patch, pre, dev, name)): raise ValueError( "If major is a string, minor, patch, pre, dev and name " "must not be specified." ) version_dict = parse_major(major) if version_dict is not None: major = version_dict["major"] minor = version_dict["minor"] patch = version_dict["patch"] pre = version_dict["pre"] dev = version_dict["dev"] architecture = version_dict["architecture"] else: name, major = major, None version_matcher = operator.methodcaller( "matches", major, minor, patch, pre, dev, name, architecture, ) # Deduplicate with the python executable path matched_python = set(self._find_all_python_versions()) return self._dedup(matched_python, version_matcher) def find( self, major: int | str | None = None, minor: int | None = None, patch: int | None = None, pre: bool | None = None, dev: bool | None = None, name: str | None = None, architecture: str | None = None, ) -> PythonVersion | None: """ Return the Python version that is closest to the given version criteria. :param major: The major version or the version string or the name to match. :param minor: The minor version to match. :param patch: The micro version to match. :param pre: Whether the python is a prerelease. :param dev: Whether the python is a devrelease. :param name: The name of the python. :param architecture: The architecture of the python. :param from_provider: Providers to use (default: use all). :return: a Python object or None """ return next( iter(self.find_all(major, minor, patch, pre, dev, name, architecture)), None, ) def _find_all_python_versions(self) -> Iterable[PythonVersion]: """Find all python versions on the system.""" for provider in self._providers: yield from provider.find_pythons() def _dedup( self, python_versions: Iterable[PythonVersion], version_matcher: Callable[[PythonVersion], bool], ) -> list[PythonVersion]: def dedup_key(python_version: PythonVersion) -> str: if self.no_same_interpreter: return python_version.interpreter.as_posix() if self.no_same_file: return python_version.binary_hash() if self.resolve_symlinks and not python_version.keep_symlink: return python_version.real_path.as_posix() return python_version.executable.as_posix() def sort_key(python_version: PythonVersion) -> tuple[int, int, int]: return ( python_version.executable.is_symlink(), get_suffix_preference(python_version.name), -len(python_version.executable.as_posix()), ) result: dict[str, PythonVersion] = {} for python_version in sorted(python_versions, key=sort_key): key = dedup_key(python_version) if ( key not in result and python_version.is_valid() and version_matcher(python_version) ): result[key] = python_version return sorted(result.values(), reverse=True) findpython-0.3.1/src/findpython/pep514tools/000077500000000000000000000000001445440406000207465ustar00rootroot00000000000000findpython-0.3.1/src/findpython/pep514tools/__init__.py000066400000000000000000000006721445440406000230640ustar00rootroot00000000000000# ------------------------------------------------------------------------- # Copyright (c) Steve Dower # All rights reserved. # # Distributed under the terms of the MIT License # ------------------------------------------------------------------------- __author__ = "Steve Dower " __version__ = "0.1.0" from findpython.pep514tools.environment import find, findall, findone __all__ = ["findall", "find", "findone"] findpython-0.3.1/src/findpython/pep514tools/__main__.py000066400000000000000000000003761445440406000230460ustar00rootroot00000000000000# ------------------------------------------------------------------------- # Copyright (c) Steve Dower # All rights reserved. # # Distributed under the terms of the MIT License # ------------------------------------------------------------------------- findpython-0.3.1/src/findpython/pep514tools/_registry.py000066400000000000000000000146471445440406000233430ustar00rootroot00000000000000# ------------------------------------------------------------------------- # Copyright (c) Steve Dower # All rights reserved. # # Distributed under the terms of the MIT License # ------------------------------------------------------------------------- __all__ = [ "open_source", "REGISTRY_SOURCE_LM", "REGISTRY_SOURCE_LM_WOW6432", "REGISTRY_SOURCE_CU", ] import re from itertools import count try: import winreg except ImportError: import _winreg as winreg REGISTRY_SOURCE_LM = 1 REGISTRY_SOURCE_LM_WOW6432 = 2 REGISTRY_SOURCE_CU = 3 _REG_KEY_INFO = { REGISTRY_SOURCE_LM: ( winreg.HKEY_LOCAL_MACHINE, r"Software\Python", winreg.KEY_WOW64_64KEY, ), REGISTRY_SOURCE_LM_WOW6432: ( winreg.HKEY_LOCAL_MACHINE, r"Software\Python", winreg.KEY_WOW64_32KEY, ), REGISTRY_SOURCE_CU: (winreg.HKEY_CURRENT_USER, r"Software\Python", 0), } def get_value_from_tuple(value, vtype): if vtype == winreg.REG_SZ: if "\0" in value: return value[: value.index("\0")] return value return None def join(x, y): return x + "\\" + y _VALID_ATTR = re.compile("^[a-z_]+$") _VALID_KEY = re.compile("^[A-Za-z]+$") _KEY_TO_ATTR = re.compile("([A-Z]+[a-z]+)") class PythonWrappedDict(object): @staticmethod def _attr_to_key(attr): if not attr: return "" if not _VALID_ATTR.match(attr): return attr return "".join(c.capitalize() for c in attr.split("_")) @staticmethod def _key_to_attr(key): if not key: return "" if not _VALID_KEY.match(key): return key return "_".join(k for k in _KEY_TO_ATTR.split(key) if k).lower() def __init__(self, d): self._d = d def __getattr__(self, attr): if attr.startswith("_"): return object.__getattribute__(self, attr) if attr == "value": attr = "" key = self._attr_to_key(attr) try: return self._d[key] except Exception: pass raise AttributeError(attr) def __setattr__(self, attr, value): if attr.startswith("_"): return object.__setattr__(self, attr, value) if attr == "value": attr = "" self._d[self._attr_to_key(attr)] = value def __dir__(self): k2a = self._key_to_attr return list(map(k2a, self._d)) def _setdefault(self, key, value): self._d.setdefault(key, value) def _items(self): return self._d.items() def __repr__(self): k2a = self._key_to_attr return ( "info(" + ", ".join("{}={!r}".format(k2a(k), v) for k, v in self._d.items()) + ")" ) class RegistryAccessor(object): def __init__(self, root, subkey, flags): self._root = root self.subkey = subkey _, _, self.name = subkey.rpartition("\\") self._flags = flags def __iter__(self): subkey_names = [] try: with winreg.OpenKeyEx( self._root, self.subkey, 0, winreg.KEY_READ | self._flags ) as key: for i in count(): subkey_names.append(winreg.EnumKey(key, i)) except OSError: pass return iter(self[k] for k in subkey_names) def __getitem__(self, key): return RegistryAccessor(self._root, join(self.subkey, key), self._flags) def get_value(self, value_name): try: with winreg.OpenKeyEx( self._root, self.subkey, 0, winreg.KEY_READ | self._flags ) as key: return get_value_from_tuple(*winreg.QueryValueEx(key, value_name)) except OSError: return None def get_all_values(self): schema = {} for subkey in self: schema[subkey.name] = subkey.get_all_values() key = winreg.OpenKeyEx(self._root, self.subkey, 0, winreg.KEY_READ | self._flags) try: with key: for i in count(): vname, value, vtype = winreg.EnumValue(key, i) value = get_value_from_tuple(value, vtype) if value: schema[vname or ""] = value except OSError: pass return PythonWrappedDict(schema) def set_value(self, value_name, value): with winreg.CreateKeyEx( self._root, self.subkey, 0, winreg.KEY_WRITE | self._flags ) as key: if value is None: winreg.DeleteValue(key, value_name) elif isinstance(value, str): winreg.SetValueEx(key, value_name, 0, winreg.REG_SZ, value) else: raise TypeError("cannot write {} to registry".format(type(value))) def _set_all_values(self, rootkey, name, info, errors): with winreg.CreateKeyEx(rootkey, name, 0, winreg.KEY_WRITE | self._flags) as key: for k, v in info: if isinstance(v, PythonWrappedDict): self._set_all_values(key, k, v._items(), errors) elif isinstance(v, dict): self._set_all_values(key, k, v.items(), errors) elif v is None: winreg.DeleteValue(key, k) elif isinstance(v, str): winreg.SetValueEx(key, k, 0, winreg.REG_SZ, v) else: errors.append("cannot write {} to registry".format(type(v))) def set_all_values(self, info): errors = [] if isinstance(info, PythonWrappedDict): items = info._items() elif isinstance(info, dict): items = info.items() else: raise TypeError("info must be a dictionary") self._set_all_values(self._root, self.subkey, items, errors) if len(errors) == 1: raise ValueError(errors[0]) elif errors: raise ValueError(errors) def delete(self): for k in self: k.delete() try: key = winreg.OpenKeyEx(self._root, None, 0, winreg.KEY_READ | self._flags) except OSError: return with key: winreg.DeleteKeyEx(key, self.subkey) def open_source(registry_source): info = _REG_KEY_INFO.get(registry_source) if not info: raise ValueError("unsupported registry source") root, subkey, flags = info return RegistryAccessor(root, subkey, flags) findpython-0.3.1/src/findpython/pep514tools/environment.py000066400000000000000000000110361445440406000236650ustar00rootroot00000000000000# ------------------------------------------------------------------------- # Copyright (c) Steve Dower # All rights reserved. # # Distributed under the terms of the MIT License # ------------------------------------------------------------------------- __all__ = ["Environment", "findall", "find", "findone"] import sys from findpython.pep514tools._registry import ( REGISTRY_SOURCE_CU, REGISTRY_SOURCE_LM, REGISTRY_SOURCE_LM_WOW6432, open_source, ) # These tags are treated specially when the Company is 'PythonCore' _PYTHONCORE_COMPATIBILITY_TAGS = { "2.0", "2.1", "2.2", "2.3", "2.4", "2.5", "2.6", "2.7", "3.0", "3.1", "3.2", "3.3", "3.4", } _IS_64BIT_OS = None def _is_64bit_os(): global _IS_64BIT_OS if _IS_64BIT_OS is None: if sys.maxsize > 2**32: import platform _IS_64BIT_OS = platform.machine() == "AMD64" else: _IS_64BIT_OS = False return _IS_64BIT_OS class Environment(object): def __init__(self, source, company, tag, guessed_arch=None): self._source = source self.company = company self.tag = tag self._guessed_arch = guessed_arch self._orig_info = company, tag self.info = {} def load(self): if not self._source: raise ValueError("Environment not initialized with a source") self.info = info = self._source[self.company][self.tag].get_all_values() if self.company == "PythonCore": info._setdefault("DisplayName", "Python " + self.tag) info._setdefault("SupportUrl", "http://www.python.org/") info._setdefault("Version", self.tag[:3]) info._setdefault("SysVersion", self.tag[:3]) if self._guessed_arch: info._setdefault("SysArchitecture", self._guessed_arch) def save(self, copy=False): if not self._source: raise ValueError("Environment not initialized with a source") if (self.company, self.tag) != self._orig_info: if not copy: self._source[self._orig_info[0]][self._orig_info[1]].delete() self._orig_info = self.company, self.tag src = self._source[self.company][self.tag] src.set_all_values(self.info) self.info = src.get_all_values() def delete(self): if (self.company, self.tag) != self._orig_info: raise ValueError( "cannot delete Environment when company/tag have been modified" ) if not self._source: raise ValueError("Environment not initialized with a source") self._source.delete() def __repr__(self): return "".format(self.company, self.tag) def _get_sources(include_per_machine=True, include_per_user=True): if _is_64bit_os(): if include_per_user: yield open_source(REGISTRY_SOURCE_CU), None if include_per_machine: yield open_source(REGISTRY_SOURCE_LM), "64bit" yield open_source(REGISTRY_SOURCE_LM_WOW6432), "32bit" else: if include_per_user: yield open_source(REGISTRY_SOURCE_CU), "32bit" if include_per_machine: yield open_source(REGISTRY_SOURCE_LM), "32bit" def findall(include_per_machine=True, include_per_user=True): for src, arch in _get_sources( include_per_machine=include_per_machine, include_per_user=include_per_user ): for company in src: for tag in company: try: env = Environment(src, company.name, tag.name, arch) env.load() except OSError: pass else: yield env def find( company_or_tag, tag=None, include_per_machine=True, include_per_user=True, maxcount=None, ): if not tag: env = Environment(None, "PythonCore", company_or_tag) else: env = Environment(None, company_or_tag, tag) results = [] for src, arch in _get_sources( include_per_machine=include_per_machine, include_per_user=include_per_user ): try: env._source = src env._guessed_arch = arch env.load() except OSError: pass else: results.append(env) return results def findone(company_or_tag, tag=None, include_per_machine=True, include_per_user=True): found = find(company_or_tag, tag, include_per_machine, include_per_user, maxcount=1) if found: return found[0] findpython-0.3.1/src/findpython/providers/000077500000000000000000000000001445440406000206645ustar00rootroot00000000000000findpython-0.3.1/src/findpython/providers/__init__.py000066400000000000000000000015161445440406000230000ustar00rootroot00000000000000""" This package contains all the providers for the pythonfinder module. """ from __future__ import annotations from findpython.providers.asdf import AsdfProvider from findpython.providers.base import BaseProvider from findpython.providers.macos import MacOSProvider from findpython.providers.path import PathProvider from findpython.providers.pyenv import PyenvProvider from findpython.providers.rye import RyeProvider from findpython.providers.winreg import WinregProvider _providers: list[type[BaseProvider]] = [ # General: PathProvider, # Tool Specific: AsdfProvider, PyenvProvider, RyeProvider, # Windows only: WinregProvider, # MacOS only: MacOSProvider, ] ALL_PROVIDERS = {cls.name(): cls for cls in _providers} __all__ = [cls.__name__ for cls in _providers] + ["ALL_PROVIDERS", "BaseProvider"] findpython-0.3.1/src/findpython/providers/asdf.py000066400000000000000000000020421445440406000221510ustar00rootroot00000000000000from __future__ import annotations import os import typing as t from pathlib import Path from findpython.providers.base import BaseProvider from findpython.python import PythonVersion class AsdfProvider(BaseProvider): """A provider that finds python installed with asdf""" def __init__(self, root: Path) -> None: self.root = root @classmethod def create(cls) -> t.Self | None: asdf_root = os.path.expanduser( os.path.expandvars(os.getenv("ASDF_DATA_DIR", "~/.asdf")) ) if not os.path.exists(asdf_root): return None return cls(Path(asdf_root)) def find_pythons(self) -> t.Iterable[PythonVersion]: python_dir = self.root / "installs/python" if not python_dir.exists(): return for version in python_dir.iterdir(): if version.is_dir(): bindir = version / "bin" if not bindir.exists(): bindir = version yield from self.find_pythons_from_path(bindir, True) findpython-0.3.1/src/findpython/providers/base.py000066400000000000000000000034371445440406000221570ustar00rootroot00000000000000from __future__ import annotations import abc import logging import typing as t from pathlib import Path from findpython.python import PythonVersion from findpython.utils import path_is_python, safe_iter_dir logger = logging.getLogger("findpython") class BaseProvider(metaclass=abc.ABCMeta): """The base class for python providers""" version_maker: t.Callable[..., PythonVersion] = PythonVersion @classmethod def name(cls) -> str: """Configuration name for this provider. By default, the lowercase class name with 'provider' removed. """ self_name = cls.__name__.lower() if self_name.endswith("provider"): self_name = self_name[: -len("provider")] return self_name @classmethod @abc.abstractmethod def create(cls) -> t.Self | None: """Return an instance of the provider or None if it is not available""" pass @abc.abstractmethod def find_pythons(self) -> t.Iterable[PythonVersion]: """Return the python versions found by the provider""" pass @classmethod def find_pythons_from_path( cls, path: Path, as_interpreter: bool = False ) -> t.Iterable[PythonVersion]: """A general helper method to return pythons under a given path. :param path: The path to search for pythons :param as_interpreter: Use the path as the interpreter path. If the pythons might be a wrapper script, don't set this to True. :returns: An iterable of PythonVersion objects """ return ( cls.version_maker( child.absolute(), _interpreter=child.absolute() if as_interpreter else None, ) for child in safe_iter_dir(path) if path_is_python(child) ) findpython-0.3.1/src/findpython/providers/macos.py000066400000000000000000000013671445440406000223470ustar00rootroot00000000000000from __future__ import annotations import typing as t from pathlib import Path from findpython.providers.base import BaseProvider from findpython.python import PythonVersion class MacOSProvider(BaseProvider): """A provider that finds python from macos typical install base with python.org installer. """ INSTALL_BASE = Path("/Library/Frameworks/Python.framework/Versions/") @classmethod def create(cls) -> t.Self | None: if not cls.INSTALL_BASE.exists(): return None return cls() def find_pythons(self) -> t.Iterable[PythonVersion]: for version in self.INSTALL_BASE.iterdir(): if version.is_dir(): yield from self.find_pythons_from_path(version / "bin", True) findpython-0.3.1/src/findpython/providers/path.py000066400000000000000000000012261445440406000221730ustar00rootroot00000000000000from __future__ import annotations import os import typing as t from dataclasses import dataclass from pathlib import Path from findpython.providers.base import BaseProvider from findpython.python import PythonVersion @dataclass class PathProvider(BaseProvider): """A provider that finds Python from PATH env.""" paths: list[Path] @classmethod def create(cls) -> t.Self | None: paths = [Path(path) for path in os.getenv("PATH", "").split(os.pathsep) if path] return cls(paths) def find_pythons(self) -> t.Iterable[PythonVersion]: for path in self.paths: yield from self.find_pythons_from_path(path) findpython-0.3.1/src/findpython/providers/pyenv.py000066400000000000000000000020601445440406000223750ustar00rootroot00000000000000from __future__ import annotations import os import typing as t from pathlib import Path from findpython.providers.base import BaseProvider from findpython.python import PythonVersion class PyenvProvider(BaseProvider): """A provider that finds python installed with pyenv""" def __init__(self, root: Path) -> None: self.root = root @classmethod def create(cls) -> t.Self | None: pyenv_root = os.path.expanduser( os.path.expandvars(os.getenv("PYENV_ROOT", "~/.pyenv")) ) if not os.path.exists(pyenv_root): return None return cls(Path(pyenv_root)) def find_pythons(self) -> t.Iterable[PythonVersion]: versions_path = self.root.joinpath("versions") if versions_path.exists(): for version in versions_path.iterdir(): if version.is_dir(): bindir = version / "bin" if not bindir.exists(): bindir = version yield from self.find_pythons_from_path(bindir, True) findpython-0.3.1/src/findpython/providers/rye.py000066400000000000000000000016211445440406000220350ustar00rootroot00000000000000from __future__ import annotations import shutil import typing as t from pathlib import Path from findpython.providers.base import BaseProvider from findpython.python import PythonVersion from findpython.utils import safe_iter_dir class RyeProvider(BaseProvider): def __init__(self) -> None: self.root = Path.home() / ".rye" self.rye_bin = shutil.which("rye") @classmethod def create(cls) -> t.Self | None: return cls() def find_pythons(self) -> t.Iterable[PythonVersion]: py_root = self.root / "py" if not py_root.exists(): return for child in safe_iter_dir(py_root): if child.is_symlink(): # registered an existing python continue python_bin = child / "install/bin/python3" if python_bin.exists(): yield self.version_maker(python_bin, _interpreter=python_bin) findpython-0.3.1/src/findpython/providers/winreg.py000066400000000000000000000023411445440406000225310ustar00rootroot00000000000000from __future__ import annotations import platform import typing as t from pathlib import Path from findpython.providers.base import BaseProvider from findpython.python import PythonVersion from findpython.utils import WINDOWS SYS_ARCHITECTURE = platform.architecture()[0] class WinregProvider(BaseProvider): """A provider that finds Python from the winreg.""" @classmethod def create(cls) -> t.Self | None: if not WINDOWS: return None return cls() def find_pythons(self) -> t.Iterable[PythonVersion]: from findpython.pep514tools import findall as pep514_findall env_versions = pep514_findall() for version in env_versions: install_path = getattr(version.info, "install_path", None) if install_path is None: continue try: path = Path(install_path.executable_path) except AttributeError: continue if path.exists(): py_ver = self.version_maker( path, None, getattr(version.info, "sys_architecture", SYS_ARCHITECTURE), path, ) yield py_ver findpython-0.3.1/src/findpython/python.py000066400000000000000000000144371445440406000205530ustar00rootroot00000000000000from __future__ import annotations import dataclasses as dc import logging import os import subprocess from functools import lru_cache from pathlib import Path from packaging.version import InvalidVersion, Version from findpython.utils import get_binary_hash logger = logging.getLogger("findpython") GET_VERSION_TIMEOUT = float(os.environ.get("FINDPYTHON_GET_VERSION_TIMEOUT", 5)) @lru_cache(maxsize=1024) def _run_script(executable: str, script: str, timeout: float | None = None) -> str: """Run a script and return the output.""" command = [executable, "-EsSc", script] logger.debug("Running script: %s", command) return subprocess.run( command, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, timeout=timeout, check=True, text=True, ).stdout @dc.dataclass class PythonVersion: """The single Python version object found by pythonfinder.""" executable: Path _version: Version | None = None _architecture: str | None = None _interpreter: Path | None = None keep_symlink: bool = False def is_valid(self) -> bool: """Return True if the python is not broken.""" try: v = self._get_version() except ( OSError, subprocess.CalledProcessError, subprocess.TimeoutExpired, InvalidVersion, ): return False if self._version is None: self._version = v return True @property def real_path(self) -> Path: """Resolve the symlink if possible and return the real path.""" try: return self.executable.resolve() except OSError: return self.executable @property def name(self) -> str: """Return the name of the python.""" return self.executable.name @property def interpreter(self) -> Path: if self._interpreter is None: self._interpreter = Path(self._get_interpreter()) return self._interpreter @property def version(self) -> Version: """Return the version of the python.""" if self._version is None: self._version = self._get_version() return self._version @property def major(self) -> int: """Return the major version of the python.""" return self.version.major @property def minor(self) -> int: """Return the minor version of the python.""" return self.version.minor @property def patch(self) -> int: """Return the micro version of the python.""" return self.version.micro @property def is_prerelease(self) -> bool: """Return True if the python is a prerelease.""" return self.version.is_prerelease @property def is_devrelease(self) -> bool: """Return True if the python is a devrelease.""" return self.version.is_devrelease @property def architecture(self) -> str: if not self._architecture: self._architecture = self._get_architecture() return self._architecture def binary_hash(self) -> str: """Return the binary hash of the python.""" return get_binary_hash(self.real_path) def matches( self, major: int | None = None, minor: int | None = None, patch: int | None = None, pre: bool | None = None, dev: bool | None = None, name: str | None = None, architecture: str | None = None, ) -> bool: """ Return True if the python matches the provided criteria. :param major: The major version to match. :type major: int :param minor: The minor version to match. :type minor: int :param patch: The micro version to match. :type patch: int :param pre: Whether the python is a prerelease. :type pre: bool :param dev: Whether the python is a devrelease. :type dev: bool :param name: The name of the python. :type name: str :param architecture: The architecture of the python. :type architecture: str :return: Whether the python matches the provided criteria. :rtype: bool """ if major is not None and self.major != major: return False if minor is not None and self.minor != minor: return False if patch is not None and self.patch != patch: return False if pre is not None and self.is_prerelease != pre: return False if dev is not None and self.is_devrelease != dev: return False if name is not None and self.name != name: return False if architecture is not None and self.architecture != architecture: return False return True def __hash__(self) -> int: return hash(self.executable) def __repr__(self) -> str: attrs = ("executable", "version", "architecture", "major", "minor", "patch") return "".format( ", ".join(f"{attr}={getattr(self, attr)!r}" for attr in attrs) ) def __str__(self) -> str: return f"{self.name} {self.version} @ {self.executable}" def _get_version(self) -> Version: """Get the version of the python.""" script = "import platform; print(platform.python_version())" version = _run_script( str(self.executable), script, timeout=GET_VERSION_TIMEOUT ).strip() # Dev builds may produce version like `3.11.0+` and packaging.version # will reject it. Here we just remove the part after `+` # since it isn't critical for version comparison. version = version.split("+")[0] return Version(version) def _get_architecture(self) -> str: script = "import platform; print(platform.architecture()[0])" return _run_script(str(self.executable), script).strip() def _get_interpreter(self) -> str: script = "import sys; print(sys.executable)" return _run_script(str(self.executable), script).strip() def __lt__(self, other: PythonVersion) -> bool: """Sort by the version, then by length of the executable path.""" return (self.version, len(self.executable.as_posix())) < ( other.version, len(other.executable.as_posix()), ) findpython-0.3.1/src/findpython/utils.py000066400000000000000000000104261445440406000203640ustar00rootroot00000000000000from __future__ import annotations import errno import hashlib import os import re import sys from functools import lru_cache from pathlib import Path from typing import TYPE_CHECKING, cast if TYPE_CHECKING: from typing import Generator, Sequence, TypedDict VERSION_RE = re.compile( r"(?P\d+)(?:\.(?P\d+)(?:\.(?P[0-9]+))?)?\.?" r"(?:(?P[abc]|rc|dev)(?:(?P\d+(?:\.\d+)*))?)" r"?(?P(\.post(?P\d+))?(\.dev(?P\d+))?)?" r"(?:-(?P32|64))?" ) WINDOWS = sys.platform == "win32" MACOS = sys.platform == "darwin" PYTHON_IMPLEMENTATIONS = ( "python", "ironpython", "jython", "pypy", "anaconda", "miniconda", "stackless", "activepython", "pyston", "micropython", ) if WINDOWS: KNOWN_EXTS: Sequence[str] = (".exe", "", ".py", ".bat") else: KNOWN_EXTS = ("", ".sh", ".bash", ".csh", ".zsh", ".fish", ".py") PY_MATCH_STR = ( r"((?P{0})(?:\d(?:\.?\d\d?[cpm]{{0,3}})?)?" r"(?:(?<=\d)-[\d\.]+)*(?!w))(?P{1})$".format( "|".join(PYTHON_IMPLEMENTATIONS), "|".join(KNOWN_EXTS), ) ) RE_MATCHER = re.compile(PY_MATCH_STR) def safe_iter_dir(path: Path) -> Generator[Path, None, None]: """Iterate over a directory, returning an empty iterator if the path is not a directory or is not readable. """ if not os.access(str(path), os.R_OK) or not path.is_dir(): return try: yield from path.iterdir() except OSError as exc: if exc.errno == errno.EACCES: return raise @lru_cache(maxsize=1024) def path_is_known_executable(path: Path) -> bool: """ Returns whether a given path is a known executable from known executable extensions or has the executable bit toggled. :param path: The path to the target executable. :type path: :class:`~Path` :return: True if the path has chmod +x, or is a readable, known executable extension. :rtype: bool """ try: return ( path.is_file() and os.access(str(path), os.R_OK) and (path.suffix in KNOWN_EXTS or os.access(str(path), os.X_OK)) ) except OSError: return False @lru_cache(maxsize=1024) def looks_like_python(name: str) -> bool: """ Determine whether the supplied filename looks like a possible name of python. :param str name: The name of the provided file. :return: Whether the provided name looks like python. :rtype: bool """ if not any(name.lower().startswith(py_name) for py_name in PYTHON_IMPLEMENTATIONS): return False match = RE_MATCHER.match(name) return bool(match) @lru_cache(maxsize=1024) def path_is_python(path: Path) -> bool: """ Determine whether the supplied path is a executable and looks like a possible path to python. :param path: The path to an executable. :type path: :class:`~Path` :return: Whether the provided path is an executable path to python. :rtype: bool """ return looks_like_python(path.name) and path_is_known_executable(path) @lru_cache(maxsize=1024) def get_binary_hash(path: Path) -> str: """Return the MD5 hash of the given file.""" hasher = hashlib.md5() with path.open("rb") as f: for chunk in iter(lambda: f.read(4096), b""): hasher.update(chunk) return hasher.hexdigest() if TYPE_CHECKING: class VersionDict(TypedDict): pre: bool | None dev: bool | None major: int | None minor: int | None patch: int | None architecture: str | None def parse_major(version: str) -> VersionDict | None: """Parse the version dict from the version string""" match = VERSION_RE.match(version) if not match: return None rv = match.groupdict() rv["pre"] = bool(rv.pop("prerel")) rv["dev"] = bool(rv.pop("dev")) for int_values in ("major", "minor", "patch"): if rv[int_values] is not None: rv[int_values] = int(rv[int_values]) if rv["architecture"]: rv["architecture"] = f"{rv['architecture']}bit" return cast("VersionDict", rv) def get_suffix_preference(name: str) -> int: for i, suffix in enumerate(KNOWN_EXTS): if suffix and name.endswith(suffix): return i return KNOWN_EXTS.index("") findpython-0.3.1/tests/000077500000000000000000000000001445440406000150405ustar00rootroot00000000000000findpython-0.3.1/tests/__init__.py000066400000000000000000000000001445440406000171370ustar00rootroot00000000000000findpython-0.3.1/tests/conftest.py000066400000000000000000000033211445440406000172360ustar00rootroot00000000000000from pathlib import Path import pytest from packaging.version import parse from findpython.providers import ALL_PROVIDERS, PathProvider from findpython.python import PythonVersion class _MockRegistry: def __init__(self) -> None: self.versions: dict[Path, PythonVersion] = {} def add_python( self, executable, version=None, architecture="64bit", interpreter=None, keep_symlink=False, ): if version is not None: version = parse(version) executable = Path(executable) if interpreter is None: interpreter = executable executable.parent.mkdir(parents=True, exist_ok=True) executable.touch(exist_ok=True) executable.chmod(0o744) py_ver = PythonVersion( executable, version, architecture, interpreter, keep_symlink ) if version is not None: py_ver._get_version = lambda: version self.versions[executable] = py_ver return py_ver def version_maker(self, executable, *args, **kwargs): return self.versions[executable] @pytest.fixture() def mocked_python(tmp_path, monkeypatch): mocked = _MockRegistry() for python in [ (tmp_path / "python3.7", "3.7.0"), (tmp_path / "python3.8", "3.8.0"), (tmp_path / "python3.9", "3.9.0"), ]: mocked.add_python(*python) monkeypatch.setattr( "findpython.providers.base.BaseProvider.version_maker", mocked.version_maker ) ALL_PROVIDERS.clear() ALL_PROVIDERS["path"] = PathProvider monkeypatch.setenv("PATH", str(tmp_path)) return mocked @pytest.fixture(params=[False, True]) def switch(request): return request.param findpython-0.3.1/tests/test_cli.py000066400000000000000000000011451445440406000172210ustar00rootroot00000000000000from findpython.__main__ import cli def test_cli_find_pythons(mocked_python, capsys): retcode = cli(["--all"]) assert retcode == 0 out, _ = capsys.readouterr() lines = out.strip().splitlines() for version, line in zip(("3.9", "3.8", "3.7"), lines): assert line.startswith(f"python{version} {version}.0") def test_cli_find_python_by_version(mocked_python, capsys, tmp_path): retcode = cli(["3.8"]) assert retcode == 0 out, _ = capsys.readouterr() line = out.strip() assert line.startswith("python3.8 3.8.0") assert line.endswith(str(tmp_path / "python3.8")) findpython-0.3.1/tests/test_finder.py000066400000000000000000000112541445440406000177230ustar00rootroot00000000000000import os from pathlib import Path import pytest from packaging.version import Version from findpython import Finder, register_provider from findpython.providers.pyenv import PyenvProvider def test_find_pythons(mocked_python, tmp_path): finder = Finder() all_pythons = finder.find_all() assert len(all_pythons) == 3 assert all_pythons[0].executable == Path(tmp_path / "python3.9") assert all_pythons[0].version == Version("3.9.0") assert all_pythons[1].executable == Path(tmp_path / "python3.8") assert all_pythons[1].version == Version("3.8.0") assert all_pythons[2].executable == Path(tmp_path / "python3.7") assert all_pythons[2].version == Version("3.7.0") def test_find_python_by_version(mocked_python, tmp_path): finder = Finder() python = finder.find(3, 8) assert python.executable == Path(tmp_path / "python3.8") assert python.version == Version("3.8.0") assert finder.find("3.8") == python assert finder.find("3.8.0") == python assert finder.find("python3.8") == python def test_find_python_by_version_not_found(mocked_python, tmp_path): finder = Finder() python = finder.find(3, 10) assert python is None def test_find_python_by_architecture(mocked_python, tmp_path): python = mocked_python.add_python( tmp_path / "python38", "3.8.0", architecture="32bit" ) finder = Finder() assert finder.find(3, 8, architecture="32bit") == python assert finder.find(3, 8, architecture="64bit").executable == tmp_path / "python3.8" def test_find_python_with_prerelease(mocked_python, tmp_path): python = mocked_python.add_python(tmp_path / "python3.10", "3.10.0.a1") finder = Finder() assert python == finder.find(pre=True) def test_find_python_with_devrelease(mocked_python, tmp_path): python = mocked_python.add_python(tmp_path / "python3.10", "3.10.0.dev1") finder = Finder() assert python == finder.find(dev=True) def test_find_python_with_non_existing_path(mocked_python, monkeypatch): monkeypatch.setenv("PATH", "/non/existing/path" + os.pathsep + os.environ["PATH"]) finder = Finder() all_pythons = finder.find_all() assert len(all_pythons) == 3 def test_find_python_exclude_invalid(mocked_python, tmp_path): python = mocked_python.add_python(tmp_path / "python3.10") finder = Finder() all_pythons = finder.find_all() assert len(all_pythons) == 3 assert python not in all_pythons def test_find_python_deduplicate_same_file(mocked_python, tmp_path, switch): for i, python in enumerate(mocked_python.versions): python.write_bytes(str(i).encode()) new_python = mocked_python.add_python(tmp_path / "python3", "3.9.0") new_python.executable.write_bytes(b"0") finder = Finder(no_same_file=switch) all_pythons = finder.find_all() assert len(all_pythons) == (3 if switch else 4) assert (new_python in all_pythons) is not switch @pytest.mark.skipif(os.name == "nt", reason="Not supported on Windows") def test_find_python_deduplicate_symlinks(mocked_python, tmp_path): python = mocked_python.add_python(tmp_path / "python3.9", "3.9.0") (tmp_path / "python3").symlink_to(python.executable) symlink1 = mocked_python.add_python(tmp_path / "python3", "3.9.0") (tmp_path / "python").symlink_to(python.executable) symlink2 = mocked_python.add_python(tmp_path / "python", "3.9.0", keep_symlink=True) finder = Finder(resolve_symlinks=True) all_pythons = finder.find_all() assert python in all_pythons assert symlink1 not in all_pythons assert symlink2 in all_pythons def test_find_python_deduplicate_same_interpreter(mocked_python, tmp_path, switch): if os.name == "nt": suffix = ".bat" else: suffix = ".sh" python = mocked_python.add_python( tmp_path / f"python{suffix}", "3.9.0", interpreter=tmp_path / "python3.9" ) finder = Finder(no_same_interpreter=switch) all_pythons = finder.find_all() assert len(all_pythons) == (3 if switch else 4) assert (python in all_pythons) is not switch def test_find_python_from_pyenv(mocked_python, tmp_path, monkeypatch): register_provider(PyenvProvider) python = mocked_python.add_python( tmp_path / ".pyenv/versions/3.8/bin/python", "3.8.0" ) monkeypatch.setenv("PYENV_ROOT", str(tmp_path / ".pyenv")) pythons = Finder().find_all(3, 8) assert len(pythons) == 2 assert python in pythons def test_find_python_skips_empty_pyenv(mocked_python, tmp_path, monkeypatch): register_provider(PyenvProvider) pyenv_path = Path(tmp_path / ".pyenv") pyenv_path.mkdir() monkeypatch.setenv("PYENV_ROOT", str(pyenv_path)) all_pythons = Finder().find_all() assert len(all_pythons) == 3 findpython-0.3.1/tests/test_posix.py000066400000000000000000000060411445440406000176140ustar00rootroot00000000000000import stat import sys from pathlib import Path import pytest from findpython import register_provider from findpython.finder import Finder from findpython.providers.asdf import AsdfProvider from findpython.providers.pyenv import PyenvProvider from findpython.providers.rye import RyeProvider if sys.platform == "win32": pytest.skip("Skip POSIX tests on Windows", allow_module_level=True) def test_find_python_resolve_symlinks(mocked_python, tmp_path, switch): link = Path(tmp_path / "python") link.symlink_to(Path(tmp_path / "python3.7")) python = mocked_python.add_python(link, "3.7.0") finder = Finder(resolve_symlinks=switch) all_pythons = finder.find_all() assert len(all_pythons) == (3 if switch else 4) assert (python in all_pythons) is not switch def test_find_python_from_asdf(mocked_python, tmp_path, monkeypatch): register_provider(AsdfProvider) python = mocked_python.add_python( tmp_path / ".asdf/installs/python/3.8/bin/python", "3.8.0" ) monkeypatch.setenv("ASDF_DATA_DIR", str(tmp_path / ".asdf")) pythons = Finder().find_all(3, 8) assert len(pythons) == 2 assert python in pythons def test_find_python_exclude_unreadable(mocked_python, tmp_path): python = Path(tmp_path / "python3.8") python.chmod(python.stat().st_mode & ~stat.S_IRUSR) try: finder = Finder() all_pythons = finder.find_all() assert len(all_pythons) == 2, all_pythons assert python not in [version.executable for version in all_pythons] finally: python.chmod(0o744) def test_find_python_from_provider(mocked_python, tmp_path, monkeypatch): register_provider(AsdfProvider) register_provider(PyenvProvider) python38 = mocked_python.add_python( tmp_path / ".asdf/installs/python/3.8/bin/python", "3.8.0" ) python381 = mocked_python.add_python( tmp_path / ".pyenv/versions/3.8.1/bin/python", "3.8.1" ) python382 = mocked_python.add_python( tmp_path / ".asdf/installs/python/3.8.2/bin/python", "3.8.2" ) monkeypatch.setenv("ASDF_DATA_DIR", str(tmp_path / ".asdf")) monkeypatch.setenv("PYENV_ROOT", str(tmp_path / ".pyenv")) pythons = Finder(selected_providers=["pyenv", "asdf"]).find_all(3, 8) assert len(pythons) == 3 assert python38 in pythons assert python381 in pythons assert python382 in pythons asdf_pythons = Finder(selected_providers=["asdf"]).find_all(3, 8) assert len(asdf_pythons) == 2 assert python38 in asdf_pythons assert python382 in asdf_pythons pyenv_pythons = Finder(selected_providers=["pyenv"]).find_all(3, 8) assert len(pyenv_pythons) == 1 assert python381 in pyenv_pythons def test_find_python_from_rye_provider(mocked_python, tmp_path, monkeypatch): python310 = mocked_python.add_python( tmp_path / ".rye/py/cpython@3.10.9/install/bin/python3", "3.10.9" ) monkeypatch.setenv("HOME", str(tmp_path)) register_provider(RyeProvider) pythons = Finder(selected_providers=["rye"]).find_all(3, 10) assert python310 in pythons findpython-0.3.1/tests/test_utils.py000066400000000000000000000012511445440406000176100ustar00rootroot00000000000000import pytest from findpython.utils import WINDOWS, looks_like_python matrix = [ ("python", True), ("python3", True), ("python38", True), ("python3.8", True), ("python3.10", True), ("python310", True), ("python3.6m", True), ("python3.6.8m", False), ("anaconda3.3", True), ("python-3.8.10", False), ("unknown-2.0.0", False), ("python3.8.unknown", False), ("python38.bat", WINDOWS), ("python38.exe", WINDOWS), ("python38.sh", not WINDOWS), ("python38.csh", not WINDOWS), ] @pytest.mark.parametrize("name, expected", matrix) def test_looks_like_python(name, expected): assert looks_like_python(name) == expected