pax_global_header 0000666 0000000 0000000 00000000064 14704062261 0014514 g ustar 00root root 0000000 0000000 52 comment=bab436668eae84da79ca69d1f281fefb98b2a9a6
findpython-0.6.2/ 0000775 0000000 0000000 00000000000 14704062261 0013703 5 ustar 00root root 0000000 0000000 findpython-0.6.2/.github/ 0000775 0000000 0000000 00000000000 14704062261 0015243 5 ustar 00root root 0000000 0000000 findpython-0.6.2/.github/workflows/ 0000775 0000000 0000000 00000000000 14704062261 0017300 5 ustar 00root root 0000000 0000000 findpython-0.6.2/.github/workflows/ci.yml 0000664 0000000 0000000 00000002560 14704062261 0020421 0 ustar 00root root 0000000 0000000 name: Tests
on:
pull_request:
paths-ignore:
- "*.md"
push:
branches:
- main
paths-ignore:
- "*.md"
jobs:
Testing:
runs-on: ${{ matrix.os }}
strategy:
matrix:
python-version: [3.8, 3.9, "3.10", "3.11", "3.12", "3.13"]
os: [ubuntu-latest, windows-latest]
arch: [x64]
include:
- python-version: "3.12"
os: windows-latest
arch: x86
- python-version: "3.8"
os: macos-13
arch: x64
- python-version: "3.9"
os: macos-13
arch: x64
- python-version: "3.10"
os: macos-latest
arch: arm64
- python-version: "3.11"
os: macos-latest
arch: arm64
- python-version: "3.12"
os: macos-latest
arch: arm64
- python-version: "3.13"
os: macos-latest
arch: arm64
steps:
- uses: actions/checkout@v4
- name: Set up PDM
uses: pdm-project/setup-pdm@v4
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.6.2/.github/workflows/release.yml 0000664 0000000 0000000 00000001556 14704062261 0021452 0 ustar 00root root 0000000 0000000 name: Release
on:
push:
tags:
- "*"
jobs:
release-pypi:
name: release-pypi
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: 20
- 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: |
pipx run 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: |
pipx run twine upload --username __token__ --password ${{ secrets.PYPI_TOKEN }} dist/*
findpython-0.6.2/.gitignore 0000664 0000000 0000000 00000000207 14704062261 0015672 0 ustar 00root root 0000000 0000000 .pdm-python
*.py[cod]
__pycache__/
venv/
.tox/
.vscode/
/build/
/dist/
*.egg-info/
/target/
.pdm-build/
/src/findpython/__version__.py
findpython-0.6.2/.pre-commit-config.yaml 0000664 0000000 0000000 00000001117 14704062261 0020164 0 ustar 00root root 0000000 0000000 exclude: >
(?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.6.2/LICENSE 0000664 0000000 0000000 00000002063 14704062261 0014711 0 ustar 00root root 0000000 0000000 MIT 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.6.2/README.md 0000664 0000000 0000000 00000011046 14704062261 0015164 0 ustar 00root root 0000000 0000000 # FindPython
_A utility to find python versions on your system._
[](https://github.com/frostming/findpython/actions/workflows/ci.yml)
[](https://pypi.org/project/findpython)
[](https://pypi.org/project/findpython)
[](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
## 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.6.2/pdm.lock 0000664 0000000 0000000 00000012666 14704062261 0015350 0 ustar 00root root 0000000 0000000 # This file is @generated by PDM.
# It is not intended for manual editing.
[metadata]
groups = ["default", "tests"]
strategy = ["cross_platform", "inherit_metadata"]
lock_version = "4.4.1"
content_hash = "sha256:6eb2efcbb7606a63e2aac268217a24edd3c8b7d908d0a537d3f896fc525b0a03"
[[package]]
name = "attrs"
version = "22.1.0"
requires_python = ">=3.5"
summary = "Classes Without Boilerplate"
groups = ["tests"]
files = [
{file = "attrs-22.1.0-py2.py3-none-any.whl", hash = "sha256:86efa402f67bf2df34f51a335487cf46b1ec130d02b8d39fd248abfd30da551c"},
{file = "attrs-22.1.0.tar.gz", hash = "sha256:29adc2665447e5191d0e7c568fde78b21f9672d344281d0c6e1ab085429b22b6"},
]
[[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."
groups = ["tests"]
marker = "sys_platform == \"win32\""
files = [
{file = "colorama-0.4.5-py2.py3-none-any.whl", hash = "sha256:854bf444933e37f5824ae7bfc1e98d5bce2ebe4160d46b5edf346a89358e99da"},
{file = "colorama-0.4.5.tar.gz", hash = "sha256:e6c6b4334fc50988a639d9b98aa429a0b57da6e17b9a44f0451f930b6967b7a4"},
]
[[package]]
name = "exceptiongroup"
version = "1.1.0"
requires_python = ">=3.7"
summary = "Backport of PEP 654 (exception groups)"
groups = ["tests"]
marker = "python_version < \"3.11\""
files = [
{file = "exceptiongroup-1.1.0-py3-none-any.whl", hash = "sha256:327cbda3da756e2de031a3107b81ab7b3770a602c4d16ca618298c526f4bec1e"},
{file = "exceptiongroup-1.1.0.tar.gz", hash = "sha256:bcb67d800a4497e1b404c2dd44fca47d3b7a5e5433dbab67f96c1a685cdfdf23"},
]
[[package]]
name = "importlib-metadata"
version = "4.12.0"
requires_python = ">=3.7"
summary = "Read metadata from Python packages"
groups = ["tests"]
marker = "python_version < \"3.8\""
dependencies = [
"typing-extensions>=3.6.4; python_version < \"3.8\"",
"zipp>=0.5",
]
files = [
{file = "importlib_metadata-4.12.0-py3-none-any.whl", hash = "sha256:7401a975809ea1fdc658c3aa4f78cc2195a0e019c5cbc4c06122884e9ae80c23"},
{file = "importlib_metadata-4.12.0.tar.gz", hash = "sha256:637245b8bab2b6502fcbc752cc4b7a6f6243bb02b31c5c26156ad103d3d45670"},
]
[[package]]
name = "iniconfig"
version = "1.1.1"
summary = "iniconfig: brain-dead simple config-ini parsing"
groups = ["tests"]
files = [
{file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"},
{file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"},
]
[[package]]
name = "packaging"
version = "23.0"
requires_python = ">=3.7"
summary = "Core utilities for Python packages"
groups = ["default", "tests"]
files = [
{file = "packaging-23.0-py3-none-any.whl", hash = "sha256:714ac14496c3e68c99c29b00845f7a2b85f3bb6f1078fd9f72fd20f0570002b2"},
{file = "packaging-23.0.tar.gz", hash = "sha256:b6ad297f8907de0fa2fe1ccbd26fdaf387f5f47c7275fedf8cce89f99446cf97"},
]
[[package]]
name = "pluggy"
version = "1.0.0"
requires_python = ">=3.6"
summary = "plugin and hook calling mechanisms for python"
groups = ["tests"]
dependencies = [
"importlib-metadata>=0.12; python_version < \"3.8\"",
]
files = [
{file = "pluggy-1.0.0-py2.py3-none-any.whl", hash = "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3"},
{file = "pluggy-1.0.0.tar.gz", hash = "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159"},
]
[[package]]
name = "pytest"
version = "7.2.1"
requires_python = ">=3.7"
summary = "pytest: simple powerful testing with Python"
groups = ["tests"]
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\"",
]
files = [
{file = "pytest-7.2.1-py3-none-any.whl", hash = "sha256:c7c6ca206e93355074ae32f7403e8ea12163b1163c976fee7d4d84027c162be5"},
{file = "pytest-7.2.1.tar.gz", hash = "sha256:d45e0952f3727241918b8fd0f376f5ff6b301cc0777c6f9a556935c92d8a7d42"},
]
[[package]]
name = "tomli"
version = "2.0.1"
requires_python = ">=3.7"
summary = "A lil' TOML parser"
groups = ["tests"]
marker = "python_version < \"3.11\""
files = [
{file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"},
{file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"},
]
[[package]]
name = "typing-extensions"
version = "4.3.0"
requires_python = ">=3.7"
summary = "Backported and Experimental Type Hints for Python 3.7+"
groups = ["tests"]
marker = "python_version < \"3.8\""
files = [
{file = "typing_extensions-4.3.0-py3-none-any.whl", hash = "sha256:25642c956049920a5aa49edcdd6ab1e06d7e5d467fc00e0506c44ac86fbfca02"},
{file = "typing_extensions-4.3.0.tar.gz", hash = "sha256:e6d2677a32f47fc7eb2795db1dd15c1f34eff616bcaf2cfb5e997f854fa1c4a6"},
]
[[package]]
name = "zipp"
version = "3.8.1"
requires_python = ">=3.7"
summary = "Backport of pathlib-compatible object wrapper for zip files"
groups = ["tests"]
marker = "python_version < \"3.8\""
files = [
{file = "zipp-3.8.1-py3-none-any.whl", hash = "sha256:47c40d7fe183a6f21403a199b3e4192cca5774656965b0a4988ad2f8feb5f009"},
{file = "zipp-3.8.1.tar.gz", hash = "sha256:05b45f1ee8f807d0cc928485ca40a07cb491cf092ff587c0df9cb1fd154848d2"},
]
findpython-0.6.2/pyproject.toml 0000664 0000000 0000000 00000003123 14704062261 0016616 0 ustar 00root root 0000000 0000000 [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.8"
license = {text = "MIT"}
readme = "README.md"
dynamic = ["version"]
classifiers = [
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
]
[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.6.2/src/ 0000775 0000000 0000000 00000000000 14704062261 0014472 5 ustar 00root root 0000000 0000000 findpython-0.6.2/src/findpython/ 0000775 0000000 0000000 00000000000 14704062261 0016654 5 ustar 00root root 0000000 0000000 findpython-0.6.2/src/findpython/__init__.py 0000664 0000000 0000000 00000003610 14704062261 0020765 0 ustar 00root root 0000000 0000000 """
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.6.2/src/findpython/__main__.py 0000664 0000000 0000000 00000005257 14704062261 0020757 0 ustar 00root root 0000000 0000000 from __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(
"--pre", "--prereleases", action="store_true", help="Allow prereleases"
)
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, allow_prereleases=args.pre)
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.6.2/src/findpython/finder.py 0000664 0000000 0000000 00000016761 14704062261 0020510 0 ustar 00root root 0000000 0000000 from __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,
allow_prereleases: bool = False,
implementation: 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 allow_prereleases: Whether to allow prereleases.
:param implementation: The implementation of the python. E.g. "cpython", "pypy".
:return: a list of PythonVersion objects
"""
if allow_prereleases and (pre is False or dev is False):
raise ValueError(
"If allow_prereleases is True, pre and dev must not be False."
)
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"]
if allow_prereleases:
pre = pre or None
dev = dev or None
architecture = version_dict["architecture"]
implementation = version_dict["implementation"]
else:
name, major = major, None
version_matcher = operator.methodcaller(
"matches",
major,
minor,
patch,
pre,
dev,
name,
architecture,
implementation,
)
# 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,
allow_prereleases: bool = False,
implementation: 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 allow_prereleases: Whether to allow prereleases.
:param implementation: The implementation of the python. E.g. "cpython", "pypy".
:return: a Python object or None
"""
return next(
iter(
self.find_all(
major,
minor,
patch,
pre,
dev,
name,
architecture,
allow_prereleases,
implementation,
)
),
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.6.2/src/findpython/pep514tools/ 0000775 0000000 0000000 00000000000 14704062261 0020753 5 ustar 00root root 0000000 0000000 findpython-0.6.2/src/findpython/pep514tools/__init__.py 0000664 0000000 0000000 00000000672 14704062261 0023071 0 ustar 00root root 0000000 0000000 # -------------------------------------------------------------------------
# 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.6.2/src/findpython/pep514tools/__main__.py 0000664 0000000 0000000 00000000376 14704062261 0023053 0 ustar 00root root 0000000 0000000 # -------------------------------------------------------------------------
# Copyright (c) Steve Dower
# All rights reserved.
#
# Distributed under the terms of the MIT License
# -------------------------------------------------------------------------
findpython-0.6.2/src/findpython/pep514tools/_registry.py 0000664 0000000 0000000 00000014647 14704062261 0023350 0 ustar 00root root 0000000 0000000 # -------------------------------------------------------------------------
# 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.6.2/src/findpython/pep514tools/environment.py 0000664 0000000 0000000 00000011036 14704062261 0023672 0 ustar 00root root 0000000 0000000 # -------------------------------------------------------------------------
# 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.6.2/src/findpython/providers/ 0000775 0000000 0000000 00000000000 14704062261 0020671 5 ustar 00root root 0000000 0000000 findpython-0.6.2/src/findpython/providers/__init__.py 0000664 0000000 0000000 00000001516 14704062261 0023005 0 ustar 00root root 0000000 0000000 """
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.6.2/src/findpython/providers/asdf.py 0000664 0000000 0000000 00000002042 14704062261 0022156 0 ustar 00root root 0000000 0000000 from __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.6.2/src/findpython/providers/base.py 0000664 0000000 0000000 00000003437 14704062261 0022164 0 ustar 00root root 0000000 0000000 from __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.6.2/src/findpython/providers/macos.py 0000664 0000000 0000000 00000001367 14704062261 0022354 0 ustar 00root root 0000000 0000000 from __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.6.2/src/findpython/providers/path.py 0000664 0000000 0000000 00000001226 14704062261 0022200 0 ustar 00root root 0000000 0000000 from __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.6.2/src/findpython/providers/pyenv.py 0000664 0000000 0000000 00000002060 14704062261 0022402 0 ustar 00root root 0000000 0000000 from __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.6.2/src/findpython/providers/rye.py 0000664 0000000 0000000 00000002025 14704062261 0022041 0 ustar 00root root 0000000 0000000 from __future__ import annotations
import os
import typing as t
from pathlib import Path
from findpython.providers.base import BaseProvider
from findpython.python import PythonVersion
from findpython.utils import WINDOWS, safe_iter_dir
class RyeProvider(BaseProvider):
def __init__(self, root: Path) -> None:
self.root = root
@classmethod
def create(cls) -> t.Self | None:
root = Path(os.getenv("RYE_PY_ROOT", "~/.rye/py")).expanduser()
return cls(root)
def find_pythons(self) -> t.Iterable[PythonVersion]:
if not self.root.exists():
return
for child in safe_iter_dir(self.root):
for intermediate in ("", "install/"):
if WINDOWS:
python_bin = child / (intermediate + "python.exe")
else:
python_bin = child / (intermediate + "bin/python3")
if python_bin.exists():
yield self.version_maker(python_bin, _interpreter=python_bin)
break
findpython-0.6.2/src/findpython/providers/winreg.py 0000664 0000000 0000000 00000003102 14704062261 0022532 0 ustar 00root root 0000000 0000000 from __future__ import annotations
import platform
import typing as t
from pathlib import Path
from packaging.version import Version
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_version = getattr(version.info, "version", None)
parse_version: Version | None = None
if py_version:
try:
parse_version = Version(py_version)
except ValueError:
pass
py_ver = self.version_maker(
path,
parse_version,
getattr(version.info, "sys_architecture", SYS_ARCHITECTURE),
path,
)
yield py_ver
findpython-0.6.2/src/findpython/python.py 0000664 0000000 0000000 00000015700 14704062261 0020552 0 ustar 00root root 0000000 0000000 from __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 implementation(self) -> str:
"""Return the implementation of the python."""
script = "import platform; print(platform.python_implementation())"
return _run_script(str(self.executable), script).strip()
@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,
implementation: 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
:param implementation: The implementation of the python.
:type implementation: 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
if (
implementation is not None
and self.implementation.lower() != implementation.lower()
):
return False
return True
def __hash__(self) -> int:
return hash(self.executable)
def __repr__(self) -> str:
attrs = (
"executable",
"version",
"architecture",
"implementation",
"major",
"minor",
"patch",
)
return "".format(
", ".join(f"{attr}={getattr(self, attr)!r}" for attr in attrs)
)
def __str__(self) -> str:
return f"{self.implementation:>9}@{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.6.2/src/findpython/utils.py 0000664 0000000 0000000 00000010510 14704062261 0020363 0 ustar 00root root 0000000 0000000 from __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\w+)@)?(?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
dev: bool
major: int | None
minor: int | None
patch: int | None
architecture: str | None
implementation: 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.6.2/tests/ 0000775 0000000 0000000 00000000000 14704062261 0015045 5 ustar 00root root 0000000 0000000 findpython-0.6.2/tests/__init__.py 0000664 0000000 0000000 00000000000 14704062261 0017144 0 ustar 00root root 0000000 0000000 findpython-0.6.2/tests/conftest.py 0000664 0000000 0000000 00000003577 14704062261 0017260 0 ustar 00root root 0000000 0000000 from pathlib import Path
from unittest.mock import PropertyMock
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
)
monkeypatch.setattr(
"findpython.python.PythonVersion.implementation",
PropertyMock(return_value="CPython"),
)
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.6.2/tests/test_cli.py 0000664 0000000 0000000 00000001144 14704062261 0017225 0 ustar 00root root 0000000 0000000 from 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.lstrip().startswith(f"CPython@{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("CPython@3.8.0")
assert line.endswith(str(tmp_path / "python3.8"))
findpython-0.6.2/tests/test_finder.py 0000664 0000000 0000000 00000011254 14704062261 0017730 0 ustar 00root root 0000000 0000000 import 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.6.2/tests/test_posix.py 0000664 0000000 0000000 00000006371 14704062261 0017627 0 ustar 00root root 0000000 0000000 import 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"
)
python311 = mocked_python.add_python(
tmp_path / ".rye/py/cpython@3.11.8/bin/python3", "3.11.8"
)
monkeypatch.setenv("HOME", str(tmp_path))
register_provider(RyeProvider)
find_310 = Finder(selected_providers=["rye"]).find_all(3, 10)
assert python310 in find_310
find_311 = Finder(selected_providers=["rye"]).find_all(3, 11)
assert python311 in find_311
findpython-0.6.2/tests/test_utils.py 0000664 0000000 0000000 00000001251 14704062261 0017615 0 ustar 00root root 0000000 0000000 import 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