Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/auto_test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ jobs:
- name: Install dependencies
run: |
python -m pip install --upgrade pip
python -m pip install -e . --group dev
python -m pip install -e . --group test
- name: Test with pytest
run: |
pytest tests/
2 changes: 1 addition & 1 deletion .github/workflows/test_builds.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ jobs:
- name: Set up Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: "3.14" # build with oldest supported python
python-version: "3.14" # build with 3.14 to include 3.14 dependencies
- name: Build the Zipapp
run: >-
python3 scripts/build_zipapp.py
Expand Down
16 changes: 16 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,16 @@ dynamic = ['version']

[dependency-groups]
dev = [
{include-group = "test"},
{include-group = "type-check"},
]

test = [
"pytest>=8.4",
"pytest-cov>=6.1",
"pyfakefs>=5.8",
]
type-check = [
"mypy>=1.16",
]

Expand All @@ -64,6 +71,15 @@ testpaths = [
"tests",
]

[tool.ruff.lint]
# I001 / RUF023 - Import, __all__ and __slots__ sorting rules.
ignore = ["I001", "RUF022", "RUF023"]

[tool.ruff.lint.extend-per-file-ignores]
# The details script is written to run under ancient Python as well as new Python
# As such it can't use features that didn't exist in Python2
"src/ducktools/pythonfinder/details_script.py" = ["C408", "UP032"]

[tool.uv]
exclude-newer = "1 week"

Expand Down
4 changes: 2 additions & 2 deletions scripts/detail_this_python.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,6 @@

import sys

from ducktools.pythonfinder.shared import get_install_details
from ducktools.pythonfinder.shared import DetailFinder

print(get_install_details(sys.executable))
print(DetailFinder().get_install_details(sys.executable))
37 changes: 18 additions & 19 deletions src/ducktools/pythonfinder/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ def _get_formatter(self, file=None, *args, **kwargs):

# noinspection PyArgumentList
formatter = self.formatter_class(prog=self.prog, width=columns - 2)
if sys.version_info >= (3, 15): #
if sys.version_info >= (3, 15):
formatter._set_color(self.color, file=file)
elif sys.version_info >= (3, 14):
formatter._set_color(self.color)
Expand All @@ -120,7 +120,7 @@ def _get_formatter(self, file=None, *args, **kwargs):


def get_parser() -> argparse.ArgumentParser:
FixedArgumentParser = _get_parser_class() # noqa
FixedArgumentParser = _get_parser_class()

parser = FixedArgumentParser(
prog="ducktools-pythonfinder",
Expand All @@ -130,14 +130,15 @@ def get_parser() -> argparse.ArgumentParser:

subparsers = parser.add_subparsers(dest="command", required=False)

clear_cache = subparsers.add_parser(
_ = subparsers.add_parser(
"clear-cache",
help="Clear the cache of Python install details"
)

parser.add_argument("--min", help="Specify minimum Python version")
parser.add_argument("--max", help="Specify maximum Python version")
parser.add_argument("--compatible", help="Specify compatible Python version")
specifiers = parser.add_argument_group("Version specifiers", "Specifiers for Python version filters")
specifiers.add_argument("--min", help="Specify minimum Python version")
specifiers.add_argument("--max", help="Specify maximum Python version")
specifiers.add_argument("--compatible", help="Specify compatible Python version")

return parser

Expand All @@ -147,12 +148,10 @@ def display_local_installs(
max_ver: str | None = None,
compatible: str | None = None,
) -> None:
if min_ver:
min_ver_tuple = version_str_to_tuple(min_ver)
if max_ver:
max_ver_tuple = version_str_to_tuple(max_ver)
if compatible:
compatible_spec = _laz.SpecifierSet(f"~={compatible}")

min_ver_tuple = version_str_to_tuple(min_ver) if min_ver else None
max_ver_tuple = version_str_to_tuple(max_ver) if max_ver else None
compatible_spec = _laz.SpecifierSet(f"~={compatible}") if compatible else None

installs = list_python_installs()

Expand All @@ -168,11 +167,11 @@ def display_local_installs(

# First collect the strings
for install in installs:
if min_ver and install.version < min_ver_tuple:
continue
elif max_ver and install.version > max_ver_tuple:
continue
elif compatible and not compatible_spec.contains(install.version_str):
if (
(min_ver_tuple and install.version < min_ver_tuple)
or (max_ver_tuple and install.version > max_ver_tuple)
or (compatible_spec and not compatible_spec.contains(install.version_str))
):
continue

version_str = install.version_str
Expand Down Expand Up @@ -233,11 +232,11 @@ def display_local_installs(


def main() -> int:
if sys.version_info < (3, 10):
if sys.version_info < (3, 12): # ruff: ignore[UP036]
v = sys.version_info
raise UnsupportedPythonError(
f"Python {v.major}.{v.minor}.{v.micro} is not supported. "
f"ducktools.pythonfinder requires Python 3.10 or later."
f"ducktools.pythonfinder requires Python 3.12 or later."
)

if sys.argv[1:]:
Expand Down
4 changes: 2 additions & 2 deletions src/ducktools/pythonfinder/details_script.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ def get_details():
"graalpy_version": version_str_to_tuple(ver)
}
except (NameError, ValueError):
metadata = {"{}_version".format(implementation): sys.implementation.version}
metadata = {"{}_version".format(implementation): sys.implementation.version}
elif implementation != "cpython": # pragma: no cover
if implementation == "micropython":
imp_ver = sys.implementation.version[:3]
Expand Down Expand Up @@ -110,7 +110,7 @@ def get_details():
architecture = "64bit" if (sys.maxsize > 2**32) else "32bit"
else:
architecture = "32bit" if (struct.calcsize("P") == 4) else "64bit"

install = dict(
version=list(sys.version_info),
executable=sys.executable,
Expand Down
5 changes: 2 additions & 3 deletions src/ducktools/pythonfinder/linux/pyenv_search.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,9 +80,8 @@ def get_pyenv_pythons(
*,
finder: DetailFinder | None = None,
) -> Iterator[PythonInstall]:
if versions_folder is None:
if pyenv_root := get_pyenv_root():
versions_folder = os.path.join(pyenv_root, "versions")
if versions_folder is None and (pyenv_root := get_pyenv_root()):
versions_folder = os.path.join(pyenv_root, "versions")

if versions_folder is None or not os.path.exists(versions_folder):
return
Expand Down
41 changes: 24 additions & 17 deletions src/ducktools/pythonfinder/shared.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@
f"Could not find local app data folder {_local_app_folder}"
)
else:
raise EnvironmentError(
raise RuntimeError(
"Environment variable %LOCALAPPDATA% "
"for local application data folder location "
"not found"
Expand Down Expand Up @@ -139,7 +139,7 @@ class DetailsScript(Prefab):
"""
_source_code: str | None = attribute(default=None, private=True)

def get_source_code(self):
def get_source_code(self) -> str:
if self._source_code is None:
if os.path.exists(details_file := details_script.__file__):
with open(details_file) as f:
Expand All @@ -154,6 +154,8 @@ def get_source_code(self):
else:
raise FileNotFoundError(f"Could not find {details_script.__file__!r}")

assert isinstance(self._source_code, str)

return self._source_code


Expand Down Expand Up @@ -193,6 +195,9 @@ def raw_cache(self) -> dict:
self._raw_cache = _laz.json.load(f)
except (_laz.json.JSONDecodeError, FileNotFoundError):
self._raw_cache = {}

assert isinstance(self._raw_cache, dict)

return self._raw_cache

def save(self) -> None:
Expand All @@ -207,7 +212,7 @@ def clear_invalid_runtimes(self) -> None:
Remove cache entries where the python.exe no longer exists
"""
removed_runtimes: set[str] = set()
for exe_path in self.raw_cache.copy().keys():
for exe_path in self.raw_cache.copy():
if not os.path.exists(exe_path):
self.raw_cache.pop(exe_path)
removed_runtimes.add(exe_path)
Expand Down Expand Up @@ -338,7 +343,7 @@ def __prefab_post_init__(
if len(version) == 3:
# Micropython gives an invalid 3 part version here
# Add the extras to avoid breaking
self.version = tuple([*version, "final", 0]) # type: ignore
self.version = (*version, "final", 0) # type: ignore
else:
self.version = version # type: ignore

Expand Down Expand Up @@ -371,7 +376,7 @@ def implementation_version(self) -> tuple[int, int, int, str, int] | None:
if self._implementation_version is None:
if implementation_ver := self.metadata.get(f"{self.implementation}_version"):
if len(implementation_ver) == 3:
self._implementation_version = tuple([*implementation_ver, "final", 0]) # type: ignore
self._implementation_version = (*implementation_ver, "final", 0) # type: ignore
else:
self._implementation_version = implementation_ver
else:
Expand Down Expand Up @@ -568,15 +573,17 @@ def _implementation_from_uv_dir(
def get_uv_pythons(finder=None) -> Iterator[PythonInstall]:
# This takes some shortcuts over the regular pythonfinder
# As the UV folders give the python version and the implementation
if uv_python_path := get_uv_python_path():
if os.path.exists(uv_python_path):
finder = DetailFinder() if finder is None else finder

with finder, os.scandir(uv_python_path) as fld:
for f in fld:
if (
f.is_dir()
and not f.is_symlink()
and (install := _implementation_from_uv_dir(f, finder=finder))
):
yield install
if (
(uv_python_path := get_uv_python_path())
and os.path.exists(uv_python_path)
):
finder = DetailFinder() if finder is None else finder

with finder, os.scandir(uv_python_path) as fld:
for f in fld:
if (
f.is_dir()
and not f.is_symlink()
and (install := _implementation_from_uv_dir(f, finder=finder))
):
yield install
4 changes: 1 addition & 3 deletions src/ducktools/pythonfinder/venv.py
Original file line number Diff line number Diff line change
Expand Up @@ -179,9 +179,7 @@ def parent_executable(self) -> str | None:

@property
def parent_exists(self) -> bool:
if self.parent_executable and os.path.exists(self.parent_executable):
return True
return False
return bool(self.parent_executable and os.path.exists(self.parent_executable))

def get_parent_install(
self,
Expand Down
5 changes: 2 additions & 3 deletions src/ducktools/pythonfinder/win32/pyenv_search.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,8 @@ def get_pyenv_pythons(
finder: DetailFinder | None = None,
) -> Iterator[PythonInstall]:

if versions_folder is None:
if pyenv_root := get_pyenv_root():
versions_folder = os.path.join(pyenv_root, "versions")
if versions_folder is None and (pyenv_root := get_pyenv_root()):
versions_folder = os.path.join(pyenv_root, "versions")

if versions_folder is None or not os.path.exists(versions_folder):
return
Expand Down
23 changes: 11 additions & 12 deletions src/ducktools/pythonfinder/win32/registry_search.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@
import winreg
from _collections_abc import Iterator

from ..shared import DetailFinder, PythonInstall, version_str_to_tuple
from ..shared import DetailFinder, PythonInstall

exclude_companies = {
"PyLauncher", # pylauncher is special cased to be ignored
Expand Down Expand Up @@ -90,7 +90,7 @@ def get_registered_pythons(finder: DetailFinder | None = None) -> Iterator[Pytho
comp_metadata[f"Company{name}"] = data

for py_keyname in enum_keys(company_key):
metadata = {
metadata: dict = {
**comp_metadata,
"Tag": py_keyname,
}
Expand All @@ -114,16 +114,15 @@ def get_registered_pythons(finder: DetailFinder | None = None) -> Iterator[Pytho

metadata["InWindowsRegistry"] = True

if python_path:
# Pyenv puts architecture information in the Version value for some reason
if os.path.isfile(python_path):
details = finder.get_install_details(
python_path,
managed_by=metadata["Company"],
metadata=metadata,
)
if details:
yield details
# Pyenv puts architecture information in the Version value for some reason
if python_path and os.path.isfile(python_path):
details = finder.get_install_details(
python_path,
managed_by=metadata["Company"],
metadata=metadata,
)
if details:
yield details

finally:
if base_key:
Expand Down
Loading