diff --git a/src/ducktools/pythonfinder/__init__.py b/src/ducktools/pythonfinder/__init__.py index 9001866..c2d2caf 100644 --- a/src/ducktools/pythonfinder/__init__.py +++ b/src/ducktools/pythonfinder/__init__.py @@ -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), diff --git a/src/ducktools/pythonfinder/__main__.py b/src/ducktools/pythonfinder/__main__.py index 93ee1ca..5d9d782 100644 --- a/src/ducktools/pythonfinder/__main__.py +++ b/src/ducktools/pythonfinder/__main__.py @@ -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( [ @@ -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 @@ -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 @@ -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( @@ -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() @@ -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 @@ -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}") @@ -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( @@ -334,7 +341,9 @@ def main(): display_local_installs() stop_autoclose() + + return 0 if __name__ == "__main__": - main() + sys.exit(main()) diff --git a/src/ducktools/pythonfinder/linux/pyenv_search.py b/src/ducktools/pythonfinder/linux/pyenv_search.py index ebe5374..026c464 100644 --- a/src/ducktools/pythonfinder/linux/pyenv_search.py +++ b/src/ducktools/pythonfinder/linux/pyenv_search.py @@ -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(): @@ -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): diff --git a/src/ducktools/pythonfinder/py.typed b/src/ducktools/pythonfinder/py.typed new file mode 100644 index 0000000..b648ac9 --- /dev/null +++ b/src/ducktools/pythonfinder/py.typed @@ -0,0 +1 @@ +partial diff --git a/src/ducktools/pythonfinder/pythonorg_search.py b/src/ducktools/pythonfinder/pythonorg_search.py index 19eb9ec..dae35e2 100644 --- a/src/ducktools/pythonfinder/pythonorg_search.py +++ b/src/ducktools/pythonfinder/pythonorg_search.py @@ -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/" @@ -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: """ @@ -369,3 +371,4 @@ def latest_python_download(self, prereleases=False) -> PythonDownload | None: md5_sum=release_file.md5_sum, ) return download + return None \ No newline at end of file diff --git a/src/ducktools/pythonfinder/shared.py b/src/ducktools/pythonfinder/shared.py index 168c94d..d33d712 100644 --- a/src/ducktools/pythonfinder/shared.py +++ b/src/ducktools/pythonfinder/shared.py @@ -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: @@ -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 """ @@ -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 """ @@ -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, @@ -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) @@ -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") @@ -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 diff --git a/src/ducktools/pythonfinder/venv.py b/src/ducktools/pythonfinder/venv.py index bdde2b2..dcc8827 100644 --- a/src/ducktools/pythonfinder/venv.py +++ b/src/ducktools/pythonfinder/venv.py @@ -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 @@ -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, @@ -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. @@ -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}" @@ -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: @@ -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: diff --git a/src/ducktools/pythonfinder/win32/pyenv_search.py b/src/ducktools/pythonfinder/win32/pyenv_search.py index 9805349..4ef91e0 100644 --- a/src/ducktools/pythonfinder/win32/pyenv_search.py +++ b/src/ducktools/pythonfinder/win32/pyenv_search.py @@ -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