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 src/ducktools/pythonfinder/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@
from .linux import get_python_installs


def list_python_installs(*, finder: DetailFinder | None = None):
def list_python_installs(*, finder: DetailFinder | None = None) -> list[PythonInstall]:
finder = DetailFinder() if finder is None else finder
return sorted(
get_python_installs(finder=finder),
Expand Down
63 changes: 36 additions & 27 deletions src/ducktools/pythonfinder/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,15 @@
import os

from ducktools.lazyimporter import LazyImporter, ModuleImport, FromImport
from ducktools.pythonfinder import list_python_installs, __version__
from ducktools.pythonfinder.shared import purge_caches

from . import list_python_installs, __version__
from .shared import purge_caches


TYPE_CHECKING = False
if TYPE_CHECKING:
import argparse


_laz = LazyImporter(
[
Expand All @@ -49,7 +56,7 @@ class UnsupportedPythonError(Exception):
pass


def stop_autoclose():
def stop_autoclose() -> None:
"""
Checks if it thinks windows will auto close the window after running

Expand All @@ -76,11 +83,11 @@ def stop_autoclose():
_laz.subprocess.run("pause", shell=True)


def _get_parser_class():
def _get_parser_class() -> type[argparse.ArgumentParser]:
# This class is deferred to avoid the argparse import
# if there are no arguments to parse

class FixedArgumentParser(_laz.argparse.ArgumentParser):
class FixedArgumentParser(_laz.argparse.ArgumentParser): # type: ignore
"""
The builtin argument parser uses shutil to figure out the terminal width
to display help info. This one replaces the function that calls help info
Expand Down Expand Up @@ -108,7 +115,7 @@ def _get_formatter(self):
return FixedArgumentParser


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

parser = FixedArgumentParser(
Expand Down Expand Up @@ -162,16 +169,16 @@ def get_parser():


def display_local_installs(
min_ver=None,
max_ver=None,
compatible=None,
):
min_ver: str | None = None,
max_ver: str | None = None,
compatible: str | None = None,
) -> None:
if min_ver:
min_ver = tuple(int(i) for i in min_ver.split("."))
min_ver_tuple = tuple(int(i) for i in min_ver.split("."))
if max_ver:
max_ver = tuple(int(i) for i in max_ver.split("."))
max_ver_tuple = tuple(int(i) for i in max_ver.split("."))
if compatible:
compatible = tuple(int(i) for i in compatible.split("."))
compatible_tuple = tuple(int(i) for i in compatible.split("."))

installs = list_python_installs()

Expand All @@ -185,15 +192,15 @@ def display_local_installs(

# First collect the strings
for install in installs:
if min_ver and install.version < min_ver:
if min_ver and install.version < min_ver_tuple:
continue
elif max_ver and install.version > max_ver:
elif max_ver and install.version > max_ver_tuple:
continue
elif compatible:
if install.version < compatible:
if install.version < compatible_tuple:
continue
version_parts = len(compatible) - 1
if install.version[:version_parts] > compatible[:-1]:
if install.version[:version_parts] > compatible_tuple[:-1]:
continue

version_str = install.version_str
Expand Down Expand Up @@ -253,14 +260,14 @@ def display_local_installs(


def display_remote_binaries(
min_ver,
max_ver,
compatible,
all_binaries,
system,
machine,
prerelease
):
min_ver: str,
max_ver: str,
compatible: str,
all_binaries: bool,
system: str,
machine: str,
prerelease: bool,
) -> None:
specs = []
if min_ver:
specs.append(f">={min_ver}")
Expand Down Expand Up @@ -294,7 +301,7 @@ def display_remote_binaries(
print("No Python releases found matching specification")


def main():
def main() -> int:
if sys.version_info < (3, 8):
v = sys.version_info
raise UnsupportedPythonError(
Expand Down Expand Up @@ -334,7 +341,9 @@ def main():
display_local_installs()

stop_autoclose()

return 0


if __name__ == "__main__":
main()
sys.exit(main())
4 changes: 2 additions & 2 deletions src/ducktools/pythonfinder/linux/pyenv_search.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ def get_pyenv_root() -> str | None:
def get_pyenv_pythons(
versions_folder: str | os.PathLike | None = None,
*,
finder: DetailFinder = None,
finder: DetailFinder | None = None,
) -> Iterator[PythonInstall]:
if versions_folder is None:
if pyenv_root := get_pyenv_root():
Expand All @@ -90,7 +90,7 @@ def get_pyenv_pythons(
finder = DetailFinder() if finder is None else finder

with finder:
for p in sorted(os.scandir(versions_folder), key=lambda x: x.path):
for p in sorted(os.scandir(str(versions_folder)), key=lambda x: x.path):
# Don't include folders that are venvs
venv_indicator = os.path.join(p.path, "pyvenv.cfg")
if os.path.exists(venv_indicator):
Expand Down
1 change: 1 addition & 0 deletions src/ducktools/pythonfinder/py.typed
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
partial
5 changes: 4 additions & 1 deletion src/ducktools/pythonfinder/pythonorg_search.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,8 @@
from packaging.version import Version

from ducktools.classbuilder.prefab import Prefab, attribute, get_attributes
from ducktools.pythonfinder.shared import version_str_to_tuple

from .shared import version_str_to_tuple


RELEASE_PAGE = "https://www.python.org/api/v2/downloads/release/"
Expand Down Expand Up @@ -341,6 +342,7 @@ def latest_binary_match(self, specifier: SpecifierSet, prereleases=False) -> Pyt
for download in self.matching_downloads(specifier, prereleases):
if download.url.endswith(tag):
return download
return None

def latest_python_download(self, prereleases=False) -> PythonDownload | None:
"""
Expand Down Expand Up @@ -369,3 +371,4 @@ def latest_python_download(self, prereleases=False) -> PythonDownload | None:
md5_sum=release_file.md5_sum,
)
return download
return None
27 changes: 15 additions & 12 deletions src/ducktools/pythonfinder/shared.py
Original file line number Diff line number Diff line change
Expand Up @@ -190,7 +190,7 @@ def __exit__(self, exc_type, exc_val, exc_tb):
self.save()

@property
def raw_cache(self):
def raw_cache(self) -> dict:
if self._raw_cache is None:
try:
with open(self.cache_path) as f:
Expand All @@ -199,14 +199,14 @@ def raw_cache(self):
self._raw_cache = {}
return self._raw_cache

def save(self):
def save(self) -> None:
os.makedirs(os.path.dirname(self.cache_path), exist_ok=True)
with open(self.cache_path, 'w') as f:
_laz.json.dump(self.raw_cache, f, indent=4)

self._dirty_cache = False

def clear_invalid_runtimes(self):
def clear_invalid_runtimes(self) -> None:
"""
Remove cache entries where the python.exe no longer exists
"""
Expand All @@ -218,7 +218,7 @@ def clear_invalid_runtimes(self):
if removed_runtimes:
self._dirty_cache = True

def clear_cache(self):
def clear_cache(self) -> None:
"""
Completely empty the cache
"""
Expand Down Expand Up @@ -357,6 +357,8 @@ def implementation_version(self) -> tuple[int, int, int, str, int] | None:
def implementation_version_str(self) -> str:
return version_tuple_to_str(self.implementation_version)

# Typing these classmethods would require an import
# This is not acceptable for performance reasons
@classmethod
def from_str(
cls,
Expand Down Expand Up @@ -387,13 +389,13 @@ def from_str(
@classmethod
def from_json(
cls,
version,
executable,
architecture,
implementation,
metadata,
paths=None,
managed_by=None,
version: str,
executable: str,
architecture: str,
implementation: str,
metadata: dict,
paths: dict | None = None,
managed_by: str | None = None,
):
if arch_ver := metadata.get(f"{implementation}_version"):
metadata[f"{implementation}_version"] = tuple(arch_ver)
Expand Down Expand Up @@ -431,6 +433,7 @@ def get_pip_version(self) -> str | None:
return pip_call.stdout


# Return type missing due to import requirements
def _python_exe_regex(basename: str = "python"):
if sys.platform == "win32":
return _laz.re.compile(rf"{basename}\d?\.?\d*\.exe")
Expand All @@ -443,7 +446,7 @@ def get_folder_pythons(
basenames: tuple[str, ...] = ("python", "pypy", "micropython"),
finder: DetailFinder | None = None,
managed_by: str | None = None,
):
) -> Iterator[PythonInstall]:
regexes = [_python_exe_regex(name) for name in basenames]

finder = DetailFinder() if finder is None else finder
Expand Down
14 changes: 10 additions & 4 deletions src/ducktools/pythonfinder/venv.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ def version_str(self) -> str:
return version_tuple_to_str(self.version)

@property
def parent_executable(self) -> str:
def parent_executable(self) -> str | None:
if self._parent_executable is None:
# Guess the parent executable file
parent_exe = None
Expand Down Expand Up @@ -122,7 +122,9 @@ def parent_executable(self) -> str:

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

def get_parent_install(
self,
Expand All @@ -135,6 +137,9 @@ def get_parent_install(
finder = DetailFinder() if finder is None else finder

if self.parent_exists:
# parent_exists forces this check
assert self.parent_executable is not None

exe = self.parent_executable

# Python installs may be cached, can skip querying exe.
Expand Down Expand Up @@ -253,6 +258,7 @@ def get_python_venvs(
:param search_parent_folders: Also search parent folders
:yield: PythonVEnv details.
"""
# This converts base_dir to a Path, but mypy doesn't know that
base_dir = _laz.Path.cwd() if base_dir is None else _laz.Path(base_dir)

cwd_pattern = pattern = f"*/{VENV_CONFIG_NAME}"
Expand All @@ -261,7 +267,7 @@ def get_python_venvs(
# Only search cwd recursively, parents are searched non-recursively
cwd_pattern = "*" + pattern

for conf in base_dir.glob(cwd_pattern):
for conf in base_dir.glob(cwd_pattern): # type: ignore
try:
env = PythonVEnv.from_cfg(conf)
except InvalidVEnvError:
Expand All @@ -270,7 +276,7 @@ def get_python_venvs(

if search_parent_folders:
# Search parent folders
for fld in base_dir.parents:
for fld in base_dir.parents: # type: ignore
try:
for conf in fld.glob(pattern):
try:
Expand Down
2 changes: 1 addition & 1 deletion src/ducktools/pythonfinder/win32/pyenv_search.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ def get_pyenv_pythons(
finder = DetailFinder() if finder is None else finder

with finder:
for p in os.scandir(versions_folder):
for p in os.scandir(str(versions_folder)):
# On windows, venv folders usually have the python.exe in \Scripts\
# while runtimes have it in the base folder so venvs shouldn't be disovered
# but exclude them early anyway
Expand Down
Loading