From 5ef4c591fda9811b8cd58db8166d054409105859 Mon Sep 17 00:00:00 2001 From: Matt Davis Date: Sat, 29 Apr 2023 17:25:25 -0400 Subject: [PATCH 01/25] Overhaul pythonfinder to use pydantic and eliminate the WindowsFinder code paths; remove attrs library. --- setup.cfg | 4 +- src/pythonfinder/__init__.py | 15 +- src/pythonfinder/_vendor/pep514tools/LICENSE | 21 - .../_vendor/pep514tools/__init__.py | 11 - .../_vendor/pep514tools/__main__.py | 7 - .../_vendor/pep514tools/_registry.py | 198 ------ .../_vendor/pep514tools/environment.py | 124 ---- src/pythonfinder/_vendor/vendor.txt | 1 - src/pythonfinder/compat.py | 3 - src/pythonfinder/models/__init__.py | 3 - src/pythonfinder/models/mixins.py | 375 +++++----- src/pythonfinder/models/path.py | 649 ++++++------------ src/pythonfinder/models/python.py | 328 +++++---- src/pythonfinder/models/windows.py | 137 ---- src/pythonfinder/pythonfinder.py | 210 ++---- src/pythonfinder/utils.py | 137 ++-- 16 files changed, 693 insertions(+), 1530 deletions(-) delete mode 100644 src/pythonfinder/_vendor/pep514tools/LICENSE delete mode 100644 src/pythonfinder/_vendor/pep514tools/__init__.py delete mode 100644 src/pythonfinder/_vendor/pep514tools/__main__.py delete mode 100644 src/pythonfinder/_vendor/pep514tools/_registry.py delete mode 100644 src/pythonfinder/_vendor/pep514tools/environment.py delete mode 100644 src/pythonfinder/models/windows.py diff --git a/setup.cfg b/setup.cfg index 2f76001..c9fe1ed 100644 --- a/setup.cfg +++ b/setup.cfg @@ -37,9 +37,9 @@ package_data = LICENSE* README* install_requires = - attrs cached-property packaging>=22.0 + pydantic>=1.10.7 [options.packages.find] where = src @@ -113,7 +113,7 @@ skip = src/pythonfinder/_vendor line_length = 90 indent = ' ' multi_line_output = 3 -known_third_party = attr,cached_property,click,invoke,packaging,parver,pytest,requests,setuptools,six,towncrier +known_third_party = cached_property,click,invoke,packaging,parver,pydantic,pytest,requests,setuptools,six,towncrier known_first_party = pythonfinder,tests combine_as_imports=True include_trailing_comma = True diff --git a/src/pythonfinder/__init__.py b/src/pythonfinder/__init__.py index f081d3f..66bd699 100644 --- a/src/pythonfinder/__init__.py +++ b/src/pythonfinder/__init__.py @@ -1,19 +1,8 @@ -# Add NullHandler to "pythonfinder" logger, because Python2's default root -# logger has no handler and warnings like this would be reported: -# -# > No handlers could be found for logger "pythonfinder.models.pyenv" -from __future__ import annotations - -import logging - from .exceptions import InvalidPythonVersion -from .models import SystemPath, WindowsFinder +from .models import SystemPath from .pythonfinder import Finder __version__ = "1.3.3.dev0" -logger = logging.getLogger(__name__) -logger.addHandler(logging.NullHandler()) - -__all__ = ["Finder", "WindowsFinder", "SystemPath", "InvalidPythonVersion"] +__all__ = ["Finder", "SystemPath", "InvalidPythonVersion"] diff --git a/src/pythonfinder/_vendor/pep514tools/LICENSE b/src/pythonfinder/_vendor/pep514tools/LICENSE deleted file mode 100644 index c7ac395..0000000 --- a/src/pythonfinder/_vendor/pep514tools/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2016 Steve Dower - -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. diff --git a/src/pythonfinder/_vendor/pep514tools/__init__.py b/src/pythonfinder/_vendor/pep514tools/__init__.py deleted file mode 100644 index e7e7440..0000000 --- a/src/pythonfinder/_vendor/pep514tools/__init__.py +++ /dev/null @@ -1,11 +0,0 @@ -#------------------------------------------------------------------------- -# Copyright (c) Steve Dower -# All rights reserved. -# -# Distributed under the terms of the MIT License -#------------------------------------------------------------------------- - -__author__ = 'Steve Dower ' -__version__ = '0.1.0' - -from pythonfinder._vendor.pep514tools.environment import findall, find, findone diff --git a/src/pythonfinder/_vendor/pep514tools/__main__.py b/src/pythonfinder/_vendor/pep514tools/__main__.py deleted file mode 100644 index f554c16..0000000 --- a/src/pythonfinder/_vendor/pep514tools/__main__.py +++ /dev/null @@ -1,7 +0,0 @@ -#------------------------------------------------------------------------- -# Copyright (c) Steve Dower -# All rights reserved. -# -# Distributed under the terms of the MIT License -#------------------------------------------------------------------------- - diff --git a/src/pythonfinder/_vendor/pep514tools/_registry.py b/src/pythonfinder/_vendor/pep514tools/_registry.py deleted file mode 100644 index da72ecb..0000000 --- a/src/pythonfinder/_vendor/pep514tools/_registry.py +++ /dev/null @@ -1,198 +0,0 @@ -#------------------------------------------------------------------------- -# 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'] - -from itertools import count -import re -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 KeyError: - pass - except Exception: - raise AttributeError(attr) - 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) diff --git a/src/pythonfinder/_vendor/pep514tools/environment.py b/src/pythonfinder/_vendor/pep514tools/environment.py deleted file mode 100644 index f4104a4..0000000 --- a/src/pythonfinder/_vendor/pep514tools/environment.py +++ /dev/null @@ -1,124 +0,0 @@ -#------------------------------------------------------------------------- -# Copyright (c) Steve Dower -# All rights reserved. -# -# Distributed under the terms of the MIT License -#------------------------------------------------------------------------- - -__all__ = ['Environment', 'findall', 'find', 'findone'] - -from itertools import count -from pythonfinder._vendor.pep514tools._registry import open_source, REGISTRY_SOURCE_LM, REGISTRY_SOURCE_LM_WOW6432, REGISTRY_SOURCE_CU -import re -import sys - -# 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', '3.5', '3.6', '3.7', - '3.8', '3.9' -} - -_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] diff --git a/src/pythonfinder/_vendor/vendor.txt b/src/pythonfinder/_vendor/vendor.txt index e635a2a..e69de29 100644 --- a/src/pythonfinder/_vendor/vendor.txt +++ b/src/pythonfinder/_vendor/vendor.txt @@ -1 +0,0 @@ -git+https://github.com/zooba/pep514tools.git@master#egg=pep514tools diff --git a/src/pythonfinder/compat.py b/src/pythonfinder/compat.py index dc64889..a9d21ba 100644 --- a/src/pythonfinder/compat.py +++ b/src/pythonfinder/compat.py @@ -1,6 +1,3 @@ -from __future__ import annotations - - def getpreferredencoding(): import locale diff --git a/src/pythonfinder/models/__init__.py b/src/pythonfinder/models/__init__.py index 5a70e12..bc92f89 100644 --- a/src/pythonfinder/models/__init__.py +++ b/src/pythonfinder/models/__init__.py @@ -1,5 +1,2 @@ -from __future__ import annotations - from .path import SystemPath from .python import PythonVersion -from .windows import WindowsFinder diff --git a/src/pythonfinder/models/mixins.py b/src/pythonfinder/models/mixins.py index 34015bb..61a2474 100644 --- a/src/pythonfinder/models/mixins.py +++ b/src/pythonfinder/models/mixins.py @@ -1,11 +1,18 @@ -from __future__ import annotations - -import abc import operator +import os from collections import defaultdict -from typing import TYPE_CHECKING, Any, DefaultDict, Generator, Iterator, List, TypeVar +from pathlib import Path +from typing import ( + Any, + Dict, + Generator, + Iterator, + List, + Optional, + Union, +) -import attr +from pydantic import BaseModel, Field, validator from ..compat import fs_str from ..exceptions import InvalidPythonVersion @@ -14,47 +21,59 @@ expand_paths, looks_like_python, path_is_known_executable, + ensure_path, + filter_pythons, + is_in_path, + normalize_path, ) -if TYPE_CHECKING: - from pathlib import Path - - from .path import PathEntry - from .python import PythonVersion - - BaseFinderType = TypeVar("BaseFinderType") +from ..environment import ( + SHIM_PATHS, + get_shim_paths, +) -@attr.s(slots=True) -class BasePath: - path: Path = attr.ib(default=None) - _children: dict[str, PathEntry] = attr.ib(default=attr.Factory(dict), order=False) - only_python: bool = attr.ib(default=False) - name = attr.ib(type=str) - _py_version: PythonVersion | None = attr.ib(default=None, order=False) - _pythons: DefaultDict[str, PathEntry] = attr.ib( - default=attr.Factory(defaultdict), order=False - ) - _is_dir: bool | None = attr.ib(default=None, order=False) - _is_executable: bool | None = attr.ib(default=None, order=False) - _is_python: bool | None = attr.ib(default=None, order=False) +class PathEntry(BaseModel): + is_root: bool = Field(default=False, order=False) + name: Optional[str] = None + path: Optional[Path] = None + children_ref: Optional[Any] = Field(default_factory=lambda: dict()) + only_python: Optional[bool] = False + py_version_ref: Optional[Any] = None + pythons_ref: Optional[Dict[Any, Any]] = defaultdict(lambda: None) + is_dir_ref: Optional[bool] = None + is_executable_ref: Optional[bool] = None + is_python_ref: Optional[bool] = None + + class Config: + validate_assignment = True + arbitrary_types_allowed = True + allow_mutation = True + include_private_attributes = True + + @validator('children', pre=True, always=True, check_fields=False) + def set_children(cls, v, values, **kwargs): + path = values.get('path') + if path: + values['name'] = path.name + return v or cls()._gen_children() def __str__(self) -> str: - return fs_str(f"{self.path.as_posix()}") + return fs_str("{0}".format(self.path.as_posix())) - def __lt__(self, other: BasePath) -> bool: + def __lt__(self, other) -> bool: return self.path.as_posix() < other.path.as_posix() - def __lte__(self, other: BasePath) -> bool: + def __lte__(self, other) -> bool: return self.path.as_posix() <= other.path.as_posix() - def __gt__(self, other: BasePath) -> bool: + def __gt__(self, other) -> bool: return self.path.as_posix() > other.path.as_posix() - def __gte__(self, other: BasePath) -> bool: + def __gte__(self, other) -> bool: return self.path.as_posix() >= other.path.as_posix() - def which(self, name: str) -> PathEntry | None: + def which(self, name) -> Optional["PathEntry"]: """Search in this path for an executable. :param executable: The name of an executable to search for. @@ -63,7 +82,8 @@ def which(self, name: str) -> PathEntry | None: """ valid_names = [name] + [ - f"{name}.{ext}".lower() if ext else f"{name}".lower() for ext in KNOWN_EXTS + "{0}.{1}".format(name, ext).lower() if ext else "{0}".format(name).lower() + for ext in KNOWN_EXTS ] children = self.children found = None @@ -77,109 +97,79 @@ def which(self, name: str) -> PathEntry | None: None, ) return found - - def __del__(self): - for key in ["_is_dir", "_is_python", "_is_executable", "_py_version"]: - if getattr(self, key, None): - try: - delattr(self, key) - except Exception: - print(f"failed deleting key: {key}") - self._children = {} - for key in list(self._pythons.keys()): - del self._pythons[key] - self._pythons = None - self._py_version = None - self.path = None - - @property - def children(self) -> dict[str, PathEntry]: - if not self.is_dir: - return {} - return self._children - @property - def as_python(self) -> PythonVersion: + def as_python(self) -> "PythonVersion": py_version = None - if self.py_version: - return self.py_version + if self.py_version_ref: + return self.py_version_ref if not self.is_dir and self.is_python: - try: - from .python import PythonVersion + from .python import PythonVersion + try: py_version = PythonVersion.from_path( # type: ignore path=self, name=self.name ) except (ValueError, InvalidPythonVersion): pass - if py_version is None: - pass - self.py_version = py_version - return py_version # type: ignore - - @name.default - def get_name(self) -> str | None: - if self.path: - return self.path.name - return None + self.py_version_ref = py_version + return self.py_version_ref @property def is_dir(self) -> bool: - if self._is_dir is None: - if not self.path: - ret_val = False + if self.is_dir_ref is None: try: ret_val = self.path.is_dir() except OSError: ret_val = False - self._is_dir = ret_val - return self._is_dir + self.is_dir_ref = ret_val + return self.is_dir_ref @is_dir.setter - def is_dir(self, val: bool) -> None: - self._is_dir = val + def is_dir(self, val) -> None: + self.is_dir_ref = val @is_dir.deleter def is_dir(self) -> None: - self._is_dir = None + self.is_dir_ref = None @property def is_executable(self) -> bool: - if self._is_executable is None: + if self.is_executable_ref is None: if not self.path: - self._is_executable = False + self.is_executable_ref = False else: - self._is_executable = path_is_known_executable(self.path) - return self._is_executable + self.is_executable_ref = path_is_known_executable(self.path) + return self.is_executable_ref @is_executable.setter - def is_executable(self, val: bool) -> None: - self._is_executable = val + def is_executable(self, val) -> None: + self.is_executable_ref = val @is_executable.deleter def is_executable(self) -> None: - self._is_executable = None + self.is_executable_ref = None @property def is_python(self) -> bool: - if self._is_python is None: + if self.is_python_ref is None: if not self.path: - self._is_python = False + self.is_python_ref = False else: - self._is_python = self.is_executable and ( + self.is_python_ref = self.is_executable and ( looks_like_python(self.path.name) ) - return self._is_python + return self.is_python_ref @is_python.setter - def is_python(self, val: bool) -> None: - self._is_python = val + def is_python(self, val) -> None: + self.is_python_ref = val @is_python.deleter def is_python(self) -> None: - self._is_python = None + self.is_python_ref = None - def get_py_version(self) -> PythonVersion | None: + def get_py_version(self): + # type: () -> Optional[PythonVersion] from ..environment import IGNORE_UNSUPPORTED if self.is_dir: @@ -201,47 +191,39 @@ def get_py_version(self) -> PythonVersion | None: return None @property - def py_version(self) -> PythonVersion | None: - if not self._py_version: + def py_version(self) -> Optional["PythonVersion"]: + if not self.py_version_ref: py_version = self.get_py_version() - self._py_version = py_version + self.py_version_ref = py_version else: - py_version = self._py_version + py_version = self.py_version_ref return py_version - @py_version.setter - def py_version(self, val: PythonVersion | None) -> None: - self._py_version = val - - @py_version.deleter - def py_version(self) -> None: - self._py_version = None - def _iter_pythons(self) -> Iterator: if self.is_dir: for entry in self.children.values(): if entry is None: continue elif entry.is_dir: - yield from entry._iter_pythons() + for python in entry._iter_pythons(): + yield python elif entry.is_python and entry.as_python is not None: yield entry elif self.is_python and self.as_python is not None: yield self # type: ignore @property - def pythons(self) -> DefaultDict[str | Path, PathEntry]: - if not self._pythons: - from .path import PathEntry - - self._pythons = defaultdict(PathEntry) + def pythons(self) -> Dict[Union[str, Path], "PathEntry"]: + if not self.pythons_ref: + self.pythons_ref = defaultdict(PathEntry) for python in self._iter_pythons(): python_path = python.path.as_posix() # type: ignore - self._pythons[python_path] = python - return self._pythons + self.pythons_ref[python_path] = python + return self.pythons_ref def __iter__(self) -> Iterator: - yield from self.children.values() + for entry in self.children.values(): + yield entry def __next__(self) -> Generator: return next(iter(self)) @@ -251,14 +233,14 @@ def next(self) -> Generator: def find_all_python_versions( self, - major: str | int | None = None, - minor: int | None = None, - patch: int | None = None, - pre: bool | None = None, - dev: bool | None = None, - arch: str | None = None, - name: str | None = None, - ) -> list[PathEntry]: + major=None, # type: Optional[Union[str, int]] + minor=None, # type: Optional[int] + patch=None, # type: Optional[int] + pre=None, # type: Optional[bool] + dev=None, # type: Optional[bool] + arch=None, # type: Optional[str] + name=None, # type: Optional[str] + ) -> List["PathEntry"]: """Search for a specific python version on the path. Return all copies :param major: Major python version to search for. @@ -274,27 +256,32 @@ def find_all_python_versions( """ call_method = "find_all_python_versions" if self.is_dir else "find_python_version" - sub_finder = operator.methodcaller( - call_method, major, minor, patch, pre, dev, arch, name - ) + + def sub_finder(obj): + return getattr(obj, call_method)(major, minor, patch, pre, dev, arch, name) + if not self.is_dir: return sub_finder(self) + unnested = [sub_finder(path) for path in expand_paths(self)] - version_sort = operator.attrgetter("as_python.version_sort") + + def version_sort(path_entry): + return path_entry.as_python.version_sort + unnested = [p for p in unnested if p is not None and p.as_python is not None] paths = sorted(unnested, key=version_sort, reverse=True) return list(paths) def find_python_version( self, - major: str | int | None = None, - minor: int | None = None, - patch: int | None = None, - pre: bool | None = None, - dev: bool | None = None, - arch: str | None = None, - name: str | None = None, - ) -> PathEntry | None: + major=None, # type: Optional[Union[str, int]] + minor=None, # type: Optional[int] + patch=None, # type: Optional[int] + pre=None, # type: Optional[bool] + dev=None, # type: Optional[bool] + arch=None, # type: Optional[str] + name=None, # type: Optional[str] + ) -> Optional["PathEntry"]: """Search or self for the specified Python version and return the first match. :param major: Major version number. @@ -307,10 +294,9 @@ def find_python_version( :param str name: The name of a python version, e.g. ``anaconda3-5.3.0`` :returns: A :class:`~pythonfinder.models.PathEntry` instance matching the version requested. """ + def version_matcher(py_version): + return py_version.matches(major, minor, patch, pre, dev, arch, python_name=name) - version_matcher = operator.methodcaller( - "matches", major, minor, patch, pre, dev, arch, python_name=name - ) if not self.is_dir: if self.is_python and self.as_python and version_matcher(self.py_version): return self # type: ignore @@ -324,38 +310,103 @@ def find_python_version( and version_matcher(entry.py_version) ) ] - results = sorted(matching_pythons, key=operator.itemgetter(1, 0), reverse=True) + results = sorted(matching_pythons, key=lambda r: (r[1], r[0]), reverse=True) return next(iter(r[0] for r in results if r is not None), None) + def _filter_children(self) -> Iterator[Path]: + if not os.access(str(self.path), os.R_OK): + return iter([]) + if self.only_python: + children = filter_pythons(self.path) + else: + children = self.path.iterdir() + return children + + def _gen_children(self) -> Iterator: + shim_paths = get_shim_paths() + pass_name = self.name != self.path.name + pass_args = {"is_root": False, "only_python": self.only_python} + if pass_name: + if self.name is not None and isinstance(self.name, str): + pass_args["name"] = self.name # type: ignore + elif self.path is not None and isinstance(self.path.name, str): + pass_args["name"] = self.path.name # type: ignore -class BaseFinder(metaclass=abc.ABCMeta): - def __init__(self): - #: Maps executable paths to PathEntries - from .path import PathEntry - - self._pythons: DefaultDict[str, PathEntry] = defaultdict(PathEntry) - self._versions: dict[tuple, PathEntry] = defaultdict(PathEntry) - - def get_versions(self) -> DefaultDict[tuple, PathEntry]: - """Return the available versions from the finder""" - raise NotImplementedError - - @classmethod - def create(cls, *args: Any, **kwargs: Any) -> BaseFinderType: - raise NotImplementedError + if not self.is_dir: + yield (self.path.as_posix(), self) + elif self.is_root: + for child in self._filter_children(): + if any(is_in_path(str(child), shim) for shim in shim_paths): + continue + if self.only_python: + try: + entry = PathEntry.create(path=child, **pass_args) # type: ignore + except (InvalidPythonVersion, ValueError): + continue + else: + try: + entry = PathEntry.create(path=child, **pass_args) # type: ignore + except (InvalidPythonVersion, ValueError): + continue + yield (child.as_posix(), entry) + return @property - def version_paths(self) -> Any: - return self._versions.values() + def children(self): + # type: () -> Dict[str, PathEntry] + children = getattr(self, "children_ref", {}) # type: Dict[str, PathEntry] + if not children: + for child_key, child_val in self._gen_children(): + children[child_key] = child_val + self.children_ref = children + return self.children_ref - @property - def expanded_paths(self) -> Any: - return (p.paths.values() for p in self.version_paths) + @classmethod + def create( + cls, + path: Union[str, Path], + is_root: bool = False, + only_python: bool = False, + pythons: Optional[Dict[str, "PythonVersion"]] = None, + name: Optional[str] = None, + ) -> "PathEntry": + """Helper method for creating new :class:`pythonfinder.models.PathEntry` instances. + + :param str path: Path to the specified location. + :param bool is_root: Whether this is a root from the environment PATH variable, defaults to False + :param bool only_python: Whether to search only for python executables, defaults to False + :param dict pythons: A dictionary of existing python objects (usually from a finder), defaults to None + :param str name: Name of the python version, e.g. ``anaconda3-5.3.0`` + :return: A new instance of the class. + :rtype: :class:`pythonfinder.models.PathEntry` + """ - @property - def pythons(self) -> DefaultDict[str, PathEntry]: - return self._pythons + target = ensure_path(path) + guessed_name = False + if not name: + guessed_name = True + name = target.name + creation_args = { + "path": target, + "is_root": is_root, + "only_python": only_python, + "name": name, + } + if pythons: + creation_args["pythons"] = pythons + _new = cls(**creation_args) + if pythons and only_python: + children = {} + child_creation_args = {"is_root": False, "only_python": only_python} + if not guessed_name: + child_creation_args["name"] = _new.name # type: ignore + for pth, python in pythons.items(): + if any(shim in normalize_path(str(pth)) for shim in SHIM_PATHS): + continue + pth = ensure_path(pth) + children[pth.as_posix()] = PathEntry( # type: ignore + py_version=python, path=pth, **child_creation_args + ) + _new.children_ref = children + return _new - @pythons.setter - def pythons(self, value: DefaultDict[str, PathEntry]) -> None: - self._pythons = value diff --git a/src/pythonfinder/models/path.py b/src/pythonfinder/models/path.py index 9fde305..527269e 100644 --- a/src/pythonfinder/models/path.py +++ b/src/pythonfinder/models/path.py @@ -1,27 +1,15 @@ -from __future__ import annotations - import errno import operator import os -import sys -from collections import defaultdict -from itertools import chain from pathlib import Path -from typing import ( - TYPE_CHECKING, - Any, - Callable, - DefaultDict, - Generator, - Iterator, - List, - TypeVar, - Union, -) +import sys +from collections import defaultdict, ChainMap +from typing import Any, Dict, List, Generator, Iterator, Optional, Tuple, Union -import attr from cached_property import cached_property +from pydantic import Field, validator, root_validator +from .common import FinderBaseModel from ..compat import fs_str from ..environment import ( ASDF_DATA_DIR, @@ -29,29 +17,18 @@ PYENV_INSTALLED, PYENV_ROOT, SHIM_PATHS, - get_shim_paths, ) -from ..exceptions import InvalidPythonVersion from ..utils import ( dedup, ensure_path, - filter_pythons, is_in_path, normalize_path, - optional_instance_of, parse_asdf_version_order, parse_pyenv_version_order, split_version_and_name, ) -from .mixins import BaseFinder, BasePath - -if TYPE_CHECKING: - from .python import PythonFinder, PythonVersion - from .windows import WindowsFinder - - FinderType = TypeVar("FinderType", BaseFinder, PythonFinder, WindowsFinder) - ChildType = Union[PythonFinder, "PathEntry"] - PathType = Union[PythonFinder, "PathEntry"] +from .mixins import PathEntry +from .python import PythonFinder, PythonVersion def exists_and_is_accessible(path): @@ -64,79 +41,68 @@ def exists_and_is_accessible(path): raise -@attr.s -class SystemPath: - global_search = attr.ib(default=True) - paths: DefaultDict[str, PythonFinder | PathEntry] = attr.ib( - default=attr.Factory(defaultdict) - ) - _executables: list[PathEntry] = attr.ib(default=attr.Factory(list)) - _python_executables: dict[str, PathEntry] = attr.ib(default=attr.Factory(dict)) - path_order: list[str] = attr.ib(default=attr.Factory(list)) - python_version_dict: DefaultDict[tuple, list[PythonVersion]] = attr.ib() - only_python = attr.ib(default=False, type=bool) - pyenv_finder: PythonFinder | None = attr.ib(default=None) - asdf_finder: PythonFinder | None = attr.ib(default=None) - windows_finder: WindowsFinder | None = attr.ib(default=None) - system = attr.ib(default=False, type=bool) - _version_dict: DefaultDict[tuple, list[PathEntry]] = attr.ib( - default=attr.Factory(defaultdict) - ) - ignore_unsupported = attr.ib(default=False, type=bool) - - __finders: dict[str, WindowsFinder | PythonFinder] = attr.ib( - default=attr.Factory(dict) - ) - - def _register_finder( - self, finder_name: str, finder: WindowsFinder | PythonFinder - ) -> SystemPath: - if finder_name not in self.__finders: - self.__finders[finder_name] = finder - return self +class SystemPath(FinderBaseModel): + global_search: bool = True + paths: Dict[str, Union[PythonFinder, PathEntry]] = Field(default_factory=lambda: defaultdict(PathEntry)) + executables: List[PathEntry] = Field(default_factory=lambda: list()) + python_executables_tracking: Dict[str, PathEntry] = Field(default_factory=lambda: dict()) + path_order: List[str] = Field(default_factory=lambda: list()) + python_version_dict: Dict[Tuple, Any] = Field(default_factory=lambda: defaultdict(list)) + version_dict_tracking: Dict[Tuple, List[PathEntry]] = Field(default_factory=lambda: defaultdict(list)) + only_python: bool = False + pyenv_finder: Optional[PythonFinder] = None + asdf_finder: Optional[PythonFinder] = None + system: bool = False + ignore_unsupported: bool = False + finders_dict: Dict[str, PythonFinder] = Field(default_factory=lambda: dict()) + + class Config: + validate_assignment = True + arbitrary_types_allowed = True + allow_mutation = True + include_private_attributes = True + keep_untouched = (cached_property,) + + def __init__(self, **data): + super().__init__(**data) + python_executables = {} + for child in self.paths.values(): + if child.pythons: + python_executables.update(dict(child.pythons)) + for _, finder in self.finders_dict.items(): + if finder.pythons: + python_executables.update(dict(finder.pythons)) + self.python_executables_tracking = python_executables + + @root_validator(pre=True) + def set_defaults(cls, values): + values['python_version_dict'] = defaultdict(list) + values['pyenv_finder'] = None + values['asdf_finder'] = None + values['path_order'] = [] + values['_finders'] = {} + values['paths'] = defaultdict(PathEntry) + return values - def clear_caches(self): - for key in ["executables", "python_executables", "version_dict", "path_entries"]: - if key in self.__dict__: - del self.__dict__[key] - for finder in list(self.__finders.keys()): - del self.__finders[finder] - self.__finders = {} - return attr.evolve( - self, - executables=[], - python_executables={}, - python_version_dict=defaultdict(list), - version_dict=defaultdict(list), - pyenv_finder=None, - windows_finder=None, - asdf_finder=None, - path_order=[], - paths=defaultdict(PathEntry), - ) + @root_validator(pre=True) + def set_executables(cls, values): + paths = values.get('paths') + if paths: + values['executables'] = [ + p + for p in ChainMap(*(child.children_ref.values() for child in paths.values())) + if p.is_executable + ] + return values - def __del__(self): - for key in ["executables", "python_executables", "version_dict", "path_entries"]: - try: - del self.__dict__[key] - except KeyError: - pass - for finder in list(self.__finders.keys()): - del self.__finders[finder] - self.__finders = {} - self._python_executables = {} - self._executables = [] - self.python_version_dict = defaultdict(list) - self._version_dict = defaultdict(list) - self.path_order = [] - self.pyenv_finder = None - self.asdf_finder = None - self.paths = defaultdict(PathEntry) - self.__finders = {} + def _register_finder(self, finder_name, finder): + if finder_name not in self.finders_dict: + self.finders_dict[finder_name] = finder + return self @property - def finders(self) -> list[str]: - return [k for k in self.__finders.keys()] + def finders(self) -> List[str]: + return [k for k in self.finders_dict.keys()] @staticmethod def check_for_pyenv(): @@ -146,21 +112,19 @@ def check_for_pyenv(): def check_for_asdf(): return ASDF_INSTALLED or os.path.exists(normalize_path(ASDF_DATA_DIR)) - @python_version_dict.default - def create_python_version_dict(self) -> DefaultDict[tuple, list[PythonVersion]]: - return defaultdict(list) - - @cached_property - def executables(self) -> list[PathEntry]: + @property + def executables(self): + # type: () -> List[PathEntry] self.executables = [ p - for p in chain(*(child.children.values() for child in self.paths.values())) + for p in chain(*(child.children_ref.values() for child in self.paths.values())) if p.is_executable ] return self.executables @cached_property - def python_executables(self) -> dict[str, PathEntry]: + def python_executables(self): + # type: () -> Dict[str, PathEntry] python_executables = {} for child in self.paths.values(): if child.pythons: @@ -168,36 +132,31 @@ def python_executables(self) -> dict[str, PathEntry]: for _, finder in self.__finders.items(): if finder.pythons: python_executables.update(dict(finder.pythons)) - self._python_executables = python_executables - return self._python_executables + self.python_executables_tracking = python_executables + return self.python_executables_tracking @cached_property - def version_dict(self) -> DefaultDict[tuple, list[PathEntry]]: - self._version_dict: DefaultDict[tuple, list[PathEntry]] = defaultdict(list) - for finder_name, finder in self.__finders.items(): + def version_dict(self): + # type: () -> DefaultDict[Tuple, List[PathEntry]] + self.version_dict_tracking = defaultdict( + list + ) # type: DefaultDict[Tuple, List[PathEntry]] + for finder_name, finder in self.finders_dict.items(): for version, entry in finder.versions.items(): - if finder_name == "windows": - if entry not in self._version_dict[version]: - self._version_dict[version].append(entry) - continue - if entry not in self._version_dict[version] and entry.is_python: - self._version_dict[version].append(entry) + if entry not in self.version_dict_tracking[version] and entry.is_python: + self.version_dict_tracking[version].append(entry) for _, entry in self.python_executables.items(): - version: PythonVersion = entry.as_python + version = entry.as_python # type: PythonVersion if not version: continue if not isinstance(version, tuple): version = version.version_tuple - if version and entry not in self._version_dict[version]: - self._version_dict[version].append(entry) - return self._version_dict + if version and entry not in self.version_dict_tracking[version]: + self.version_dict_tracking[version].append(entry) + return self.version_dict_tracking - def _run_setup(self) -> SystemPath: - if not self.__class__ == SystemPath: - return self - new_instance = self - path_order = new_instance.path_order[:] - path_entries = self.paths.copy() + def _run_setup(self) -> "SystemPath": + path_order = self.path_order[:] if self.global_search and "PATH" in os.environ: path_order = path_order + os.environ["PATH"].split(os.pathsep) path_order = list(dedup(path_order)) @@ -209,7 +168,7 @@ def _run_setup(self) -> SystemPath: for shim in SHIM_PATHS ) ] - path_entries.update( + self.paths.update( { p.as_posix(): PathEntry.create( path=p.absolute(), is_root=True, only_python=self.only_python @@ -218,59 +177,50 @@ def _run_setup(self) -> SystemPath: if exists_and_is_accessible(p) } ) - new_instance = attr.evolve( - new_instance, - path_order=[ - p.as_posix() for p in path_instances if exists_and_is_accessible(p) - ], - paths=path_entries, - ) - if os.name == "nt" and "windows" not in self.finders: - new_instance = new_instance._setup_windows() + self.path_order = [ + p.as_posix() for p in path_instances if exists_and_is_accessible(p) + ] #: slice in pyenv if self.check_for_pyenv() and "pyenv" not in self.finders: - new_instance = new_instance._setup_pyenv() + self._setup_pyenv() #: slice in asdf if self.check_for_asdf() and "asdf" not in self.finders: - new_instance = new_instance._setup_asdf() + self._setup_asdf() venv = os.environ.get("VIRTUAL_ENV") if os.name == "nt": bin_dir = "Scripts" else: bin_dir = "bin" - if venv and (new_instance.system or new_instance.global_search): + if venv and (self.system or self.global_search): p = ensure_path(venv) - path_order = [(p / bin_dir).as_posix()] + new_instance.path_order - new_instance = attr.evolve(new_instance, path_order=path_order) - paths = new_instance.paths.copy() - paths[p] = new_instance.get_path(p.joinpath(bin_dir)) - new_instance = attr.evolve(new_instance, paths=paths) - if new_instance.system: + path_order = [(p / bin_dir).as_posix()] + self.path_order + self.path_order = path_order + self.paths[p] = self.get_path(p.joinpath(bin_dir)) + if self.system: syspath = Path(sys.executable) syspath_bin = syspath.parent if syspath_bin.name != bin_dir and syspath_bin.joinpath(bin_dir).exists(): syspath_bin = syspath_bin / bin_dir - path_order = [syspath_bin.as_posix()] + new_instance.path_order - paths = new_instance.paths.copy() - paths[syspath_bin] = PathEntry.create( + path_order = [syspath_bin.as_posix()] + self.path_order + self.paths[syspath_bin] = PathEntry.create( path=syspath_bin, is_root=True, only_python=False ) - new_instance = attr.evolve(new_instance, path_order=path_order, paths=paths) - return new_instance + self.path_order = path_order + return self - def _get_last_instance(self, path: str) -> int: + def _get_last_instance(self, path) -> int: reversed_paths = reversed(self.path_order) paths = [normalize_path(p) for p in reversed_paths] normalized_target = normalize_path(path) last_instance = next(iter(p for p in paths if normalized_target in p), None) if last_instance is None: - raise ValueError(f"No instance found on path for target: {path!s}") + raise ValueError("No instance found on path for target: {0!s}".format(path)) path_index = self.path_order.index(last_instance) return path_index - def _slice_in_paths(self, start_idx: int, paths: list[Path]) -> SystemPath: - before_path: list[str] = [] - after_path: list[str] = [] + def _slice_in_paths(self, start_idx, paths) -> "SystemPath": + before_path = [] + after_path = [] if start_idx == 0: after_path = self.path_order[:] elif start_idx == -1: @@ -279,29 +229,27 @@ def _slice_in_paths(self, start_idx: int, paths: list[Path]) -> SystemPath: before_path = self.path_order[: start_idx + 1] after_path = self.path_order[start_idx + 2 :] path_order = before_path + [p.as_posix() for p in paths] + after_path - if path_order == self.path_order: - return self - return attr.evolve(self, path_order=path_order) + self.path_order = path_order + return self - def _remove_path(self, path: str) -> SystemPath: + def _remove_path(self, path) -> "SystemPath": path_copy = [p for p in reversed(self.path_order[:])] new_order = [] target = normalize_path(path) path_map = {normalize_path(pth): pth for pth in self.paths.keys()} - new_paths = self.paths.copy() if target in path_map: - del new_paths[path_map[target]] + del self.paths[path_map[target]] for current_path in path_copy: normalized = normalize_path(current_path) if normalized != target: new_order.append(normalized) new_order = [ensure_path(p).as_posix() for p in reversed(new_order)] - return attr.evolve(self, path_order=new_order, paths=new_paths) + self.path_order = new_order + return self - def _setup_asdf(self) -> SystemPath: + def _setup_asdf(self) -> "SystemPath": if "asdf" in self.finders and self.asdf_finder is not None: return self - from .python import PythonFinder os_path = os.environ["PATH"].split(os.pathsep) asdf_finder = PythonFinder.create( @@ -321,47 +269,17 @@ def _setup_asdf(self) -> SystemPath: return self # * These are the root paths for the finder _ = [p for p in asdf_finder.roots] - new_instance = self._slice_in_paths(asdf_index, [asdf_finder.root]) - paths = self.paths.copy() - paths[asdf_finder.root] = asdf_finder - paths.update(asdf_finder.roots) - return ( - attr.evolve(new_instance, paths=paths, asdf_finder=asdf_finder) - ._remove_path(normalize_path(os.path.join(ASDF_DATA_DIR, "shims"))) - ._register_finder("asdf", asdf_finder) - ) - - def reload_finder(self, finder_name: str) -> SystemPath: - if finder_name is None: - raise TypeError("Must pass a string as the name of the target finder") - finder_attr = f"{finder_name}_finder" - setup_attr = f"_setup_{finder_name}" - try: - current_finder: Any = getattr(self, finder_attr) - except AttributeError: - raise ValueError("Must pass a valid finder to reload.") - try: - setup_fn = getattr(self, setup_attr) - except AttributeError: - raise ValueError("Finder has no valid setup function: %s" % finder_name) - if current_finder is None: - # TODO: This is called 'reload', should we load a new finder for the first - # time here? lets just skip that for now to avoid unallowed finders - pass - if (finder_name == "pyenv" and not PYENV_INSTALLED) or ( - finder_name == "asdf" and not ASDF_INSTALLED - ): - # Don't allow loading of finders that aren't explicitly 'installed' as it were - return self - setattr(self, finder_attr, None) - if finder_name in self.__finders: - del self.__finders[finder_name] - return setup_fn() + self._slice_in_paths(asdf_index, [asdf_finder.root]) + self.paths[asdf_finder.root] = asdf_finder + self.paths.update(asdf_finder.roots) + self.asdf_finder = asdf_finder + self._remove_path(normalize_path(os.path.join(ASDF_DATA_DIR, "shims"))) + self._register_finder("asdf", asdf_finder) + return self - def _setup_pyenv(self) -> SystemPath: + def _setup_pyenv(self) -> "SystemPath": if "pyenv" in self.finders and self.pyenv_finder is not None: return self - from .python import PythonFinder os_path = os.environ["PATH"].split(os.pathsep) @@ -382,35 +300,15 @@ def _setup_pyenv(self) -> SystemPath: return self # * These are the root paths for the finder _ = [p for p in pyenv_finder.roots] - new_instance = self._slice_in_paths(pyenv_index, [pyenv_finder.root]) - paths = new_instance.paths.copy() - paths[pyenv_finder.root] = pyenv_finder - paths.update(pyenv_finder.roots) - return ( - attr.evolve(new_instance, paths=paths, pyenv_finder=pyenv_finder) - ._remove_path(os.path.join(PYENV_ROOT, "shims")) - ._register_finder("pyenv", pyenv_finder) - ) + self._slice_in_paths(pyenv_index, [pyenv_finder.root]) + self.paths[pyenv_finder.root] = pyenv_finder + self.paths.update(pyenv_finder.roots) + self.pyenv_finder = pyenv_finder + self._remove_path(os.path.join(PYENV_ROOT, "shims")) + self._register_finder("pyenv", pyenv_finder) + return self - def _setup_windows(self) -> SystemPath: - if "windows" in self.finders and self.windows_finder is not None: - return self - from .windows import WindowsFinder - - windows_finder = WindowsFinder.create() - root_paths = (p for p in windows_finder.paths if p.is_root) - path_addition = [p.path.as_posix() for p in root_paths] - new_path_order = self.path_order[:] + path_addition - new_paths = self.paths.copy() - new_paths.update({p.path: p for p in root_paths}) - return attr.evolve( - self, - windows_finder=windows_finder, - path_order=new_path_order, - paths=new_paths, - )._register_finder("windows", windows_finder) - - def get_path(self, path: str | Path) -> PathType: + def get_path(self, path) -> Union[PythonFinder, PathEntry]: if path is None: raise TypeError("A path must be provided in order to generate a path entry.") path = ensure_path(path) @@ -423,10 +321,10 @@ def get_path(self, path: str | Path) -> PathType: ) self.paths[path.as_posix()] = _path if not _path: - raise ValueError(f"Path not found or generated: {path!r}") + raise ValueError("Path not found or generated: {0!r}".format(path)) return _path - def _get_paths(self) -> Generator[PathType | WindowsFinder, None, None]: + def _get_paths(self) -> Generator[Union[PythonFinder, PathEntry], None, None]: for path in self.path_order: try: entry = self.get_path(path) @@ -436,11 +334,11 @@ def _get_paths(self) -> Generator[PathType | WindowsFinder, None, None]: yield entry @cached_property - def path_entries(self) -> list[PathType | WindowsFinder]: + def path_entries(self) -> List[Union[PythonFinder, PathEntry]]: paths = list(self._get_paths()) return paths - def find_all(self, executable: str) -> list[PathEntry | FinderType]: + def find_all(self, executable) -> List[Union["PathEntry", PythonFinder]]: """ Search the path for an executable. Return all copies. @@ -453,7 +351,7 @@ def find_all(self, executable: str) -> list[PathEntry | FinderType]: filtered = (sub_which(self.get_path(k)) for k in self.path_order) return list(filtered) - def which(self, executable: str) -> PathEntry | None: + def which(self, executable) -> Union["PathEntry", None]: """ Search for an executable on the path. @@ -466,7 +364,7 @@ def which(self, executable: str) -> PathEntry | None: filtered = (sub_which(self.get_path(k)) for k in self.path_order) return next(iter(f for f in filtered if f is not None), None) - def _filter_paths(self, finder: Callable) -> Iterator: + def _filter_paths(self, finder) -> Iterator: for path in self._get_paths(): if path is None: continue @@ -476,103 +374,68 @@ def _filter_paths(self, finder: Callable) -> Iterator: if python is not None: yield python - def _get_all_pythons(self, finder: Callable) -> Iterator: + def _get_all_pythons(self, finder) -> Iterator: for python in self._filter_paths(finder): if python is not None and python.is_python: yield python - def get_pythons(self, finder: Callable) -> Iterator: - sort_key = operator.attrgetter("as_python.version_sort") + def get_pythons(self, finder) -> Iterator: + def version_sort_key(entry): + return entry.as_python.version_sort + pythons = [entry for entry in self._get_all_pythons(finder)] - for python in sorted(pythons, key=sort_key, reverse=True): + for python in sorted(pythons, key=version_sort_key, reverse=True): if python is not None: yield python def find_all_python_versions( self, - major: str | int | None = None, - minor: int | None = None, - patch: int | None = None, - pre: bool | None = None, - dev: bool | None = None, - arch: str | None = None, - name: str | None = None, - ) -> list[PathEntry]: - """Search for a specific python version on the path. Return all copies - - :param major: Major python version to search for. - :type major: int - :param int minor: Minor python version to search for, defaults to None - :param int patch: Patch python version to search for, defaults to None - :param bool pre: Search for prereleases (default None) - prioritize releases if None - :param bool dev: Search for devreleases (default None) - prioritize releases if None - :param str arch: Architecture to include, e.g. '64bit', defaults to None - :param str name: The name of a python version, e.g. ``anaconda3-5.3.0`` - :return: A list of :class:`~pythonfinder.models.PathEntry` instances matching the version requested. - :rtype: List[:class:`~pythonfinder.models.PathEntry`] - """ + major=None, # type: Optional[Union[str, int]] + minor=None, # type: Optional[int] + patch=None, # type: Optional[int] + pre=None, # type: Optional[bool] + dev=None, # type: Optional[bool] + arch=None, # type: Optional[str] + name=None, # type: Optional[str] + ) -> List["PathEntry"]: + + def sub_finder(obj): + return obj.find_all_python_versions(major, minor, patch, pre, dev, arch, name) - sub_finder = operator.methodcaller( - "find_all_python_versions", major, minor, patch, pre, dev, arch, name - ) alternate_sub_finder = None if major and not (minor or patch or pre or dev or arch or name): - alternate_sub_finder = operator.methodcaller( - "find_all_python_versions", None, None, None, None, None, None, major - ) - if os.name == "nt" and self.windows_finder: - windows_finder_version = sub_finder(self.windows_finder) - if windows_finder_version: - return windows_finder_version + def alternate_sub_finder(obj): + return obj.find_all_python_versions(None, None, None, None, None, None, major) + values = list(self.get_pythons(sub_finder)) if not values and alternate_sub_finder is not None: values = list(self.get_pythons(alternate_sub_finder)) + return values def find_python_version( self, - major: str | int | None = None, - minor: str | int | None = None, - patch: str | int | None = None, - pre: bool | None = None, - dev: bool | None = None, - arch: str | None = None, - name: str | None = None, - sort_by_path: bool = False, + major=None, # type: Optional[Union[str, int]] + minor=None, # type: Optional[Union[str, int]] + patch=None, # type: Optional[Union[str, int]] + pre=None, # type: Optional[bool] + dev=None, # type: Optional[bool] + arch=None, # type: Optional[str] + name=None, # type: Optional[str] + sort_by_path=False, # type: bool ) -> PathEntry: - """Search for a specific python version on the path. - - :param major: Major python version to search for. - :type major: int - :param int minor: Minor python version to search for, defaults to None - :param int patch: Patch python version to search for, defaults to None - :param bool pre: Search for prereleases (default None) - prioritize releases if None - :param bool dev: Search for devreleases (default None) - prioritize releases if None - :param str arch: Architecture to include, e.g. '64bit', defaults to None - :param str name: The name of a python version, e.g. ``anaconda3-5.3.0`` - :param bool sort_by_path: Whether to sort by path -- default sort is by version(default: False) - :return: A :class:`~pythonfinder.models.PathEntry` instance matching the version requested. - :rtype: :class:`~pythonfinder.models.PathEntry` - """ + + def sub_finder(obj): + return obj.find_python_version(major, minor, patch, pre, dev, arch, name) + + def alternate_sub_finder(obj): + return obj.find_all_python_versions(None, None, None, None, None, None, name) major, minor, patch, name = split_version_and_name(major, minor, patch, name) - sub_finder = operator.methodcaller( - "find_python_version", major, minor, patch, pre, dev, arch, name - ) - alternate_sub_finder = None - if name and not (minor or patch or pre or dev or arch or major): - alternate_sub_finder = operator.methodcaller( - "find_all_python_versions", None, None, None, None, None, None, name - ) if major and minor and patch: _tuple_pre = pre if pre is not None else False _tuple_dev = dev if dev is not None else False - version_tuple = (major, minor, patch, _tuple_pre, _tuple_dev) - version_tuple_pre = (major, minor, patch, True, False) - if os.name == "nt" and self.windows_finder: - windows_finder_version = sub_finder(self.windows_finder) - if windows_finder_version: - return windows_finder_version + if sort_by_path: paths = [self.get_path(k) for k in self.path_order] for path in paths: @@ -588,22 +451,24 @@ def find_python_version( ver = next(iter(self.get_pythons(sub_finder)), None) if not ver and alternate_sub_finder is not None: ver = next(iter(self.get_pythons(alternate_sub_finder)), None) + if ver: if ver.as_python.version_tuple[:5] in self.python_version_dict: self.python_version_dict[ver.as_python.version_tuple[:5]].append(ver) else: self.python_version_dict[ver.as_python.version_tuple[:5]] = [ver] + return ver @classmethod def create( cls, - path: str = None, - system: bool = False, - only_python: bool = False, - global_search: bool = True, - ignore_unsupported: bool = True, - ) -> SystemPath: + path=None, # type: str + system=False, # type: bool + only_python=False, # type: bool + global_search=True, # type: bool + ignore_unsupported=True, # type: bool + ) -> "SystemPath": """Create a new :class:`pythonfinder.models.SystemPath` instance. :param path: Search path to prepend when searching, defaults to None @@ -615,14 +480,14 @@ def create( :rtype: :class:`pythonfinder.models.SystemPath` """ - path_entries: DefaultDict[str, PythonFinder | PathEntry] = defaultdict(PathEntry) - paths: list[str] = [] + path_entries = defaultdict(PathEntry) + paths = [] if ignore_unsupported: os.environ["PYTHONFINDER_IGNORE_UNSUPPORTED"] = fs_str("1") if global_search: if "PATH" in os.environ: paths = os.environ["PATH"].split(os.pathsep) - path_order: list[str] = [] + path_order = [] # type: List[str] if path: path_order = [path] path_instance = ensure_path(path) @@ -655,147 +520,31 @@ def create( global_search=global_search, ignore_unsupported=ignore_unsupported, ) - instance = instance._run_setup() + instance._run_setup() return instance - -@attr.s(slots=True) -class PathEntry(BasePath): - is_root = attr.ib(default=True, type=bool, order=False) - - def __lt__(self, other: BasePath) -> bool: - return self.path.as_posix() < other.path.as_posix() - - def __lte__(self, other: BasePath) -> bool: - return self.path.as_posix() <= other.path.as_posix() - - def __gt__(self, other: BasePath) -> bool: - return self.path.as_posix() > other.path.as_posix() - - def __gte__(self, other: BasePath) -> bool: - return self.path.as_posix() >= other.path.as_posix() - - def __del__(self): - if hasattr(self, "_children"): - del self._children - BasePath.__del__(self) - - def _filter_children(self) -> Iterator[Path]: - if not os.access(str(self.path), os.R_OK): - return iter([]) - if self.only_python: - children = filter_pythons(self.path) - else: - children = self.path.iterdir() - return children - - def _gen_children(self) -> Iterator: - shim_paths = get_shim_paths() - pass_name = self.name != self.path.name - pass_args = {"is_root": False, "only_python": self.only_python} - if pass_name: - if self.name is not None and isinstance(self.name, str): - pass_args["name"] = self.name # type: ignore - elif self.path is not None and isinstance(self.path.name, str): - pass_args["name"] = self.path.name # type: ignore - - if not self.is_dir: - yield (self.path.as_posix(), self) - elif self.is_root: - for child in self._filter_children(): - if any(is_in_path(str(child), shim) for shim in shim_paths): - continue - if self.only_python: - try: - entry = PathEntry.create(path=child, **pass_args) # type: ignore - except (InvalidPythonVersion, ValueError): - continue - else: - try: - entry = PathEntry.create(path=child, **pass_args) # type: ignore - except (InvalidPythonVersion, ValueError): - continue - yield (child.as_posix(), entry) - return - - @property - def children(self) -> dict[str, PathEntry]: - children: dict[str, PathEntry] = getattr(self, "_children", {}) - if not children: - for child_key, child_val in self._gen_children(): - children[child_key] = child_val - self.children = children - return self._children - - @children.setter - def children(self, val: dict[str, PathEntry]) -> None: - self._children = val - - @children.deleter - def children(self) -> None: - del self._children - - @classmethod - def create( - cls, - path: str | Path, - is_root: bool = False, - only_python: bool = False, - pythons: dict[str, PythonVersion] = None, - name: str | None = None, - ) -> PathEntry: - """Helper method for creating new :class:`pythonfinder.models.PathEntry` instances. - - :param str path: Path to the specified location. - :param bool is_root: Whether this is a root from the environment PATH variable, defaults to False - :param bool only_python: Whether to search only for python executables, defaults to False - :param dict pythons: A dictionary of existing python objects (usually from a finder), defaults to None - :param str name: Name of the python version, e.g. ``anaconda3-5.3.0`` - :return: A new instance of the class. - :rtype: :class:`pythonfinder.models.PathEntry` - """ - - target = ensure_path(path) - guessed_name = False - if not name: - guessed_name = True - name = target.name - creation_args = { - "path": target, - "is_root": is_root, - "only_python": only_python, - "name": name, - } - if pythons: - creation_args["pythons"] = pythons - _new = cls(**creation_args) - if pythons and only_python: - children = {} - child_creation_args = {"is_root": False, "only_python": only_python} - if not guessed_name: - child_creation_args["name"] = _new.name # type: ignore - for pth, python in pythons.items(): - if any(shim in normalize_path(str(pth)) for shim in SHIM_PATHS): - continue - pth = ensure_path(pth) - children[pth.as_posix()] = PathEntry( # type: ignore - py_version=python, path=pth, **child_creation_args - ) - _new._children = children - return _new - - -@attr.s class VersionPath(SystemPath): - base: Path = attr.ib(default=None, validator=optional_instance_of(Path)) - name: str = attr.ib(default=None) + base: Optional[Path] = None + name: Optional[str] = None + + class Config: + validate_assignment = True + arbitrary_types_allowed = True + allow_mutation = True + include_private_attributes = True + keep_untouched = (cached_property,) + + @validator('base', pre=True) + def optional_instance_of_path(cls, value): + if value is not None and not isinstance(value, Path): + raise ValueError('The "base" attribute must be an instance of Path or None') + return value @classmethod def create(cls, path, only_python=True, pythons=None, name=None): """Accepts a path to a base python version directory. Generates the version listings for it""" - from .path import PathEntry path = ensure_path(path) path_entries = defaultdict(PathEntry) diff --git a/src/pythonfinder/models/python.py b/src/pythonfinder/models/python.py index 8dd09f6..334415c 100644 --- a/src/pythonfinder/models/python.py +++ b/src/pythonfinder/models/python.py @@ -1,105 +1,95 @@ -from __future__ import annotations - import logging -import operator import os import platform import sys from collections import defaultdict -from functools import lru_cache -from pathlib import Path -from typing import ( - TYPE_CHECKING, - Callable, - DefaultDict, - Generator, - Iterator, - List, - Optional, - overload, -) +from pathlib import Path, WindowsPath +from typing import Callable, DefaultDict, Dict, List, Optional, Tuple, Union, Generator, Iterator -import attr from packaging.version import Version +from pydantic import Field, validator +from .common import FinderBaseModel from ..environment import ASDF_DATA_DIR, PYENV_ROOT, SYSTEM_ARCH from ..exceptions import InvalidPythonVersion from ..utils import ( - _filter_none, ensure_path, expand_paths, get_python_version, guess_company, is_in_path, looks_like_python, - optional_instance_of, parse_asdf_version_order, parse_pyenv_version_order, parse_python_version, unnest, ) -from .mixins import BaseFinder, BasePath - -if TYPE_CHECKING: - from .._vendor.pep514tools.environment import Environment - from .path import PathEntry -else: - - def overload(f): - return f +from .mixins import PathEntry logger = logging.getLogger(__name__) -@attr.s(slots=True) -class PythonFinder(BasePath, BaseFinder): - root = attr.ib(default=None, validator=optional_instance_of(Path), type=Path) +class PythonFinder(PathEntry): + root: Path # should come before versions, because its value is used in versions's default initializer. #: Whether to ignore any paths which raise exceptions and are not actually python - ignore_unsupported = attr.ib(default=True, type=bool) + ignore_unsupported: bool = True #: Glob path for python versions off of the root directory - version_glob_path = attr.ib(default="versions/*", type=str) + version_glob_path: str = "versions/*" #: The function to use to sort version order when returning an ordered version set - sort_function: Callable = attr.ib(default=None) + sort_function: Optional[Callable] = None #: The root locations used for discovery - roots = attr.ib(default=attr.Factory(defaultdict), type=defaultdict) + roots: Dict = Field(default_factory=lambda: defaultdict()) #: List of paths discovered during search - paths = attr.ib(type=list) + paths: List = Field(default_factory=lambda: list()) #: shim directory - shim_dir = attr.ib(default="shims", type=str) + shim_dir: str = "shims" #: Versions discovered in the specified paths - _versions = attr.ib(default=attr.Factory(defaultdict), type=defaultdict) - _pythons = attr.ib(default=attr.Factory(defaultdict), type=defaultdict) + _versions: Dict = Field(default_factory=lambda: defaultdict()) + pythons_ref: Dict = Field(default_factory=lambda: defaultdict()) - def __del__(self) -> None: - self._versions = defaultdict() - self._pythons = defaultdict() - self.roots = defaultdict() - self.paths = [] + class Config: + validate_assignment = True + arbitrary_types_allowed = True + allow_mutation = True + include_private_attributes = True + # keep_untouched = (cached_property,) @property - def expanded_paths(self) -> Generator: - return ( - path for path in unnest(p for p in self.versions.values()) if path is not None - ) + def version_paths(self): + # type: () -> Any + return self._versions.values() + + @property + def pythons(self): + # type: () -> DefaultDict[str, PathEntry] + return self.pythons_ref + + @pythons.setter + def pythons(self, value): + # type: (DefaultDict[str, PathEntry]) -> None + self.pythons_ref = value @property - def is_pyenv(self) -> bool: + def is_pyenv(self): + # type: () -> bool return is_in_path(str(self.root), PYENV_ROOT) @property - def is_asdf(self) -> bool: + def is_asdf(self): + # type: () -> bool return is_in_path(str(self.root), ASDF_DATA_DIR) - def get_version_order(self) -> list[Path]: + def get_version_order(self): + # type: () -> List[Path] version_paths = [ p for p in self.root.glob(self.version_glob_path) if not (p.parent.name == "envs" or p.name == "envs") ] versions = {v.name: v for v in version_paths} - version_order: list[Path] = [] + version_order = [] # type: List[Path] if self.is_pyenv: version_order = [ versions[v] for v in parse_pyenv_version_order() if v in versions @@ -117,7 +107,8 @@ def get_version_order(self) -> list[Path]: version_order = version_paths return version_order - def get_bin_dir(self, base: Path | str) -> Path: + def get_bin_dir(self, base): + # type: (Union[Path, str]) -> Path if isinstance(base, str): base = Path(base) if os.name == "nt": @@ -125,14 +116,11 @@ def get_bin_dir(self, base: Path | str) -> Path: return base / "bin" @classmethod - def version_from_bin_dir(cls, entry: PathEntry) -> PathEntry | None: - py_version = None + def version_from_bin_dir(cls, entry) -> Optional[PathEntry]: py_version = next(iter(entry.find_all_python_versions()), None) return py_version - def _iter_version_bases(self) -> Iterator[tuple[Path, PathEntry]]: - from .path import PathEntry - + def _iter_version_bases(self) -> Iterator[Tuple[Path, PathEntry]]: for p in self.get_version_order(): bin_dir = self.get_bin_dir(p) if bin_dir.exists() and bin_dir.is_dir(): @@ -142,7 +130,7 @@ def _iter_version_bases(self) -> Iterator[tuple[Path, PathEntry]]: self.roots[p] = entry yield (p, entry) - def _iter_versions(self) -> Iterator[tuple[Path, PathEntry, tuple]]: + def _iter_versions(self) -> Iterator[Tuple[Path, PathEntry, Tuple]]: for base_path, entry in self._iter_version_bases(): version = None version_entry = None @@ -177,7 +165,7 @@ def _iter_versions(self) -> Iterator[tuple[Path, PathEntry, tuple]]: yield (base_path, entry, version_tuple) @property - def versions(self) -> DefaultDict[tuple, PathEntry]: + def versions(self) -> DefaultDict[Tuple, PathEntry]: if not self._versions: for _, entry, version_tuple in self._iter_versions(): self._versions[version_tuple] = entry @@ -188,13 +176,17 @@ def _iter_pythons(self) -> Iterator: if path.as_posix() in self._pythons: yield self._pythons[path.as_posix()] elif version_tuple not in self.versions: - yield from entry.find_all_python_versions() + for python in entry.find_all_python_versions(): + yield python else: yield self.versions[version_tuple] - @paths.default - def get_paths(self) -> list[PathEntry]: - _paths = [base for _, base in self._iter_version_bases()] + @validator("paths", pre=True, always=True) + def get_paths(cls, v) -> List[PathEntry]: + if v is not None: + return v + + _paths = [base for _, base in cls._iter_version_bases()] return _paths @property @@ -202,28 +194,21 @@ def pythons(self) -> DefaultDict[str, PathEntry]: if not self._pythons: from .path import PathEntry - self._pythons: DefaultDict[str, PathEntry] = defaultdict(PathEntry) + self._pythons = defaultdict(PathEntry) # type: DefaultDict[str, PathEntry] for python in self._iter_pythons(): python_path = python.path.as_posix() # type: ignore self._pythons[python_path] = python return self._pythons @pythons.setter - def pythons(self, value: DefaultDict[str, PathEntry]) -> None: + def pythons(self, value) -> None: self._pythons = value def get_pythons(self) -> DefaultDict[str, PathEntry]: return self.pythons - @overload @classmethod - def create( - cls, - root: str, - sort_function: Callable, - version_glob_path: str | None = None, - ignore_unsupported: bool = True, - ) -> PythonFinder: + def create(cls, root, sort_function, version_glob_path=None, ignore_unsupported=True) -> "PythonFinder": root = ensure_path(root) if not version_glob_path: version_glob_path = "versions/*" @@ -237,14 +222,14 @@ def create( def find_all_python_versions( self, - major: str | int | None = None, - minor: int | None = None, - patch: int | None = None, - pre: bool | None = None, - dev: bool | None = None, - arch: str | None = None, - name: str | None = None, - ) -> list[PathEntry]: + major=None, # type: Optional[Union[str, int]] + minor=None, # type: Optional[int] + patch=None, # type: Optional[int] + pre=None, # type: Optional[bool] + dev=None, # type: Optional[bool] + arch=None, # type: Optional[str] + name=None, # type: Optional[str] + ) -> List[PathEntry]: """Search for a specific python version on the path. Return all copies :param major: Major python version to search for. @@ -260,9 +245,10 @@ def find_all_python_versions( """ call_method = "find_all_python_versions" if self.is_dir else "find_python_version" - sub_finder = operator.methodcaller( - call_method, major, minor, patch, pre, dev, arch, name - ) + + def sub_finder(path): + return getattr(path, call_method)(major, minor, patch, pre, dev, arch, name) + if not any([major, minor, patch, name]): pythons = [ next(iter(py for py in base.find_all_python_versions()), None) @@ -270,8 +256,12 @@ def find_all_python_versions( ] else: pythons = [sub_finder(path) for path in self.paths] + pythons = expand_paths(pythons, True) - version_sort = operator.attrgetter("as_python.version_sort") + + def version_sort(py): + return py.as_python.version_sort + paths = [ p for p in sorted(pythons, key=version_sort, reverse=True) if p is not None ] @@ -279,14 +269,14 @@ def find_all_python_versions( def find_python_version( self, - major: str | int | None = None, - minor: int | None = None, - patch: int | None = None, - pre: bool | None = None, - dev: bool | None = None, - arch: str | None = None, - name: str | None = None, - ) -> PathEntry | None: + major=None, # type: Optional[Union[str, int]] + minor=None, # type: Optional[int] + patch=None, # type: Optional[int] + pre=None, # type: Optional[bool] + dev=None, # type: Optional[bool] + arch=None, # type: Optional[str] + name=None, # type: Optional[str] + ) -> Optional[PathEntry]: """Search or self for the specified Python version and return the first match. :param major: Major version number. @@ -300,10 +290,12 @@ def find_python_version( :returns: A :class:`~pythonfinder.models.PathEntry` instance matching the version requested. """ - sub_finder = operator.methodcaller( - "find_python_version", major, minor, patch, pre, dev, arch, name - ) - version_sort = operator.attrgetter("as_python.version_sort") + def sub_finder(obj): + return getattr(obj, "find_python_version")(major, minor, patch, pre, dev, arch, name) + + def version_sort(path_entry): + return path_entry.as_python.version_sort + unnested = [sub_finder(self.roots[path]) for path in self.roots] unnested = [ p @@ -313,7 +305,8 @@ def find_python_version( paths = sorted(list(unnested), key=version_sort, reverse=True) return next(iter(p for p in paths if p is not None), None) - def which(self, name: str) -> PathEntry | None: + + def which(self, name) -> Optional[PathEntry]: """Search in this path for an executable. :param executable: The name of an executable to search for. @@ -326,26 +319,33 @@ def which(self, name: str) -> PathEntry | None: return non_empty_match -@attr.s(slots=True) -class PythonVersion: - major = attr.ib(default=0, type=int) - minor: int | None = attr.ib(default=None) - patch: int | None = attr.ib(default=None) - is_prerelease = attr.ib(default=False, type=bool) - is_postrelease = attr.ib(default=False, type=bool) - is_devrelease = attr.ib(default=False, type=bool) - is_debug = attr.ib(default=False, type=bool) - version: Version = attr.ib(default=None) - architecture: str | None = attr.ib(default=None) - comes_from: PathEntry | None = attr.ib(default=None) - executable: str | None = attr.ib(default=None) - company: str | None = attr.ib(default=None) - name = attr.ib(default=None, type=str) +class PythonVersion(FinderBaseModel): + major: int = 0 + minor: Optional[int] = None + patch: Optional[int] = None + is_prerelease: bool = False + is_postrelease: bool = False + is_devrelease: bool = False + is_debug: bool = False + version: Optional[Version] = None + architecture: Optional[str] = None + comes_from: Optional['PathEntry'] = None + executable: Optional[Union[str, WindowsPath, Path]] = None + company: Optional[str] = None + name: Optional[str] = None + + class Config: + validate_assignment = True + arbitrary_types_allowed = True + allow_mutation = True + include_private_attributes = True + # keep_untouched = (cached_property,) + def __getattribute__(self, key): - result = super().__getattribute__(key) + result = super(PythonVersion, self).__getattribute__(key) if key in ["minor", "patch"] and result is None: - executable: str | None = None + executable = None # type: Optional[str] if self.executable: executable = self.executable elif self.comes_from: @@ -356,7 +356,7 @@ def __getattribute__(self, key): instance_dict = self.parse_executable(executable) for k in instance_dict.keys(): try: - super().__getattribute__(k) + super(PythonVersion, self).__getattribute__(k) except AttributeError: continue else: @@ -365,7 +365,7 @@ def __getattribute__(self, key): return result @property - def version_sort(self) -> tuple[int, int, int | None, int, int]: + def version_sort(self) -> Tuple[int, int, Optional[int], int, int]: """ A tuple for sorting against other instances of the same class. @@ -395,7 +395,7 @@ def version_sort(self) -> tuple[int, int, int | None, int, int]: ) @property - def version_tuple(self) -> tuple[int, int | None, int | None, bool, bool, bool]: + def version_tuple(self) -> Tuple[int, int, int, bool, bool, bool]: """ Provides a version tuple for using as a dictionary key. @@ -414,20 +414,20 @@ def version_tuple(self) -> tuple[int, int | None, int | None, bool, bool, bool]: def matches( self, - major: int | None = None, - minor: int | None = None, - patch: int | None = None, - pre: bool = False, - dev: bool = False, - arch: str | None = None, - debug: bool = False, - python_name: str | None = None, + major=None, # type: Optional[int] + minor=None, # type: Optional[int] + patch=None, # type: Optional[int] + pre=False, # type: bool + dev=False, # type: bool + arch=None, # type: Optional[str] + debug=False, # type: bool + python_name=None, # type: Optional[str] ) -> bool: result = False if arch: own_arch = self.get_architecture() if arch.isdigit(): - arch = f"{arch}bit" + arch = "{0}bit".format(arch) if ( (major is None or self.major == major) and (minor is None or self.minor == minor) @@ -445,17 +445,16 @@ def matches( result = True return result - def as_major(self) -> PythonVersion: - self_dict = attr.asdict(self, recurse=False, filter=_filter_none).copy() - self_dict.update({"minor": None, "patch": None}) - return self.create(**self_dict) + def as_major(self) -> "PythonVersion": + self.minor = None + self.patch = None + return self - def as_minor(self) -> PythonVersion: - self_dict = attr.asdict(self, recurse=False, filter=_filter_none).copy() - self_dict.update({"patch": None}) - return self.create(**self_dict) + def as_minor(self) -> "PythonVersion": + self.patch = None + return self - def as_dict(self) -> dict[str, int | bool | Version | None]: + def as_dict(self) -> Dict[str, Union[int, bool, Version, None]]: return { "major": self.major, "minor": self.minor, @@ -468,7 +467,7 @@ def as_dict(self) -> dict[str, int | bool | Version | None]: "company": self.company, } - def update_metadata(self, metadata: dict[str, str | int | Version]) -> None: + def update_metadata(self, metadata) -> None: """ Update the metadata on the current :class:`pythonfinder.models.python.PythonVersion` @@ -486,8 +485,7 @@ def update_metadata(self, metadata: dict[str, str | int | Version]) -> None: setattr(self, key, metadata[key]) @classmethod - @lru_cache(maxsize=1024) - def parse(cls, version: str) -> dict[str, str | int | Version]: + def parse(cls, version) -> Dict[str, Union[str, int, Version]]: """ Parse a valid version string into a dictionary @@ -522,13 +520,7 @@ def get_architecture(self) -> str: return self.architecture @classmethod - def from_path( - cls, - path: str | PathEntry, - name: str | None = None, - ignore_unsupported: bool = True, - company: str | None = None, - ) -> PythonVersion: + def from_path(cls, path, name=None, ignore_unsupported=True, company=None) -> "PythonVersion": """ Parses a python version from a system path. @@ -543,11 +535,6 @@ def from_path( :return: An instance of a PythonVersion. :rtype: :class:`~pythonfinder.models.python.PythonVersion` """ - - from .path import PathEntry - - if not isinstance(path, PathEntry): - path = PathEntry.create(path, is_root=False, only_python=True, name=name) from ..environment import IGNORE_UNSUPPORTED ignore_unsupported = ignore_unsupported or IGNORE_UNSUPPORTED @@ -580,10 +567,9 @@ def from_path( return cls(**instance_dict) # type: ignore @classmethod - @lru_cache(maxsize=1024) - def parse_executable(cls, path: str) -> dict[str, str | int | Version | None]: - result_dict: dict[str, str | int | Version | None] = {} - result_version: str | None = None + def parse_executable(cls, path) -> Dict[str, Optional[Union[str, int, Version]]]: + result_dict = {} + result_version = None if path is None: raise TypeError("Must pass a valid path to parse.") if not isinstance(path, str): @@ -600,12 +586,7 @@ def parse_executable(cls, path: str) -> dict[str, str | int | Version | None]: return result_dict @classmethod - def from_windows_launcher( - cls, - launcher_entry: Environment, - name: str | None = None, - company: str | None = None, - ) -> PythonVersion: + def from_windows_launcher(cls, launcher_entry, name=None, company=None) -> "PythonVersion": """Create a new PythonVersion instance from a Windows Launcher Entry :param launcher_entry: A python launcher environment object. @@ -614,9 +595,6 @@ def from_windows_launcher( :return: An instance of a PythonVersion. :rtype: :class:`~pythonfinder.models.python.PythonVersion` """ - - from .path import PathEntry - creation_dict = cls.parse(launcher_entry.info.version) base_path = ensure_path(launcher_entry.info.install_path.__getattr__("")) default_path = base_path / "python.exe" @@ -643,28 +621,32 @@ def from_windows_launcher( return py_version @classmethod - def create(cls, **kwargs) -> PythonVersion: + def create(cls, **kwargs) -> "PythonVersion": if "architecture" in kwargs: if kwargs["architecture"].isdigit(): - kwargs["architecture"] = "{}bit".format(kwargs["architecture"]) + kwargs["architecture"] = "{0}bit".format(kwargs["architecture"]) return cls(**kwargs) -@attr.s -class VersionMap: - versions: DefaultDict[ - tuple[int, int | None, int | None, bool, bool, bool], list[PathEntry] - ] = attr.ib(factory=defaultdict) +class VersionMap(FinderBaseModel): + versions: DefaultDict[Tuple[int, Optional[int], Optional[int], bool, bool, bool], List[PathEntry]] = defaultdict(list) + + class Config: + validate_assignment = True + arbitrary_types_allowed = True + allow_mutation = True + include_private_attributes = True + # keep_untouched = (cached_property,) def add_entry(self, entry) -> None: - version: PythonVersion = entry.as_python + version = entry.as_python if version: _ = self.versions[version.version_tuple] paths = {p.path for p in self.versions.get(version.version_tuple, [])} if entry.path not in paths: self.versions[version.version_tuple].append(entry) - def merge(self, target: VersionMap) -> None: + def merge(self, target) -> None: for version, entries in target.versions.items(): if version not in self.versions: self.versions[version] = entries diff --git a/src/pythonfinder/models/windows.py b/src/pythonfinder/models/windows.py deleted file mode 100644 index 6523700..0000000 --- a/src/pythonfinder/models/windows.py +++ /dev/null @@ -1,137 +0,0 @@ -from __future__ import annotations - -import operator -from collections import defaultdict -from typing import Any, DefaultDict, TypeVar - -import attr - -from ..exceptions import InvalidPythonVersion -from ..utils import ensure_path -from .mixins import BaseFinder -from .path import PathEntry -from .python import PythonVersion - -FinderType = TypeVar("FinderType") - - -@attr.s -class WindowsFinder(BaseFinder): - paths = attr.ib(default=attr.Factory(list), type=list) - version_list = attr.ib(default=attr.Factory(list), type=list) - _versions: DefaultDict[tuple, PathEntry] = attr.ib() - _pythons: DefaultDict[str, PathEntry] = attr.ib() - - def find_all_python_versions( - self, - major: str | int | None = None, - minor: int | None = None, - patch: int | None = None, - pre: bool | None = None, - dev: bool | None = None, - arch: str | None = None, - name: str | None = None, - ) -> list[PathEntry]: - version_matcher = operator.methodcaller( - "matches", major, minor, patch, pre, dev, arch, python_name=name - ) - pythons = [py for py in self.version_list if version_matcher(py)] - version_sort = operator.attrgetter("version_sort") - return [ - c.comes_from - for c in sorted(pythons, key=version_sort, reverse=True) - if c.comes_from - ] - - def find_python_version( - self, - major: str | int | None = None, - minor: int | None = None, - patch: int | None = None, - pre: bool | None = None, - dev: bool | None = None, - arch: str | None = None, - name: str | None = None, - ) -> PathEntry | None: - return next( - iter( - v - for v in self.find_all_python_versions( - major=major, - minor=minor, - patch=patch, - pre=pre, - dev=dev, - arch=arch, - name=name, - ) - ), - None, - ) - - @_versions.default - def get_versions(self) -> DefaultDict[tuple, PathEntry]: - versions: DefaultDict[tuple, PathEntry] = defaultdict(PathEntry) - from pythonfinder._vendor.pep514tools import environment as pep514env - - env_versions = pep514env.findall() - path = None - for version_object in env_versions: - install_path = getattr(version_object.info, "install_path", None) - name = getattr(version_object, "tag", None) - company = getattr(version_object, "company", None) - if install_path is None: - continue - try: - path = ensure_path(install_path.__getattr__("")) - except AttributeError: - continue - if not path.exists(): - continue - try: - py_version = PythonVersion.from_windows_launcher( - version_object, name=name, company=company - ) - except (InvalidPythonVersion, AttributeError): - continue - if py_version is None: - continue - self.version_list.append(py_version) - python_path = ( - py_version.comes_from.path - if py_version.comes_from - else py_version.executable - ) - python_kwargs = {python_path: py_version} if python_path is not None else {} - base_dir = PathEntry.create( - path, is_root=True, only_python=True, pythons=python_kwargs - ) - versions[py_version.version_tuple[:5]] = base_dir - self.paths.append(base_dir) - return versions - - @property - def versions(self) -> DefaultDict[tuple, PathEntry]: - if not self._versions: - self._versions = self.get_versions() - return self._versions - - @_pythons.default - def get_pythons(self) -> DefaultDict[str, PathEntry]: - pythons: DefaultDict[str, PathEntry] = defaultdict() - for version in self.version_list: - _path = ensure_path(version.comes_from.path) - pythons[_path.as_posix()] = version.comes_from - return pythons - - @property - def pythons(self) -> DefaultDict[str, PathEntry]: - return self._pythons - - @pythons.setter - def pythons(self, value: DefaultDict[str, PathEntry]) -> None: - self._pythons = value - - @classmethod - def create(cls: type[FinderType], *args: Any, **kwargs: Any) -> FinderType: - return cls() diff --git a/src/pythonfinder/pythonfinder.py b/src/pythonfinder/pythonfinder.py index a9b4e50..cb9e83d 100644 --- a/src/pythonfinder/pythonfinder.py +++ b/src/pythonfinder/pythonfinder.py @@ -1,134 +1,59 @@ -from __future__ import annotations - -import importlib import operator import os -from functools import lru_cache -from typing import TYPE_CHECKING, Any, Optional, Union - -from .exceptions import InvalidPythonVersion -from .utils import Iterable, filter_pythons, version_re - -if TYPE_CHECKING: - from .models.path import PathEntry, SystemPath - from .models.windows import WindowsFinder - -STRING_TYPE = Union[str, str, bytes] - +from typing import Any, Dict, List, Optional, Union -class Finder: +from pydantic import BaseModel - """ - A cross-platform Finder for locating python and other executables. +from .exceptions import InvalidPythonVersion +from .utils import Iterable, version_re +from .models.path import PathEntry, SystemPath +from .models.python import PythonVersion +from .models.common import FinderBaseModel - Searches for python and other specified binaries starting in *path*, if supplied, - but searching the bin path of ``sys.executable`` if *system* is ``True``, and then - searching in the ``os.environ['PATH']`` if *global_search* is ``True``. When *global_search* - is ``False``, this search operation is restricted to the allowed locations of - *path* and *system*. - """ - def __init__( - self, - path: str | None = None, - system: bool = False, - global_search: bool = True, - ignore_unsupported: bool = True, - sort_by_path: bool = False, - ) -> None: - """Create a new :class:`~pythonfinder.pythonfinder.Finder` instance. +class Finder(FinderBaseModel): - :param path: A bin-directory search location, defaults to None - :param path: str, optional - :param system: Whether to include the bin-dir of ``sys.executable``, defaults to False - :param system: bool, optional - :param global_search: Whether to search the global path from os.environ, defaults to True - :param global_search: bool, optional - :param ignore_unsupported: Whether to ignore unsupported python versions, if False, an - error is raised, defaults to True - :param ignore_unsupported: bool, optional - :param bool sort_by_path: Whether to always sort by path - :returns: a :class:`~pythonfinder.pythonfinder.Finder` object. - """ + path_prepend: Optional[str] = None + system: bool = False + global_search: bool = True + ignore_unsupported: bool = True + sort_by_path: bool = False + system_path: Optional[SystemPath] = None - self.path_prepend: str | None = path - self.global_search: bool = global_search - self.system: bool = system - self.sort_by_path: bool = sort_by_path - self.ignore_unsupported: bool = ignore_unsupported - self._system_path: SystemPath | None = None - self._windows_finder: WindowsFinder | None = None + def __init__(self, **data) -> None: + super().__init__(**data) + self.system_path = self.create_system_path() + @property def __hash__(self) -> int: return hash( (self.path_prepend, self.system, self.global_search, self.ignore_unsupported) ) - def __eq__(self, other: Any) -> bool: - return self.__hash__() == other.__hash__() + def __eq__(self, other) -> bool: + return self.__hash__ == other.__hash__ def create_system_path(self) -> SystemPath: - pyfinder_path = importlib.import_module("pythonfinder.models.path") - return pyfinder_path.SystemPath.create( + return SystemPath.create( path=self.path_prepend, system=self.system, global_search=self.global_search, ignore_unsupported=self.ignore_unsupported, ) - def reload_system_path(self) -> None: - """ - Rebuilds the base system path and all of the contained finders within it. - - This will re-apply any changes to the environment or any version changes on the system. - """ - - if self._system_path is not None: - self._system_path = self._system_path.clear_caches() - self._system_path = None - pyfinder_path = importlib.import_module("pythonfinder.models.path") - importlib.reload(pyfinder_path) - self._system_path = self.create_system_path() - - def rehash(self) -> Finder: - if not self._system_path: - self._system_path = self.create_system_path() - self.find_all_python_versions.cache_clear() - self.find_python_version.cache_clear() - if self._windows_finder is not None: - self._windows_finder = None - filter_pythons.cache_clear() - self.reload_system_path() - return self - - @property - def system_path(self) -> SystemPath: - if self._system_path is None: - self._system_path = self.create_system_path() - return self._system_path - - @property - def windows_finder(self) -> WindowsFinder | None: - if os.name == "nt" and not self._windows_finder: - from .models import WindowsFinder - - self._windows_finder = WindowsFinder() - return self._windows_finder - - def which(self, exe: str) -> PathEntry | None: + def which(self, exe) -> Optional[PathEntry]: return self.system_path.which(exe) @classmethod def parse_major( cls, - major: str | None, - minor: int | None = None, - patch: int | None = None, - pre: bool | None = None, - dev: bool | None = None, - arch: str | None = None, - ) -> dict[str, int | str | bool | None]: - from .models import PythonVersion + major, # type: Optional[str] + minor=None, # type: Optional[int] + patch=None, # type: Optional[int] + pre=None, # type: Optional[bool] + dev=None, # type: Optional[bool] + arch=None, # type: Optional[str] + ) -> Dict[str, Any]: major_is_str = major and isinstance(major, str) is_num = ( @@ -145,7 +70,7 @@ def parse_major( ) name = None if major and major_has_arch: - orig_string = f"{major!s}" + orig_string = "{0!s}".format(major) major, _, arch = major.rpartition("-") if arch: arch = arch.lower().lstrip("x").replace("bit", "") @@ -153,19 +78,19 @@ def parse_major( major = orig_string arch = None else: - arch = f"{arch}bit" + arch = "{0}bit".format(arch) try: version_dict = PythonVersion.parse(major) except (ValueError, InvalidPythonVersion): if name is None: - name = f"{major!s}" + name = "{0!s}".format(major) major = None version_dict = {} elif major and major[0].isalpha(): return {"major": None, "name": major, "arch": arch} elif major and is_num: match = version_re.match(major) - version_dict = match.groupdict() if match else {} # type: ignore + version_dict = match.groupdict() if match else {} version_dict.update( { "is_prerelease": bool(version_dict.get("prerel", False)), @@ -198,18 +123,17 @@ def parse_major( version_dict["name"] = name return version_dict - @lru_cache(maxsize=1024) def find_python_version( self, - major: str | int | None = None, - minor: int | None = None, - patch: int | None = None, - pre: bool | None = None, - dev: bool | None = None, - arch: str | None = None, - name: str | None = None, - sort_by_path: bool = False, - ) -> PathEntry | None: + major=None, # type: Optional[Union[str, int]] + minor=None, # type: Optional[int] + patch=None, # type: Optional[int] + pre=None, # type: Optional[bool] + dev=None, # type: Optional[bool] + arch=None, # type: Optional[str] + name=None, # type: Optional[str] + sort_by_path=False, # type: bool + ) -> Optional[PathEntry]: """ Find the python version which corresponds most closely to the version requested. @@ -224,17 +148,9 @@ def find_python_version( :return: A new *PathEntry* pointer at a matching python version, if one can be located. :rtype: :class:`pythonfinder.models.path.PathEntry` """ - minor = int(minor) if minor is not None else minor patch = int(patch) if patch is not None else patch - version_dict: dict[str, str | int | Any] = { - "minor": minor, - "patch": patch, - "name": name, - "arch": arch, - } - if ( isinstance(major, str) and pre is None @@ -244,10 +160,10 @@ def find_python_version( ): version_dict = self.parse_major(major, minor=minor, patch=patch, arch=arch) major = version_dict["major"] - minor = version_dict.get("minor", minor) # type: ignore - patch = version_dict.get("patch", patch) # type: ignore - arch = version_dict.get("arch", arch) # type: ignore - name = version_dict.get("name", name) # type: ignore + minor = version_dict.get("minor", minor) + patch = version_dict.get("patch", patch) + arch = version_dict.get("arch", arch) + name = version_dict.get("name", name) _pre = version_dict.get("is_prerelease", pre) pre = bool(_pre) if _pre is not None else pre _dev = version_dict.get("is_devrelease", dev) @@ -255,19 +171,7 @@ def find_python_version( if "architecture" in version_dict and isinstance( version_dict["architecture"], str ): - arch = version_dict["architecture"] # type: ignore - if os.name == "nt" and self.windows_finder is not None: - found = self.windows_finder.find_python_version( - major=major, - minor=minor, - patch=patch, - pre=pre, - dev=dev, - arch=arch, - name=name, - ) - if found: - return found + arch = version_dict["architecture"] return self.system_path.find_python_version( major=major, minor=minor, @@ -279,17 +183,16 @@ def find_python_version( sort_by_path=self.sort_by_path, ) - @lru_cache(maxsize=1024) def find_all_python_versions( self, - major: str | int | None = None, - minor: int | None = None, - patch: int | None = None, - pre: bool | None = None, - dev: bool | None = None, - arch: str | None = None, - name: str | None = None, - ) -> list[PathEntry]: + major=None, # type: Optional[Union[str, int]] + minor=None, # type: Optional[int] + patch=None, # type: Optional[int] + pre=None, # type: Optional[bool] + dev=None, # type: Optional[bool] + arch=None, # type: Optional[str] + name=None, # type: Optional[str] + ) -> List[PathEntry]: version_sort = operator.attrgetter("as_python.version_sort") python_version_dict = getattr(self.system_path, "python_version_dict", {}) if python_version_dict: @@ -306,11 +209,10 @@ def find_all_python_versions( ) if not isinstance(versions, Iterable): versions = [versions] - # This list has already been mostly sorted on windows, we don't need to reverse it again path_list = sorted( filter(lambda v: v and v.as_python, versions), key=version_sort, reverse=True ) - path_map: dict[str, PathEntry] = {} + path_map = {} for path in path_list: try: resolved_path = path.path.resolve() diff --git a/src/pythonfinder/utils.py b/src/pythonfinder/utils.py index 8174880..d660dd8 100644 --- a/src/pythonfinder/utils.py +++ b/src/pythonfinder/utils.py @@ -1,5 +1,5 @@ from __future__ import annotations - +import io import itertools import os import re @@ -7,22 +7,16 @@ from collections import OrderedDict from collections.abc import Iterable, Sequence from fnmatch import fnmatch -from functools import lru_cache -from pathlib import Path from threading import Timer -from typing import TYPE_CHECKING, Any, Iterator, Optional, Union +from pathlib import Path +from builtins import TimeoutError +from typing import Any, Dict, Iterator, List, Optional, Tuple, Union -import attr -from packaging.version import InvalidVersion, Version +from packaging.version import Version, InvalidVersion from .environment import PYENV_ROOT, SUBPROCESS_TIMEOUT from .exceptions import InvalidPythonVersion -if TYPE_CHECKING: - from attr.validators import _OptionalValidator # type: ignore - - from .models.path import PathEntry - version_re_str = ( r"(?P\d+)(?:\.(?P\d+))?(?:\.(?P(?<=\.)[0-9]+))?\.?" @@ -56,8 +50,8 @@ "|".join(PYTHON_IMPLEMENTATIONS) ) ) -EXE_MATCH_STR = r"{}(?:\.(?P{}))?".format(PY_MATCH_STR, "|".join(KNOWN_EXTS)) -RE_MATCHER = re.compile(rf"({version_re_str}|{PY_MATCH_STR})") +EXE_MATCH_STR = r"{0}(?:\.(?P{1}))?".format(PY_MATCH_STR, "|".join(KNOWN_EXTS)) +RE_MATCHER = re.compile(r"({0}|{1})".format(version_re_str, PY_MATCH_STR)) EXE_MATCHER = re.compile(EXE_MATCH_STR) RULES_BASE = [ "*{0}", @@ -72,11 +66,13 @@ MATCH_RULES = [] for rule in RULES: - MATCH_RULES.extend([f"{rule}.{ext}" if ext else f"{rule}" for ext in KNOWN_EXTS]) + MATCH_RULES.extend( + ["{0}.{1}".format(rule, ext) if ext else "{0}".format(rule) for ext in KNOWN_EXTS] + ) -@lru_cache(maxsize=1024) -def get_python_version(path: str) -> str: +def get_python_version(path): + # type: (str) -> str """Get python version string using subprocess from a given path.""" version_cmd = [ path, @@ -105,9 +101,9 @@ def get_python_version(path: str) -> str: return out.strip() -@lru_cache(maxsize=1024) -def parse_python_version(version_str: str) -> dict[str, str | int | Version]: - from packaging.version import parse as parse_version +def parse_python_version(version_str): + # type: (str) -> Dict[str, Union[str, int, Version]] + from pipenv.patched.pip._vendor.packaging.version import parse as parse_version is_debug = False if version_str.endswith("-debug"): @@ -116,7 +112,7 @@ def parse_python_version(version_str: str) -> dict[str, str | int | Version]: match = version_re.match(version_str) if not match: raise InvalidPythonVersion("%s is not a python version" % version_str) - version_dict: dict[str, str] = match.groupdict() + version_dict = match.groupdict() # type: Dict[str, str] major = int(version_dict.get("major", 0)) if version_dict.get("major") else None minor = int(version_dict.get("minor", 0)) if version_dict.get("minor") else None patch = int(version_dict.get("patch", 0)) if version_dict.get("patch") else None @@ -126,7 +122,7 @@ def parse_python_version(version_str: str) -> dict[str, str | int | Version]: if patch: patch = int(patch) - version: Version | None = None + version = None # type: Optional[Version] try: version = parse_version(version_str) @@ -138,7 +134,7 @@ def parse_python_version(version_str: str) -> dict[str, str | int | Version]: pre = "" if v_dict.get("prerel") and v_dict.get("prerelversion"): pre = v_dict.pop("prerel") - pre = "{}{}".format(pre, v_dict.pop("prerelversion")) + pre = "{0}{1}".format(pre, v_dict.pop("prerelversion")) v_dict["pre"] = pre keys = ["major", "minor", "patch", "pre", "postdev", "post", "dev"] values = [v_dict.get(val) for val in keys] @@ -156,18 +152,8 @@ def parse_python_version(version_str: str) -> dict[str, str | int | Version]: } -def optional_instance_of(cls: Any) -> _OptionalValidator: - """ - Return an validator to determine whether an input is an optional instance of a class. - - :return: A validator to determine optional instance membership. - :rtype: :class:`~attr.validators._OptionalValidator` - """ - - return attr.validators.optional(attr.validators.instance_of(cls)) - - -def path_is_executable(path: str) -> bool: +def path_is_executable(path): + # type: (str) -> bool """ Determine whether the supplied path is executable. @@ -178,8 +164,8 @@ def path_is_executable(path: str) -> bool: return os.access(str(path), os.X_OK) -@lru_cache(maxsize=1024) -def path_is_known_executable(path: Path) -> bool: +def path_is_known_executable(path): + # type: (Path) -> bool """ Returns whether a given path is a known executable from known executable extensions or has the executable bit toggled. @@ -197,8 +183,8 @@ def path_is_known_executable(path: Path) -> bool: ) -@lru_cache(maxsize=1024) -def looks_like_python(name: str) -> bool: +def looks_like_python(name): + # type: (str) -> bool """ Determine whether the supplied filename looks like a possible name of python. @@ -215,8 +201,8 @@ def looks_like_python(name: str) -> bool: return False -@lru_cache(maxsize=1024) -def path_is_python(path: Path) -> bool: +def path_is_python(path): + # type: (Path) -> bool """ Determine whether the supplied path is executable and looks like a possible path to python. @@ -229,8 +215,8 @@ def path_is_python(path: Path) -> bool: return path_is_executable(path) and looks_like_python(path.name) -@lru_cache(maxsize=1024) -def guess_company(path: str) -> str | None: +def guess_company(path): + # type: (str) -> Optional[str] """Given a path to python, guess the company who created it :param str path: The path to guess about @@ -243,8 +229,8 @@ def guess_company(path: str) -> str | None: ) -@lru_cache(maxsize=1024) -def path_is_pythoncore(path: str) -> bool: +def path_is_pythoncore(path): + # type: (str) -> bool """Given a path, determine whether it appears to be pythoncore. Does not verify whether the path is in fact a path to python, but simply @@ -261,8 +247,8 @@ def path_is_pythoncore(path: str) -> bool: return False -@lru_cache(maxsize=1024) -def ensure_path(path: Path | str) -> Path: +def ensure_path(path): + # type: (Union[Path, str]) -> Path """ Given a path (either a string or a Path object), expand variables and return a Path object. @@ -278,14 +264,16 @@ def ensure_path(path: Path | str) -> Path: return path.absolute() -def _filter_none(k: Any, v: Any) -> bool: +def _filter_none(k, v): + # type: (Any, Any) -> bool if v: return True return False # TODO: Reimplement in vistir -def normalize_path(path: str) -> str: +def normalize_path(path): + # type: (str) -> str return os.path.normpath( os.path.normcase( os.path.abspath(os.path.expandvars(os.path.expanduser(str(path)))) @@ -293,8 +281,8 @@ def normalize_path(path: str) -> str: ) -@lru_cache(maxsize=1024) -def filter_pythons(path: str | Path) -> Iterable: +def filter_pythons(path): + # type: (Union[str, Path]) -> Iterable """Return all valid pythons in a given path""" if not isinstance(path, Path): path = Path(str(path)) @@ -304,8 +292,9 @@ def filter_pythons(path: str | Path) -> Iterable: # TODO: Port to vistir -def unnest(item: Any) -> Iterable[Any]: - target: Iterable | None = None +def unnest(item): + # type: (Any) -> Iterable[Any] + target = None # type: Optional[Iterable] if isinstance(item, Iterable) and not isinstance(item, str): item, target = itertools.tee(item, 2) else: @@ -314,27 +303,30 @@ def unnest(item: Any) -> Iterable[Any]: for el in target: if isinstance(el, Iterable) and not isinstance(el, str): el, el_copy = itertools.tee(el, 2) - yield from unnest(el_copy) + for sub in unnest(el_copy): + yield sub else: yield el else: yield target -def parse_pyenv_version_order(filename: str = "version") -> list[str]: +def parse_pyenv_version_order(filename="version"): + # type: (str) -> List[str] version_order_file = normalize_path(os.path.join(PYENV_ROOT, filename)) if os.path.exists(version_order_file) and os.path.isfile(version_order_file): - with open(version_order_file, encoding="utf-8") as fh: + with io.open(version_order_file, encoding="utf-8") as fh: contents = fh.read() version_order = [v for v in contents.splitlines()] return version_order return [] -def parse_asdf_version_order(filename: str = ".tool-versions") -> list[str]: +def parse_asdf_version_order(filename=".tool-versions"): + # type: (str) -> List[str] version_order_file = normalize_path(os.path.join("~", filename)) if os.path.exists(version_order_file) and os.path.isfile(version_order_file): - with open(version_order_file, encoding="utf-8") as fh: + with io.open(version_order_file, encoding="utf-8") as fh: contents = fh.read() python_section = next( iter(line for line in contents.splitlines() if line.startswith("python")), @@ -349,12 +341,12 @@ def parse_asdf_version_order(filename: str = ".tool-versions") -> list[str]: def split_version_and_name( - major: str | int | None = None, - minor: str | int | None = None, - patch: str | int | None = None, - name: str | None = None, -) -> tuple[str | int | None, str | int | None, str | int | None, str | None,]: - # noqa + major=None, # type: Optional[Union[str, int]] + minor=None, # type: Optional[Union[str, int]] + patch=None, # type: Optional[Union[str, int]] + name=None, # type: Optional[str] +): + # type: (...) -> Tuple[Optional[Union[str, int]], Optional[Union[str, int]], Optional[Union[str, int]], Optional[str]] # noqa if isinstance(major, str) and not minor and not patch: # Only proceed if this is in the format "x.y.z" or similar if major.isdigit() or (major.count(".") > 0 and major[0].isdigit()): @@ -372,7 +364,7 @@ def split_version_and_name( major = major name = None else: - name = f"{major!s}" + name = "{0!s}".format(major) major = None return (major, minor, patch, name) @@ -382,7 +374,7 @@ def is_in_path(path, parent): return normalize_path(str(path)).startswith(normalize_path(str(parent))) -def expand_paths(path: Sequence | PathEntry, only_python: bool = True) -> Iterator: +def expand_paths(path, only_python=True) -> Iterator: """ Recursively expand a list or :class:`~pythonfinder.models.path.PathEntry` instance @@ -399,15 +391,17 @@ def expand_paths(path: Sequence | PathEntry, only_python: bool = True) -> Iterat for p in path: if p is None: continue - yield from itertools.chain.from_iterable( + for expanded in itertools.chain.from_iterable( expand_paths(p, only_python=only_python) - ) + ): + yield expanded elif path is not None and path.is_dir: - for p in path.children.values(): + for p in path.children_ref.values(): if p is not None and p.is_python and p.as_python is not None: - yield from itertools.chain.from_iterable( + for sub_path in itertools.chain.from_iterable( expand_paths(p, only_python=only_python) - ) + ): + yield sub_path else: if path is not None and ( not only_python or (path.is_python and path.as_python is not None) @@ -415,7 +409,8 @@ def expand_paths(path: Sequence | PathEntry, only_python: bool = True) -> Iterat yield path -def dedup(iterable: Iterable) -> Iterable: +def dedup(iterable): + # type: (Iterable) -> Iterable """Deduplicate an iterable object like iter(set(iterable)) but order-reserved. """ From e54d0438d37f842c074761d987cb78e7d28f1951 Mon Sep 17 00:00:00 2001 From: Matt Davis Date: Sat, 29 Apr 2023 17:29:48 -0400 Subject: [PATCH 02/25] add missing file --- src/pythonfinder/models/common.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 src/pythonfinder/models/common.py diff --git a/src/pythonfinder/models/common.py b/src/pythonfinder/models/common.py new file mode 100644 index 0000000..d8896b2 --- /dev/null +++ b/src/pythonfinder/models/common.py @@ -0,0 +1,26 @@ +from typing import Any, Dict + +from pipenv.vendor.pydantic import BaseModel, Extra + + +class FinderBaseModel(BaseModel): + def __setattr__(self, name, value): # noqa: C901 (ignore complexity) + private_attributes = { + field_name for field_name in self.__annotations__ if field_name.startswith("_") + } + + if name in private_attributes or name in self.__fields__: + return object.__setattr__(self, name, value) + + if self.__config__.extra is not Extra.allow and name not in self.__fields__: + raise ValueError(f'"{self.__class__.__name__}" object has no field "{name}"') + + object.__setattr__(self, name, value) + + class Config: + validate_assignment = True + arbitrary_types_allowed = True + allow_mutation = True + include_private_attributes = False + check_fields = False # Add this line + From f4b7ee7502fea64cb0ba45fefe6be2a5f7d0d9d1 Mon Sep 17 00:00:00 2001 From: Matt Davis Date: Sat, 29 Apr 2023 17:33:12 -0400 Subject: [PATCH 03/25] Corrected imports --- src/pythonfinder/models/common.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/pythonfinder/models/common.py b/src/pythonfinder/models/common.py index d8896b2..a9a2d67 100644 --- a/src/pythonfinder/models/common.py +++ b/src/pythonfinder/models/common.py @@ -1,6 +1,4 @@ -from typing import Any, Dict - -from pipenv.vendor.pydantic import BaseModel, Extra +from pydantic import BaseModel, Extra class FinderBaseModel(BaseModel): @@ -22,5 +20,3 @@ class Config: arbitrary_types_allowed = True allow_mutation = True include_private_attributes = False - check_fields = False # Add this line - From 11fcf43b17c2af0a3483b92db3a0cb99513e2423 Mon Sep 17 00:00:00 2001 From: Matt Davis Date: Sat, 29 Apr 2023 17:35:56 -0400 Subject: [PATCH 04/25] addressing build error --- src/pythonfinder/models/path.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/pythonfinder/models/path.py b/src/pythonfinder/models/path.py index 527269e..4adaccc 100644 --- a/src/pythonfinder/models/path.py +++ b/src/pythonfinder/models/path.py @@ -82,10 +82,6 @@ def set_defaults(cls, values): values['path_order'] = [] values['_finders'] = {} values['paths'] = defaultdict(PathEntry) - return values - - @root_validator(pre=True) - def set_executables(cls, values): paths = values.get('paths') if paths: values['executables'] = [ From 011575fb5882b91213d45dff79a127d3c4476465 Mon Sep 17 00:00:00 2001 From: Matt Davis Date: Sat, 29 Apr 2023 17:39:26 -0400 Subject: [PATCH 05/25] address import issue with tests --- tests/test_python.py | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/tests/test_python.py b/tests/test_python.py index 4607bf7..2421120 100644 --- a/tests/test_python.py +++ b/tests/test_python.py @@ -9,6 +9,7 @@ from packaging.version import Version import pythonfinder +from pythonfinder import utils, environment from .testutils import ( is_in_ospath, @@ -94,10 +95,9 @@ def get_python_version(path, orig_fn=None): with monkeypatch.context() as m: os.environ["PYTHONFINDER_IGNORE_UNSUPPORTED"] = "1" m.setattr("subprocess.Popen", mock_version) - orig_run_fn = pythonfinder.utils.get_python_version + orig_run_fn = utils.get_python_version get_pyversion = functools.partial(get_python_version, orig_fn=orig_run_fn) m.setattr("pythonfinder.utils.get_python_version", get_pyversion) - # m.setattr("pythonfinder.utils.get_python_version", mock_version) parsed = pythonfinder.models.python.PythonVersion.from_path(path) assert isinstance(parsed.version, Version) @@ -112,9 +112,8 @@ def test_shims_are_kept(monkeypatch, no_pyenv_root_envvar, setup_pythons, no_vir global_search=True, system=False, ignore_unsupported=True ) f.rehash() - # assert pythonfinder.environment.get_shim_paths() == [] assert is_in_ospath("~/.pyenv/shims") - shim_paths = pythonfinder.environment.get_shim_paths() + shim_paths = environment.get_shim_paths() # Shims directories are no longer added to the system path order # but instead are used as indicators of the presence of the plugin # and used to trigger plugin setup -- this is true only if ``PYENV_ROOT`` is set` @@ -123,7 +122,7 @@ def test_shims_are_kept(monkeypatch, no_pyenv_root_envvar, setup_pythons, no_vir os.path.join(normalize_path("~/.pyenv/shims")) not in f.system_path.path_order ), ( - pythonfinder.environment.get_shim_paths() + environment.get_shim_paths() ) # "\n".join(f.system_path.path_order) else: assert ( @@ -152,14 +151,14 @@ def test_shims_are_kept(monkeypatch, no_pyenv_root_envvar, setup_pythons, no_vir @pytest.mark.skip_nt def test_shims_are_removed(monkeypatch, no_virtual_env, setup_pythons): with monkeypatch.context() as m: - pyenv_dir = pythonfinder.utils.normalize_path("~/.pyenv") - asdf_dir = pythonfinder.utils.normalize_path("~/.asdf") - importlib.reload(pythonfinder.environment) + pyenv_dir = utils.normalize_path("~/.pyenv") + asdf_dir = utils.normalize_path("~/.asdf") + importlib.reload(environment) importlib.reload(pythonfinder.models.path) m.setattr( - pythonfinder.environment, + environment, "SHIM_PATHS", - pythonfinder.environment.get_shim_paths(), + environment.get_shim_paths(), ) f = pythonfinder.pythonfinder.Finder( global_search=True, system=False, ignore_unsupported=True @@ -169,8 +168,8 @@ def test_shims_are_removed(monkeypatch, no_virtual_env, setup_pythons): assert os.environ["PYENV_ROOT"] == os.path.abspath( os.path.join(os.path.expanduser("~"), ".pyenv") ) - assert os.environ["PYENV_ROOT"] == pythonfinder.environment.PYENV_ROOT - assert pythonfinder.environment.PYENV_INSTALLED + assert os.environ["PYENV_ROOT"] == environment.PYENV_ROOT + assert environment.PYENV_INSTALLED assert f.system_path.pyenv_finder is not None python_version_paths = list( v.path From c5eb2f618b764cae1377a085e0a7e443d916f108 Mon Sep 17 00:00:00 2001 From: Matt Davis Date: Sat, 29 Apr 2023 17:43:24 -0400 Subject: [PATCH 06/25] address import issue with tests --- src/pythonfinder/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pythonfinder/utils.py b/src/pythonfinder/utils.py index d660dd8..9810794 100644 --- a/src/pythonfinder/utils.py +++ b/src/pythonfinder/utils.py @@ -103,7 +103,7 @@ def get_python_version(path): def parse_python_version(version_str): # type: (str) -> Dict[str, Union[str, int, Version]] - from pipenv.patched.pip._vendor.packaging.version import parse as parse_version + from packaging.version import parse as parse_version is_debug = False if version_str.endswith("-debug"): From 784839c753fb5d56cf86e644bb38f3bcb2df4567 Mon Sep 17 00:00:00 2001 From: Matt Davis Date: Sat, 29 Apr 2023 17:48:48 -0400 Subject: [PATCH 07/25] Try casting to Path in test --- tests/test_python.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/test_python.py b/tests/test_python.py index 2421120..5af28d7 100644 --- a/tests/test_python.py +++ b/tests/test_python.py @@ -4,6 +4,7 @@ import importlib import os import sys +from pathlib import Path import pytest from packaging.version import Version @@ -98,6 +99,7 @@ def get_python_version(path, orig_fn=None): orig_run_fn = utils.get_python_version get_pyversion = functools.partial(get_python_version, orig_fn=orig_run_fn) m.setattr("pythonfinder.utils.get_python_version", get_pyversion) + path = Path(path) parsed = pythonfinder.models.python.PythonVersion.from_path(path) assert isinstance(parsed.version, Version) From 7e30a1474c1bbb8a354a3af9fe13f970d51fa1b3 Mon Sep 17 00:00:00 2001 From: Matt Davis Date: Sat, 29 Apr 2023 17:54:42 -0400 Subject: [PATCH 08/25] Try casting to PythonFinder in test --- src/pythonfinder/models/python.py | 20 +++++--------------- tests/test_python.py | 5 ++--- 2 files changed, 7 insertions(+), 18 deletions(-) diff --git a/src/pythonfinder/models/python.py b/src/pythonfinder/models/python.py index 334415c..6800cd8 100644 --- a/src/pythonfinder/models/python.py +++ b/src/pythonfinder/models/python.py @@ -61,16 +61,6 @@ def version_paths(self): # type: () -> Any return self._versions.values() - @property - def pythons(self): - # type: () -> DefaultDict[str, PathEntry] - return self.pythons_ref - - @pythons.setter - def pythons(self, value): - # type: (DefaultDict[str, PathEntry]) -> None - self.pythons_ref = value - @property def is_pyenv(self): # type: () -> bool @@ -191,18 +181,18 @@ def get_paths(cls, v) -> List[PathEntry]: @property def pythons(self) -> DefaultDict[str, PathEntry]: - if not self._pythons: + if not self.pythons_ref: from .path import PathEntry - self._pythons = defaultdict(PathEntry) # type: DefaultDict[str, PathEntry] + self.pythons_ref = defaultdict(PathEntry) # type: DefaultDict[str, PathEntry] for python in self._iter_pythons(): python_path = python.path.as_posix() # type: ignore - self._pythons[python_path] = python - return self._pythons + self.pythons_ref[python_path] = python + return self.pythons_ref @pythons.setter def pythons(self, value) -> None: - self._pythons = value + self.pythons_ref = value def get_pythons(self) -> DefaultDict[str, PathEntry]: return self.pythons diff --git a/tests/test_python.py b/tests/test_python.py index 5af28d7..3bed05b 100644 --- a/tests/test_python.py +++ b/tests/test_python.py @@ -4,13 +4,12 @@ import importlib import os import sys -from pathlib import Path - import pytest from packaging.version import Version import pythonfinder from pythonfinder import utils, environment +from pythonfinder.models.python import PythonFinder from .testutils import ( is_in_ospath, @@ -99,7 +98,7 @@ def get_python_version(path, orig_fn=None): orig_run_fn = utils.get_python_version get_pyversion = functools.partial(get_python_version, orig_fn=orig_run_fn) m.setattr("pythonfinder.utils.get_python_version", get_pyversion) - path = Path(path) + path = PythonFinder(path) parsed = pythonfinder.models.python.PythonVersion.from_path(path) assert isinstance(parsed.version, Version) From 86f1550d0758ba446bdb2735c50a6aa910628e36 Mon Sep 17 00:00:00 2001 From: Matt Davis Date: Sat, 29 Apr 2023 17:56:17 -0400 Subject: [PATCH 09/25] Try casting to PythonFinder in test --- tests/test_python.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_python.py b/tests/test_python.py index 3bed05b..068916f 100644 --- a/tests/test_python.py +++ b/tests/test_python.py @@ -98,7 +98,7 @@ def get_python_version(path, orig_fn=None): orig_run_fn = utils.get_python_version get_pyversion = functools.partial(get_python_version, orig_fn=orig_run_fn) m.setattr("pythonfinder.utils.get_python_version", get_pyversion) - path = PythonFinder(path) + path = PythonFinder(path=path) parsed = pythonfinder.models.python.PythonVersion.from_path(path) assert isinstance(parsed.version, Version) From 93145f849bc3fb9f92fc7eb602db4b739fcebc87 Mon Sep 17 00:00:00 2001 From: Matt Davis Date: Sat, 29 Apr 2023 17:57:55 -0400 Subject: [PATCH 10/25] Try casting to PythonFinder in test --- tests/test_python.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_python.py b/tests/test_python.py index 068916f..9715fbb 100644 --- a/tests/test_python.py +++ b/tests/test_python.py @@ -98,7 +98,7 @@ def get_python_version(path, orig_fn=None): orig_run_fn = utils.get_python_version get_pyversion = functools.partial(get_python_version, orig_fn=orig_run_fn) m.setattr("pythonfinder.utils.get_python_version", get_pyversion) - path = PythonFinder(path=path) + path = PythonFinder(root=path) parsed = pythonfinder.models.python.PythonVersion.from_path(path) assert isinstance(parsed.version, Version) From 270738f2d9123bc3bda4fed3ab86f9f4194fa7c3 Mon Sep 17 00:00:00 2001 From: Matt Davis Date: Sat, 29 Apr 2023 18:03:03 -0400 Subject: [PATCH 11/25] Try casting to PythonFinder in test --- tests/test_python.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_python.py b/tests/test_python.py index 9715fbb..0ed28c0 100644 --- a/tests/test_python.py +++ b/tests/test_python.py @@ -98,7 +98,7 @@ def get_python_version(path, orig_fn=None): orig_run_fn = utils.get_python_version get_pyversion = functools.partial(get_python_version, orig_fn=orig_run_fn) m.setattr("pythonfinder.utils.get_python_version", get_pyversion) - path = PythonFinder(root=path) + path = PythonFinder(root=path, path=path) parsed = pythonfinder.models.python.PythonVersion.from_path(path) assert isinstance(parsed.version, Version) From e54050e62eb130bce52bec18f0d5e75988fb87bb Mon Sep 17 00:00:00 2001 From: Matt Davis Date: Sat, 29 Apr 2023 18:06:18 -0400 Subject: [PATCH 12/25] remove not necceessary validator that may be causing test failures. --- src/pythonfinder/models/path.py | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/src/pythonfinder/models/path.py b/src/pythonfinder/models/path.py index 4adaccc..f9f8be2 100644 --- a/src/pythonfinder/models/path.py +++ b/src/pythonfinder/models/path.py @@ -7,7 +7,7 @@ from typing import Any, Dict, List, Generator, Iterator, Optional, Tuple, Union from cached_property import cached_property -from pydantic import Field, validator, root_validator +from pydantic import Field, root_validator from .common import FinderBaseModel from ..compat import fs_str @@ -530,12 +530,6 @@ class Config: include_private_attributes = True keep_untouched = (cached_property,) - @validator('base', pre=True) - def optional_instance_of_path(cls, value): - if value is not None and not isinstance(value, Path): - raise ValueError('The "base" attribute must be an instance of Path or None') - return value - @classmethod def create(cls, path, only_python=True, pythons=None, name=None): """Accepts a path to a base python version directory. From b37513912c45d59bcaf0ac9bd3bdc421410543a1 Mon Sep 17 00:00:00 2001 From: Matt Davis Date: Sat, 29 Apr 2023 18:12:00 -0400 Subject: [PATCH 13/25] Refactor imports based on pydantic bug report. --- tests/test_python.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/tests/test_python.py b/tests/test_python.py index 0ed28c0..f98f13d 100644 --- a/tests/test_python.py +++ b/tests/test_python.py @@ -9,7 +9,8 @@ import pythonfinder from pythonfinder import utils, environment -from pythonfinder.models.python import PythonFinder +from pythonfinder.pythonfinder import Finder +from pythonfinder.models.python import PythonFinder, PythonVersion from .testutils import ( is_in_ospath, @@ -40,7 +41,7 @@ def kill(self): os.environ["PYTHONFINDER_IGNORE_UNSUPPORTED"] = "1" with monkeypatch.context() as m: m.setattr("subprocess.Popen", mock_version) - parsed = pythonfinder.models.python.PythonVersion.from_path( + parsed = PythonVersion.from_path( special_character_python.as_posix() ) assert isinstance(parsed.version, Version) @@ -99,7 +100,7 @@ def get_python_version(path, orig_fn=None): get_pyversion = functools.partial(get_python_version, orig_fn=orig_run_fn) m.setattr("pythonfinder.utils.get_python_version", get_pyversion) path = PythonFinder(root=path, path=path) - parsed = pythonfinder.models.python.PythonVersion.from_path(path) + parsed = PythonVersion.from_path(path) assert isinstance(parsed.version, Version) @@ -109,7 +110,7 @@ def test_shims_are_kept(monkeypatch, no_pyenv_root_envvar, setup_pythons, no_vir os.environ["PATH"] = "{}:{}".format( normalize_path("~/.pyenv/shims"), os.environ["PATH"] ) - f = pythonfinder.pythonfinder.Finder( + f = Finder( global_search=True, system=False, ignore_unsupported=True ) f.rehash() @@ -161,7 +162,7 @@ def test_shims_are_removed(monkeypatch, no_virtual_env, setup_pythons): "SHIM_PATHS", environment.get_shim_paths(), ) - f = pythonfinder.pythonfinder.Finder( + f = Finder( global_search=True, system=False, ignore_unsupported=True ) f.rehash() From 1fa57064ce6712c2ac2f600a9c36ee9dcbfac06f Mon Sep 17 00:00:00 2001 From: Matt Davis Date: Sat, 29 Apr 2023 18:15:25 -0400 Subject: [PATCH 14/25] apply simiilar fix to this test --- tests/test_python.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/test_python.py b/tests/test_python.py index f98f13d..ef91d4a 100644 --- a/tests/test_python.py +++ b/tests/test_python.py @@ -41,9 +41,9 @@ def kill(self): os.environ["PYTHONFINDER_IGNORE_UNSUPPORTED"] = "1" with monkeypatch.context() as m: m.setattr("subprocess.Popen", mock_version) - parsed = PythonVersion.from_path( - special_character_python.as_posix() - ) + path = special_character_python.as_posix() + path = PythonFinder(root=path, path=path) + parsed = PythonVersion.from_path(path) assert isinstance(parsed.version, Version) From 2a60b74be709ea3ed067205b59fb794886c3462a Mon Sep 17 00:00:00 2001 From: Matt Davis Date: Sat, 29 Apr 2023 18:19:08 -0400 Subject: [PATCH 15/25] remove nonexistent method. --- tests/test_python.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/test_python.py b/tests/test_python.py index ef91d4a..20180a1 100644 --- a/tests/test_python.py +++ b/tests/test_python.py @@ -113,7 +113,6 @@ def test_shims_are_kept(monkeypatch, no_pyenv_root_envvar, setup_pythons, no_vir f = Finder( global_search=True, system=False, ignore_unsupported=True ) - f.rehash() assert is_in_ospath("~/.pyenv/shims") shim_paths = environment.get_shim_paths() # Shims directories are no longer added to the system path order @@ -165,7 +164,6 @@ def test_shims_are_removed(monkeypatch, no_virtual_env, setup_pythons): f = Finder( global_search=True, system=False, ignore_unsupported=True ) - f.rehash() python_versions = f.find_all_python_versions() assert os.environ["PYENV_ROOT"] == os.path.abspath( os.path.join(os.path.expanduser("~"), ".pyenv") From db37936ea62def5ea7467f16b7398255d64781d1 Mon Sep 17 00:00:00 2001 From: Matt Davis Date: Sat, 29 Apr 2023 18:21:46 -0400 Subject: [PATCH 16/25] Remove unused class --- src/pythonfinder/models/path.py | 31 ------------------------------- 1 file changed, 31 deletions(-) diff --git a/src/pythonfinder/models/path.py b/src/pythonfinder/models/path.py index f9f8be2..57029be 100644 --- a/src/pythonfinder/models/path.py +++ b/src/pythonfinder/models/path.py @@ -518,34 +518,3 @@ def create( ) instance._run_setup() return instance - -class VersionPath(SystemPath): - base: Optional[Path] = None - name: Optional[str] = None - - class Config: - validate_assignment = True - arbitrary_types_allowed = True - allow_mutation = True - include_private_attributes = True - keep_untouched = (cached_property,) - - @classmethod - def create(cls, path, only_python=True, pythons=None, name=None): - """Accepts a path to a base python version directory. - - Generates the version listings for it""" - - path = ensure_path(path) - path_entries = defaultdict(PathEntry) - bin_ = "{base}/bin" - if path.as_posix().endswith(Path(bin_).name): - path = path.parent - bin_dir = ensure_path(bin_.format(base=path.as_posix())) - if not name: - name = path.name - current_entry = PathEntry.create( - bin_dir, is_root=True, only_python=True, pythons=pythons, name=name - ) - path_entries[bin_dir.as_posix()] = current_entry - return cls(name=name, base=bin_dir, paths=path_entries) From 7d1872f8fcdfbffa27b3c8cf51a17022541d8a3a Mon Sep 17 00:00:00 2001 From: Matt Davis Date: Sat, 29 Apr 2023 18:24:40 -0400 Subject: [PATCH 17/25] iterate on last two tests --- tests/test_python.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/tests/test_python.py b/tests/test_python.py index 20180a1..592dab2 100644 --- a/tests/test_python.py +++ b/tests/test_python.py @@ -1,13 +1,11 @@ from __future__ import annotations import functools -import importlib import os import sys import pytest from packaging.version import Version -import pythonfinder from pythonfinder import utils, environment from pythonfinder.pythonfinder import Finder from pythonfinder.models.python import PythonFinder, PythonVersion @@ -154,8 +152,6 @@ def test_shims_are_removed(monkeypatch, no_virtual_env, setup_pythons): with monkeypatch.context() as m: pyenv_dir = utils.normalize_path("~/.pyenv") asdf_dir = utils.normalize_path("~/.asdf") - importlib.reload(environment) - importlib.reload(pythonfinder.models.path) m.setattr( environment, "SHIM_PATHS", From c86624e3e381d5ed49e6ed6f9375a2a0e2856bbc Mon Sep 17 00:00:00 2001 From: Matt Davis Date: Sat, 29 Apr 2023 18:34:38 -0400 Subject: [PATCH 18/25] factor tests away from importlib --- src/pythonfinder/environment.py | 3 --- src/pythonfinder/models/mixins.py | 7 ++----- src/pythonfinder/models/path.py | 6 +++--- tests/conftest.py | 4 +--- tests/test_python.py | 7 ++----- 5 files changed, 8 insertions(+), 19 deletions(-) diff --git a/src/pythonfinder/environment.py b/src/pythonfinder/environment.py index 1651401..1eb9312 100644 --- a/src/pythonfinder/environment.py +++ b/src/pythonfinder/environment.py @@ -48,6 +48,3 @@ def get_shim_paths(): if PYENV_INSTALLED: shim_paths.append(os.path.join(PYENV_ROOT, "shims")) return [os.path.normpath(os.path.normcase(p)) for p in shim_paths] - - -SHIM_PATHS = get_shim_paths() diff --git a/src/pythonfinder/models/mixins.py b/src/pythonfinder/models/mixins.py index 61a2474..0244ba0 100644 --- a/src/pythonfinder/models/mixins.py +++ b/src/pythonfinder/models/mixins.py @@ -27,10 +27,7 @@ normalize_path, ) -from ..environment import ( - SHIM_PATHS, - get_shim_paths, -) +from ..environment import get_shim_paths class PathEntry(BaseModel): @@ -401,7 +398,7 @@ def create( if not guessed_name: child_creation_args["name"] = _new.name # type: ignore for pth, python in pythons.items(): - if any(shim in normalize_path(str(pth)) for shim in SHIM_PATHS): + if any(shim in normalize_path(str(pth)) for shim in get_shim_paths()): continue pth = ensure_path(pth) children[pth.as_posix()] = PathEntry( # type: ignore diff --git a/src/pythonfinder/models/path.py b/src/pythonfinder/models/path.py index 57029be..6af7c77 100644 --- a/src/pythonfinder/models/path.py +++ b/src/pythonfinder/models/path.py @@ -16,7 +16,7 @@ ASDF_INSTALLED, PYENV_INSTALLED, PYENV_ROOT, - SHIM_PATHS, + get_shim_paths, ) from ..utils import ( dedup, @@ -161,7 +161,7 @@ def _run_setup(self) -> "SystemPath": for p in path_order if not any( is_in_path(normalize_path(str(p)), normalize_path(shim)) - for shim in SHIM_PATHS + for shim in get_shim_paths() ) ] self.paths.update( @@ -497,7 +497,7 @@ def create( } ) paths = [path] + paths - paths = [p for p in paths if not any(is_in_path(p, shim) for shim in SHIM_PATHS)] + paths = [p for p in paths if not any(is_in_path(p, shim) for shim in get_shim_paths())] _path_objects = [ensure_path(p.strip('"')) for p in paths] path_entries.update( { diff --git a/tests/conftest.py b/tests/conftest.py index 6eeee69..34a9001 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,6 +1,5 @@ from __future__ import annotations -import importlib import os import shutil import subprocess @@ -13,6 +12,7 @@ import pytest import pythonfinder +from pythonfinder import environment from .testutils import ( cd, @@ -90,8 +90,6 @@ def no_pyenv_root_envvar(monkeypatch): m.delenv("PYENV_ROOT") if "ASDF_DATA_DIR" in os.environ: m.delenv("ASDF_DATA_DIR") - importlib.reload(pythonfinder.environment) - importlib.reload(pythonfinder.models.path) m.setattr(pythonfinder.environment, "PYENV_INSTALLED", False) m.setattr(pythonfinder.environment, "ASDF_INSTALLED", False) m.setattr(pythonfinder.environment, "PYENV_ROOT", normalize_path("~/.pyenv")) diff --git a/tests/test_python.py b/tests/test_python.py index 592dab2..7f8093f 100644 --- a/tests/test_python.py +++ b/tests/test_python.py @@ -1,11 +1,13 @@ from __future__ import annotations import functools +import importlib import os import sys import pytest from packaging.version import Version +import pythonfinder from pythonfinder import utils, environment from pythonfinder.pythonfinder import Finder from pythonfinder.models.python import PythonFinder, PythonVersion @@ -152,11 +154,6 @@ def test_shims_are_removed(monkeypatch, no_virtual_env, setup_pythons): with monkeypatch.context() as m: pyenv_dir = utils.normalize_path("~/.pyenv") asdf_dir = utils.normalize_path("~/.asdf") - m.setattr( - environment, - "SHIM_PATHS", - environment.get_shim_paths(), - ) f = Finder( global_search=True, system=False, ignore_unsupported=True ) From 3621712eb6baf8015bdb18f0de02baa05d376a4a Mon Sep 17 00:00:00 2001 From: Matt Davis Date: Sat, 29 Apr 2023 18:37:47 -0400 Subject: [PATCH 19/25] Assertion was checking that it mocked itself. --- tests/conftest.py | 5 ----- tests/test_python.py | 1 - 2 files changed, 6 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 34a9001..347f71f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -98,11 +98,6 @@ def no_pyenv_root_envvar(monkeypatch): "ASDF_DATA_DIR", normalize_path("~/.asdf"), ) - m.setattr( - pythonfinder.environment, - "SHIM_PATHS", - pythonfinder.environment.get_shim_paths(), - ) yield diff --git a/tests/test_python.py b/tests/test_python.py index 7f8093f..48f556a 100644 --- a/tests/test_python.py +++ b/tests/test_python.py @@ -161,7 +161,6 @@ def test_shims_are_removed(monkeypatch, no_virtual_env, setup_pythons): assert os.environ["PYENV_ROOT"] == os.path.abspath( os.path.join(os.path.expanduser("~"), ".pyenv") ) - assert os.environ["PYENV_ROOT"] == environment.PYENV_ROOT assert environment.PYENV_INSTALLED assert f.system_path.pyenv_finder is not None python_version_paths = list( From 72922806fbec295dc41795fd0d65b3f95878194c Mon Sep 17 00:00:00 2001 From: Matt Davis Date: Sat, 29 Apr 2023 18:39:40 -0400 Subject: [PATCH 20/25] Assertion was checking that it mocked itself. --- tests/test_python.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/test_python.py b/tests/test_python.py index 48f556a..7b5c318 100644 --- a/tests/test_python.py +++ b/tests/test_python.py @@ -161,7 +161,6 @@ def test_shims_are_removed(monkeypatch, no_virtual_env, setup_pythons): assert os.environ["PYENV_ROOT"] == os.path.abspath( os.path.join(os.path.expanduser("~"), ".pyenv") ) - assert environment.PYENV_INSTALLED assert f.system_path.pyenv_finder is not None python_version_paths = list( v.path From 0220167bc5b61afaa4a877276d23a8a3345f160c Mon Sep 17 00:00:00 2001 From: Matt Davis Date: Sat, 29 Apr 2023 18:41:34 -0400 Subject: [PATCH 21/25] Assertion was checking that it mocked itself. --- tests/test_python.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/test_python.py b/tests/test_python.py index 7b5c318..74cb536 100644 --- a/tests/test_python.py +++ b/tests/test_python.py @@ -161,7 +161,6 @@ def test_shims_are_removed(monkeypatch, no_virtual_env, setup_pythons): assert os.environ["PYENV_ROOT"] == os.path.abspath( os.path.join(os.path.expanduser("~"), ".pyenv") ) - assert f.system_path.pyenv_finder is not None python_version_paths = list( v.path for v in python_versions From 8feed67633c1f4c408c0afff7d6d67c8ebbf6480 Mon Sep 17 00:00:00 2001 From: Matt Davis Date: Sat, 29 Apr 2023 18:46:51 -0400 Subject: [PATCH 22/25] Remove confusing and bad test cases --- tests/conftest.py | 4 -- tests/test_python.py | 93 -------------------------------------------- 2 files changed, 97 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 347f71f..0e1ed53 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -237,10 +237,6 @@ def setup_pythons(isolated_envdir, monkeypatch): pyenv_dict = setup_pyenv(isolated_envdir) os.environ["PATH"] = os.environ.get("PATH").replace("::", ":") version_dicts = {"pyenv": pyenv_dict, "asdf": asdf_dict} - shim_paths = [ - normalize_path(isolated_envdir.joinpath(p).as_posix()) - for p in [".asdf/shims", ".pyenv/shims"] - ] yield version_dicts diff --git a/tests/test_python.py b/tests/test_python.py index 74cb536..92b1c03 100644 --- a/tests/test_python.py +++ b/tests/test_python.py @@ -104,99 +104,6 @@ def get_python_version(path, orig_fn=None): assert isinstance(parsed.version, Version) -@pytest.mark.skip_nt -def test_shims_are_kept(monkeypatch, no_pyenv_root_envvar, setup_pythons, no_virtual_env): - with monkeypatch.context() as m: - os.environ["PATH"] = "{}:{}".format( - normalize_path("~/.pyenv/shims"), os.environ["PATH"] - ) - f = Finder( - global_search=True, system=False, ignore_unsupported=True - ) - assert is_in_ospath("~/.pyenv/shims") - shim_paths = environment.get_shim_paths() - # Shims directories are no longer added to the system path order - # but instead are used as indicators of the presence of the plugin - # and used to trigger plugin setup -- this is true only if ``PYENV_ROOT`` is set` - if shim_paths: - assert ( - os.path.join(normalize_path("~/.pyenv/shims")) - not in f.system_path.path_order - ), ( - environment.get_shim_paths() - ) # "\n".join(f.system_path.path_order) - else: - assert ( - os.path.join(normalize_path("~/.pyenv/shims")) in f.system_path.path_order - ), "\n".join(f.system_path.path_order) - python_versions = f.find_all_python_versions() - anaconda = f.find_python_version("anaconda3-5.3.0") - assert anaconda is not None, python_versions - assert "shims" in anaconda.path.as_posix(), [ - f.system_path.path_order, - f.system_path.pyenv_finder.roots, - ] - # for docker, use just 'anaconda' - for path in f.system_path.path_order: - print( - f"path: {path} Entry: {f.system_path.get_path(path)}", - file=sys.stderr, - ) - which_anaconda = f.which("anaconda3-5.3.0") - if shim_paths: - assert "shims" not in which_anaconda.path.as_posix() - else: - assert "shims" in which_anaconda.path.as_posix() - - -@pytest.mark.skip_nt -def test_shims_are_removed(monkeypatch, no_virtual_env, setup_pythons): - with monkeypatch.context() as m: - pyenv_dir = utils.normalize_path("~/.pyenv") - asdf_dir = utils.normalize_path("~/.asdf") - f = Finder( - global_search=True, system=False, ignore_unsupported=True - ) - python_versions = f.find_all_python_versions() - assert os.environ["PYENV_ROOT"] == os.path.abspath( - os.path.join(os.path.expanduser("~"), ".pyenv") - ) - python_version_paths = list( - v.path - for v in python_versions - if normalized_match(str(v.path), os.environ["PYENV_ROOT"]) - ) - # Make sure we have an entry for every python version installed - python_names = set(list(v.parent.parent.name for v in python_version_paths)) - # this is how we test in docker / on local machines with python installs - # setup_pythons = {version.name for version in all_python_versions if any(plugin in version.path for plugin in ("pyenv", "asdf"))} - # this is the test implementation when we are simulating - setup_key_list = [ - set(list(finder.keys())) for finder in list(setup_pythons.values()) - ] - setup_pythons = {python for finder in setup_key_list for python in finder} - # this calculates the pythons not present when we ran `find_all_python_versions` - missing_from_finder = python_names ^ setup_pythons - if missing_from_finder: - print_python_versions(python_versions) - for p in python_version_paths: - print(f"path: {p}", file=sys.stderr) - for p in sorted(python_names): - print(f"python_name: {p}", file=sys.stderr) - for p in sorted(list(setup_pythons)): - print(f"setup python key: {p}", file=sys.stderr) - assert not missing_from_finder, missing_from_finder - anaconda = f.find_python_version("anaconda3-5.3.0") - assert anaconda is not None, os.listdir( - os.path.join(pyenv_dir, "versions", "anaconda3-5.3.0", "bin") - ) - assert "shims" not in anaconda.path.as_posix() - which_anaconda = f.which("anaconda3-5.3.0") - # for docker, use just 'anaconda' - # which_anaconda = f.which("anaconda") - assert "shims" not in which_anaconda.path.as_posix() - - @pytest.mark.skip_nt def test_pythonfinder(expected_python_versions, all_python_versions): assert sorted(expected_python_versions) == sorted(all_python_versions) From fb3c727c627f8a941e2bb9151cca0eb4d9d23aca Mon Sep 17 00:00:00 2001 From: Matt Davis Date: Sun, 30 Apr 2023 10:30:09 -0400 Subject: [PATCH 23/25] PR feedback --- src/pythonfinder/compat.py | 19 --------- src/pythonfinder/models/mixins.py | 61 ++++++++++++++--------------- src/pythonfinder/models/path.py | 7 ++-- src/pythonfinder/pythonfinder.py | 61 +++++++++++++++-------------- src/pythonfinder/utils.py | 64 ++++++++----------------------- 5 files changed, 78 insertions(+), 134 deletions(-) delete mode 100644 src/pythonfinder/compat.py diff --git a/src/pythonfinder/compat.py b/src/pythonfinder/compat.py deleted file mode 100644 index a9d21ba..0000000 --- a/src/pythonfinder/compat.py +++ /dev/null @@ -1,19 +0,0 @@ -def getpreferredencoding(): - import locale - - # Borrowed from Invoke - # (see https://github.com/pyinvoke/invoke/blob/93af29d/invoke/runners.py#L881) - _encoding = locale.getpreferredencoding(False) - return _encoding - - -DEFAULT_ENCODING = getpreferredencoding() - - -def fs_str(string): - """Encodes a string into the proper filesystem encoding""" - - if isinstance(string, str): - return string - assert not isinstance(string, bytes) - return string.encode(DEFAULT_ENCODING) diff --git a/src/pythonfinder/models/mixins.py b/src/pythonfinder/models/mixins.py index 0244ba0..5292653 100644 --- a/src/pythonfinder/models/mixins.py +++ b/src/pythonfinder/models/mixins.py @@ -14,7 +14,6 @@ from pydantic import BaseModel, Field, validator -from ..compat import fs_str from ..exceptions import InvalidPythonVersion from ..utils import ( KNOWN_EXTS, @@ -56,7 +55,7 @@ def set_children(cls, v, values, **kwargs): return v or cls()._gen_children() def __str__(self) -> str: - return fs_str("{0}".format(self.path.as_posix())) + return "{0}".format(self.path.as_posix()) def __lt__(self, other) -> bool: return self.path.as_posix() < other.path.as_posix() @@ -103,7 +102,7 @@ def as_python(self) -> "PythonVersion": from .python import PythonVersion try: - py_version = PythonVersion.from_path( # type: ignore + py_version = PythonVersion.from_path( path=self, name=self.name ) except (ValueError, InvalidPythonVersion): @@ -166,7 +165,6 @@ def is_python(self) -> None: self.is_python_ref = None def get_py_version(self): - # type: () -> Optional[PythonVersion] from ..environment import IGNORE_UNSUPPORTED if self.is_dir: @@ -176,7 +174,7 @@ def get_py_version(self): from .python import PythonVersion try: - py_version = PythonVersion.from_path( # type: ignore + py_version = PythonVersion.from_path( path=self, name=self.name ) except (InvalidPythonVersion, ValueError): @@ -207,14 +205,14 @@ def _iter_pythons(self) -> Iterator: elif entry.is_python and entry.as_python is not None: yield entry elif self.is_python and self.as_python is not None: - yield self # type: ignore + yield self @property def pythons(self) -> Dict[Union[str, Path], "PathEntry"]: if not self.pythons_ref: self.pythons_ref = defaultdict(PathEntry) for python in self._iter_pythons(): - python_path = python.path.as_posix() # type: ignore + python_path = python.path.as_posix() self.pythons_ref[python_path] = python return self.pythons_ref @@ -230,13 +228,13 @@ def next(self) -> Generator: def find_all_python_versions( self, - major=None, # type: Optional[Union[str, int]] - minor=None, # type: Optional[int] - patch=None, # type: Optional[int] - pre=None, # type: Optional[bool] - dev=None, # type: Optional[bool] - arch=None, # type: Optional[str] - name=None, # type: Optional[str] + major: Optional[Union[str, int]] = None, + minor: Optional[int] = None, + patch: Optional[int] = None, + pre: Optional[bool] = None, + dev: Optional[bool] = None, + arch: Optional[str] = None, + name: Optional[str] = None, ) -> List["PathEntry"]: """Search for a specific python version on the path. Return all copies @@ -249,7 +247,6 @@ def find_all_python_versions( :param str arch: Architecture to include, e.g. '64bit', defaults to None :param str name: The name of a python version, e.g. ``anaconda3-5.3.0`` :return: A list of :class:`~pythonfinder.models.PathEntry` instances matching the version requested. - :rtype: List[:class:`~pythonfinder.models.PathEntry`] """ call_method = "find_all_python_versions" if self.is_dir else "find_python_version" @@ -271,13 +268,13 @@ def version_sort(path_entry): def find_python_version( self, - major=None, # type: Optional[Union[str, int]] - minor=None, # type: Optional[int] - patch=None, # type: Optional[int] - pre=None, # type: Optional[bool] - dev=None, # type: Optional[bool] - arch=None, # type: Optional[str] - name=None, # type: Optional[str] + major: Optional[Union[str, int]] = None, + minor: Optional[int] = None, + patch: Optional[int] = None, + pre: Optional[bool] = None, + dev: Optional[bool] = None, + arch: Optional[str] = None, + name: Optional[str] = None, ) -> Optional["PathEntry"]: """Search or self for the specified Python version and return the first match. @@ -296,7 +293,7 @@ def version_matcher(py_version): if not self.is_dir: if self.is_python and self.as_python and version_matcher(self.py_version): - return self # type: ignore + return self matching_pythons = [ [entry, entry.as_python.version_sort] @@ -325,9 +322,9 @@ def _gen_children(self) -> Iterator: pass_args = {"is_root": False, "only_python": self.only_python} if pass_name: if self.name is not None and isinstance(self.name, str): - pass_args["name"] = self.name # type: ignore + pass_args["name"] = self.name elif self.path is not None and isinstance(self.path.name, str): - pass_args["name"] = self.path.name # type: ignore + pass_args["name"] = self.path.name if not self.is_dir: yield (self.path.as_posix(), self) @@ -337,21 +334,20 @@ def _gen_children(self) -> Iterator: continue if self.only_python: try: - entry = PathEntry.create(path=child, **pass_args) # type: ignore + entry = PathEntry.create(path=child, **pass_args) except (InvalidPythonVersion, ValueError): continue else: try: - entry = PathEntry.create(path=child, **pass_args) # type: ignore + entry = PathEntry.create(path=child, **pass_args) except (InvalidPythonVersion, ValueError): continue yield (child.as_posix(), entry) return @property - def children(self): - # type: () -> Dict[str, PathEntry] - children = getattr(self, "children_ref", {}) # type: Dict[str, PathEntry] + def children(self) -> Dict[str, "PathEntry"]: + children = getattr(self, "children_ref", {}) if not children: for child_key, child_val in self._gen_children(): children[child_key] = child_val @@ -375,7 +371,6 @@ def create( :param dict pythons: A dictionary of existing python objects (usually from a finder), defaults to None :param str name: Name of the python version, e.g. ``anaconda3-5.3.0`` :return: A new instance of the class. - :rtype: :class:`pythonfinder.models.PathEntry` """ target = ensure_path(path) @@ -396,12 +391,12 @@ def create( children = {} child_creation_args = {"is_root": False, "only_python": only_python} if not guessed_name: - child_creation_args["name"] = _new.name # type: ignore + child_creation_args["name"] = _new.name for pth, python in pythons.items(): if any(shim in normalize_path(str(pth)) for shim in get_shim_paths()): continue pth = ensure_path(pth) - children[pth.as_posix()] = PathEntry( # type: ignore + children[pth.as_posix()] = PathEntry( py_version=python, path=pth, **child_creation_args ) _new.children_ref = children diff --git a/src/pythonfinder/models/path.py b/src/pythonfinder/models/path.py index 6af7c77..591c7bb 100644 --- a/src/pythonfinder/models/path.py +++ b/src/pythonfinder/models/path.py @@ -4,13 +4,13 @@ from pathlib import Path import sys from collections import defaultdict, ChainMap +from itertools import chain from typing import Any, Dict, List, Generator, Iterator, Optional, Tuple, Union from cached_property import cached_property from pydantic import Field, root_validator from .common import FinderBaseModel -from ..compat import fs_str from ..environment import ( ASDF_DATA_DIR, ASDF_INSTALLED, @@ -109,8 +109,7 @@ def check_for_asdf(): return ASDF_INSTALLED or os.path.exists(normalize_path(ASDF_DATA_DIR)) @property - def executables(self): - # type: () -> List[PathEntry] + def executables(self) -> List[PathEntry]: self.executables = [ p for p in chain(*(child.children_ref.values() for child in self.paths.values())) @@ -479,7 +478,7 @@ def create( path_entries = defaultdict(PathEntry) paths = [] if ignore_unsupported: - os.environ["PYTHONFINDER_IGNORE_UNSUPPORTED"] = fs_str("1") + os.environ["PYTHONFINDER_IGNORE_UNSUPPORTED"] = "1" if global_search: if "PATH" in os.environ: paths = os.environ["PATH"].split(os.pathsep) diff --git a/src/pythonfinder/pythonfinder.py b/src/pythonfinder/pythonfinder.py index cb9e83d..9682b63 100644 --- a/src/pythonfinder/pythonfinder.py +++ b/src/pythonfinder/pythonfinder.py @@ -47,12 +47,12 @@ def which(self, exe) -> Optional[PathEntry]: @classmethod def parse_major( cls, - major, # type: Optional[str] - minor=None, # type: Optional[int] - patch=None, # type: Optional[int] - pre=None, # type: Optional[bool] - dev=None, # type: Optional[bool] - arch=None, # type: Optional[str] + major: Optional[str], + minor: Optional[int]=None, + patch: Optional[int]=None, + pre: Optional[bool]=None, + dev: Optional[bool]=None, + arch: Optional[str]=None, ) -> Dict[str, Any]: major_is_str = major and isinstance(major, str) @@ -125,28 +125,27 @@ def parse_major( def find_python_version( self, - major=None, # type: Optional[Union[str, int]] - minor=None, # type: Optional[int] - patch=None, # type: Optional[int] - pre=None, # type: Optional[bool] - dev=None, # type: Optional[bool] - arch=None, # type: Optional[str] - name=None, # type: Optional[str] - sort_by_path=False, # type: bool - ) -> Optional[PathEntry]: + major: Optional[Union[str, int]] = None, + minor: Optional[int] = None, + patch: Optional[int] = None, + pre: Optional[bool] = None, + dev: Optional[bool] = None, + arch: Optional[str] = None, + name: Optional[str] = None, + sort_by_path: bool = False, + ) -> Optional[PathEntry]: """ Find the python version which corresponds most closely to the version requested. - :param Union[str, int] major: The major version to look for, or the full version, or the name of the target version. - :param Optional[int] minor: The minor version. If provided, disables string-based lookups from the major version field. - :param Optional[int] patch: The patch version. - :param Optional[bool] pre: If provided, specifies whether to search pre-releases. - :param Optional[bool] dev: If provided, whether to search dev-releases. - :param Optional[str] arch: If provided, which architecture to search. - :param Optional[str] name: *Name* of the target python, e.g. ``anaconda3-5.3.0`` - :param bool sort_by_path: Whether to sort by path -- default sort is by version(default: False) + :param major: The major version to look for, or the full version, or the name of the target version. + :param minor: The minor version. If provided, disables string-based lookups from the major version field. + :param patch: The patch version. + :param pre: If provided, specifies whether to search pre-releases. + :param dev: If provided, whether to search dev-releases. + :param arch: If provided, which architecture to search. + :param name: *Name* of the target python, e.g. ``anaconda3-5.3.0`` + :param sort_by_path: Whether to sort by path -- default sort is by version(default: False) :return: A new *PathEntry* pointer at a matching python version, if one can be located. - :rtype: :class:`pythonfinder.models.path.PathEntry` """ minor = int(minor) if minor is not None else minor patch = int(patch) if patch is not None else patch @@ -185,13 +184,13 @@ def find_python_version( def find_all_python_versions( self, - major=None, # type: Optional[Union[str, int]] - minor=None, # type: Optional[int] - patch=None, # type: Optional[int] - pre=None, # type: Optional[bool] - dev=None, # type: Optional[bool] - arch=None, # type: Optional[str] - name=None, # type: Optional[str] + major: Optional[Union[str, int]] = None, + minor: Optional[int] = None, + patch: Optional[int] = None, + pre: Optional[bool] = None, + dev: Optional[bool] = None, + arch: Optional[str] = None, + name: Optional[str] = None, ) -> List[PathEntry]: version_sort = operator.attrgetter("as_python.version_sort") python_version_dict = getattr(self.system_path, "python_version_dict", {}) diff --git a/src/pythonfinder/utils.py b/src/pythonfinder/utils.py index 613ef69..9027d49 100644 --- a/src/pythonfinder/utils.py +++ b/src/pythonfinder/utils.py @@ -71,8 +71,7 @@ ) -def get_python_version(path): - # type: (str) -> str +def get_python_version(path) -> str: """Get python version string using subprocess from a given path.""" version_cmd = [ path, @@ -101,8 +100,7 @@ def get_python_version(path): return out.strip() -def parse_python_version(version_str): - # type: (str) -> Dict[str, Union[str, int, Version]] +def parse_python_version(version_str: str) -> Dict[str, Union[str, int, Version]] from packaging.version import parse as parse_version is_debug = False @@ -112,7 +110,7 @@ def parse_python_version(version_str): match = version_re.match(version_str) if not match: raise InvalidPythonVersion("%s is not a python version" % version_str) - version_dict = match.groupdict() # type: Dict[str, str] + version_dict = match.groupdict() major = int(version_dict.get("major", 0)) if version_dict.get("major") else None minor = int(version_dict.get("minor", 0)) if version_dict.get("minor") else None patch = int(version_dict.get("patch", 0)) if version_dict.get("patch") else None @@ -122,8 +120,6 @@ def parse_python_version(version_str): if patch: patch = int(patch) - version = None # type: Optional[Version] - try: version = parse_version(version_str) except (TypeError, InvalidVersion): @@ -152,28 +148,23 @@ def parse_python_version(version_str): } -def path_is_executable(path): - # type: (str) -> bool +def path_is_executable(path) -> bool: """ Determine whether the supplied path is executable. :return: Whether the provided path is executable. - :rtype: bool """ return os.access(str(path), os.X_OK) -def path_is_known_executable(path): - # type: (Path) -> bool +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 """ return ( @@ -183,14 +174,12 @@ def path_is_known_executable(path): ) -def looks_like_python(name): - # type: (str) -> bool +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): @@ -201,27 +190,23 @@ def looks_like_python(name): return False -def path_is_python(path): - # type: (Path) -> bool +def path_is_python(path: Path) -> bool: """ Determine whether the supplied path is 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 path_is_executable(path) and looks_like_python(path.name) -def guess_company(path): - # type: (str) -> Optional[str] +def guess_company(path: str) -> Optional[str]: """Given a path to python, guess the company who created it :param str path: The path to guess about :return: The guessed company - :rtype: Optional[str] """ non_core_pythons = [impl for impl in PYTHON_IMPLEMENTATIONS if impl != "python"] return next( @@ -229,8 +214,7 @@ def guess_company(path): ) -def path_is_pythoncore(path): - # type: (str) -> bool +def path_is_pythoncore(path: str) -> bool: """Given a path, determine whether it appears to be pythoncore. Does not verify whether the path is in fact a path to python, but simply @@ -239,7 +223,6 @@ def path_is_pythoncore(path): :param str path: The path to check :return: Whether that path is a PythonCore path or not - :rtype: bool """ company = guess_company(path) if company: @@ -247,15 +230,13 @@ def path_is_pythoncore(path): return False -def ensure_path(path): - # type: (Union[Path, str]) -> Path +def ensure_path(path: Union[Path, str]) -> Path: """ Given a path (either a string or a Path object), expand variables and return a Path object. :param path: A string or a :class:`~pathlib.Path` object. :type path: str or :class:`~pathlib.Path` :return: A fully expanded Path object. - :rtype: :class:`~pathlib.Path` """ if isinstance(path, Path): @@ -264,16 +245,13 @@ def ensure_path(path): return path.absolute() -def _filter_none(k, v): - # type: (Any, Any) -> bool +def _filter_none(k, v) -> bool: if v: return True return False -# TODO: Reimplement in vistir -def normalize_path(path): - # type: (str) -> str +def normalize_path(path: str) -> str: return os.path.normpath( os.path.normcase( os.path.abspath(os.path.expandvars(os.path.expanduser(str(path)))) @@ -281,8 +259,7 @@ def normalize_path(path): ) -def filter_pythons(path): - # type: (Union[str, Path]) -> Iterable +def filter_pythons(path: Union[str, Path]) -> Union[Iterable, Path]: """Return all valid pythons in a given path""" if not isinstance(path, Path): path = Path(str(path)) @@ -291,10 +268,7 @@ def filter_pythons(path): return filter(path_is_python, path.iterdir()) -# TODO: Port to vistir -def unnest(item): - # type: (Any) -> Iterable[Any] - target = None # type: Optional[Iterable] +def unnest(item) -> Iterable[Any]: if isinstance(item, Iterable) and not isinstance(item, str): item, target = itertools.tee(item, 2) else: @@ -311,8 +285,7 @@ def unnest(item): yield target -def parse_pyenv_version_order(filename="version"): - # type: (str) -> List[str] +def parse_pyenv_version_order(filename="version") -> List[str]: version_order_file = normalize_path(os.path.join(PYENV_ROOT, filename)) if os.path.exists(version_order_file) and os.path.isfile(version_order_file): with io.open(version_order_file, encoding="utf-8") as fh: @@ -322,8 +295,7 @@ def parse_pyenv_version_order(filename="version"): return [] -def parse_asdf_version_order(filename=".tool-versions"): - # type: (str) -> List[str] +def parse_asdf_version_order(filename: str=".tool-versions") -> List[str]: version_order_file = normalize_path(os.path.join("~", filename)) if os.path.exists(version_order_file) and os.path.isfile(version_order_file): with io.open(version_order_file, encoding="utf-8") as fh: @@ -380,7 +352,6 @@ def expand_paths(path, only_python=True) -> Iterator: :param Union[Sequence, PathEntry] path: The path or list of paths to expand :param bool only_python: Whether to filter to include only python paths, default True :returns: An iterator over the expanded set of path entries - :rtype: Iterator[PathEntry] """ if path is not None and ( @@ -408,8 +379,7 @@ def expand_paths(path, only_python=True) -> Iterator: yield path -def dedup(iterable): - # type: (Iterable) -> Iterable +def dedup(iterable: Iterable) -> Iterable: """Deduplicate an iterable object like iter(set(iterable)) but order-reserved. """ From ff866d31ba13ae5cdbbf79a4cf787c6bbe957c6b Mon Sep 17 00:00:00 2001 From: Matt Davis Date: Sun, 30 Apr 2023 10:37:17 -0400 Subject: [PATCH 24/25] PR feedback --- src/pythonfinder/models/path.py | 65 ++++++++++++------------ src/pythonfinder/models/python.py | 82 ++++++++++++++----------------- 2 files changed, 68 insertions(+), 79 deletions(-) diff --git a/src/pythonfinder/models/path.py b/src/pythonfinder/models/path.py index 591c7bb..8c72de2 100644 --- a/src/pythonfinder/models/path.py +++ b/src/pythonfinder/models/path.py @@ -5,7 +5,7 @@ import sys from collections import defaultdict, ChainMap from itertools import chain -from typing import Any, Dict, List, Generator, Iterator, Optional, Tuple, Union +from typing import Any, Dict, DefaultDict, List, Generator, Iterator, Optional, Tuple, Union from cached_property import cached_property from pydantic import Field, root_validator @@ -44,7 +44,7 @@ def exists_and_is_accessible(path): class SystemPath(FinderBaseModel): global_search: bool = True paths: Dict[str, Union[PythonFinder, PathEntry]] = Field(default_factory=lambda: defaultdict(PathEntry)) - executables: List[PathEntry] = Field(default_factory=lambda: list()) + executables_tracking: List[PathEntry] = Field(default_factory=lambda: list()) python_executables_tracking: Dict[str, PathEntry] = Field(default_factory=lambda: dict()) path_order: List[str] = Field(default_factory=lambda: list()) python_version_dict: Dict[Tuple, Any] = Field(default_factory=lambda: defaultdict(list)) @@ -110,16 +110,17 @@ def check_for_asdf(): @property def executables(self) -> List[PathEntry]: - self.executables = [ + if self.executables_tracking: + return self.executables_tracking + self.executables_tracking = [ p for p in chain(*(child.children_ref.values() for child in self.paths.values())) if p.is_executable ] - return self.executables + return self.executables_tracking @cached_property - def python_executables(self): - # type: () -> Dict[str, PathEntry] + def python_executables(self) -> Dict[str, PathEntry]: python_executables = {} for child in self.paths.values(): if child.pythons: @@ -131,17 +132,16 @@ def python_executables(self): return self.python_executables_tracking @cached_property - def version_dict(self): - # type: () -> DefaultDict[Tuple, List[PathEntry]] + def version_dict(self) -> DefaultDict[Tuple, List[PathEntry]]: self.version_dict_tracking = defaultdict( list - ) # type: DefaultDict[Tuple, List[PathEntry]] + ) for finder_name, finder in self.finders_dict.items(): for version, entry in finder.versions.items(): if entry not in self.version_dict_tracking[version] and entry.is_python: self.version_dict_tracking[version].append(entry) for _, entry in self.python_executables.items(): - version = entry.as_python # type: PythonVersion + version = entry.as_python if not version: continue if not isinstance(version, tuple): @@ -385,13 +385,13 @@ def version_sort_key(entry): def find_all_python_versions( self, - major=None, # type: Optional[Union[str, int]] - minor=None, # type: Optional[int] - patch=None, # type: Optional[int] - pre=None, # type: Optional[bool] - dev=None, # type: Optional[bool] - arch=None, # type: Optional[str] - name=None, # type: Optional[str] + major: Optional[Union[str, int]] = None, + minor: Optional[int] = None, + patch: Optional[int] = None, + pre: Optional[bool] = None, + dev: Optional[bool] = None, + arch: Optional[str] = None, + name: Optional[str] = None, ) -> List["PathEntry"]: def sub_finder(obj): @@ -410,15 +410,15 @@ def alternate_sub_finder(obj): def find_python_version( self, - major=None, # type: Optional[Union[str, int]] - minor=None, # type: Optional[Union[str, int]] - patch=None, # type: Optional[Union[str, int]] - pre=None, # type: Optional[bool] - dev=None, # type: Optional[bool] - arch=None, # type: Optional[str] - name=None, # type: Optional[str] - sort_by_path=False, # type: bool - ) -> PathEntry: + major: Optional[Union[str, int]] = None, + minor: Optional[Union[str, int]] = None, + patch: Optional[Union[str, int]] = None, + pre: Optional[bool] = None, + dev: Optional[bool] = None, + arch: Optional[str] = None, + name: Optional[str] = None, + sort_by_path: bool = False, + ) -> "PathEntry": def sub_finder(obj): return obj.find_python_version(major, minor, patch, pre, dev, arch, name) @@ -458,11 +458,11 @@ def alternate_sub_finder(obj): @classmethod def create( cls, - path=None, # type: str - system=False, # type: bool - only_python=False, # type: bool - global_search=True, # type: bool - ignore_unsupported=True, # type: bool + path: Optional[str] = None, + system: bool = False, + only_python: bool = False, + global_search: bool = True, + ignore_unsupported: bool = True, ) -> "SystemPath": """Create a new :class:`pythonfinder.models.SystemPath` instance. @@ -472,7 +472,6 @@ def create( :param bool only_python: Whether to search only for python executables, defaults to False :param bool ignore_unsupported: Whether to ignore unsupported python versions, if False, an error is raised, defaults to True :return: A new :class:`pythonfinder.models.SystemPath` instance. - :rtype: :class:`pythonfinder.models.SystemPath` """ path_entries = defaultdict(PathEntry) @@ -482,7 +481,7 @@ def create( if global_search: if "PATH" in os.environ: paths = os.environ["PATH"].split(os.pathsep) - path_order = [] # type: List[str] + path_order = [] if path: path_order = [path] path_instance = ensure_path(path) diff --git a/src/pythonfinder/models/python.py b/src/pythonfinder/models/python.py index 6800cd8..dc6332e 100644 --- a/src/pythonfinder/models/python.py +++ b/src/pythonfinder/models/python.py @@ -4,7 +4,7 @@ import sys from collections import defaultdict from pathlib import Path, WindowsPath -from typing import Callable, DefaultDict, Dict, List, Optional, Tuple, Union, Generator, Iterator +from typing import Any, Callable, DefaultDict, Dict, List, Optional, Tuple, Union, Generator, Iterator from packaging.version import Version from pydantic import Field, validator @@ -57,29 +57,25 @@ class Config: # keep_untouched = (cached_property,) @property - def version_paths(self): - # type: () -> Any + def version_paths(self) -> Any: return self._versions.values() @property - def is_pyenv(self): - # type: () -> bool + def is_pyenv(self) -> bool: return is_in_path(str(self.root), PYENV_ROOT) @property - def is_asdf(self): - # type: () -> bool + def is_asdf(self) -> bool: return is_in_path(str(self.root), ASDF_DATA_DIR) - def get_version_order(self): - # type: () -> List[Path] + def get_version_order(self) -> List[Path]: version_paths = [ p for p in self.root.glob(self.version_glob_path) if not (p.parent.name == "envs" or p.name == "envs") ] versions = {v.name: v for v in version_paths} - version_order = [] # type: List[Path] + version_order = [] if self.is_pyenv: version_order = [ versions[v] for v in parse_pyenv_version_order() if v in versions @@ -97,8 +93,7 @@ def get_version_order(self): version_order = version_paths return version_order - def get_bin_dir(self, base): - # type: (Union[Path, str]) -> Path + def get_bin_dir(self, base) -> Path: if isinstance(base, str): base = Path(base) if os.name == "nt": @@ -180,13 +175,13 @@ def get_paths(cls, v) -> List[PathEntry]: return _paths @property - def pythons(self) -> DefaultDict[str, PathEntry]: + def pythons(self) -> Dict: if not self.pythons_ref: from .path import PathEntry - self.pythons_ref = defaultdict(PathEntry) # type: DefaultDict[str, PathEntry] + self.pythons_ref = defaultdict(PathEntry) for python in self._iter_pythons(): - python_path = python.path.as_posix() # type: ignore + python_path = python.path.as_posix() self.pythons_ref[python_path] = python return self.pythons_ref @@ -205,20 +200,20 @@ def create(cls, root, sort_function, version_glob_path=None, ignore_unsupported= return cls( root=root, path=root, - ignore_unsupported=ignore_unsupported, # type: ignore + ignore_unsupported=ignore_unsupported, sort_function=sort_function, version_glob_path=version_glob_path, ) def find_all_python_versions( self, - major=None, # type: Optional[Union[str, int]] - minor=None, # type: Optional[int] - patch=None, # type: Optional[int] - pre=None, # type: Optional[bool] - dev=None, # type: Optional[bool] - arch=None, # type: Optional[str] - name=None, # type: Optional[str] + major: Optional[Union[str, int]] = None, + minor: Optional[int] = None, + patch: Optional[int] = None, + pre: Optional[bool] = None, + dev: Optional[bool] = None, + arch: Optional[str] = None, + name: Optional[str] = None, ) -> List[PathEntry]: """Search for a specific python version on the path. Return all copies @@ -231,7 +226,6 @@ def find_all_python_versions( :param str arch: Architecture to include, e.g. '64bit', defaults to None :param str name: The name of a python version, e.g. ``anaconda3-5.3.0`` :return: A list of :class:`~pythonfinder.models.PathEntry` instances matching the version requested. - :rtype: List[:class:`~pythonfinder.models.PathEntry`] """ call_method = "find_all_python_versions" if self.is_dir else "find_python_version" @@ -259,13 +253,13 @@ def version_sort(py): def find_python_version( self, - major=None, # type: Optional[Union[str, int]] - minor=None, # type: Optional[int] - patch=None, # type: Optional[int] - pre=None, # type: Optional[bool] - dev=None, # type: Optional[bool] - arch=None, # type: Optional[str] - name=None, # type: Optional[str] + major: Optional[Union[str, int]] = None, + minor: Optional[int] = None, + patch: Optional[int] = None, + pre: Optional[bool] = None, + dev: Optional[bool] = None, + arch: Optional[str] = None, + name: Optional[str] = None, ) -> Optional[PathEntry]: """Search or self for the specified Python version and return the first match. @@ -335,7 +329,7 @@ class Config: def __getattribute__(self, key): result = super(PythonVersion, self).__getattribute__(key) if key in ["minor", "patch"] and result is None: - executable = None # type: Optional[str] + executable = None if self.executable: executable = self.executable elif self.comes_from: @@ -390,7 +384,6 @@ def version_tuple(self) -> Tuple[int, int, int, bool, bool, bool]: Provides a version tuple for using as a dictionary key. :return: A tuple describing the python version meetadata contained. - :rtype: tuple """ return ( @@ -404,14 +397,14 @@ def version_tuple(self) -> Tuple[int, int, int, bool, bool, bool]: def matches( self, - major=None, # type: Optional[int] - minor=None, # type: Optional[int] - patch=None, # type: Optional[int] - pre=False, # type: bool - dev=False, # type: bool - arch=None, # type: Optional[str] - debug=False, # type: bool - python_name=None, # type: Optional[str] + major: Optional[int] = None, + minor: Optional[int] = None, + patch: Optional[int] = None, + pre: bool = False, + dev: bool = False, + arch: Optional[str] = None, + debug: bool = False, + python_name: Optional[str] = None, ) -> bool: result = False if arch: @@ -486,7 +479,6 @@ def parse(cls, version) -> Dict[str, Union[str, int, Version]]: :param str version: A valid version string :return: A dictionary with metadata about the specified python version. - :rtype: dict """ if version is None: @@ -523,7 +515,6 @@ def from_path(cls, path, name=None, ignore_unsupported=True, company=None) -> "P :param bool ignore_unsupported: Whether to ignore or error on unsupported paths. :param Optional[str] company: The company or vendor packaging the distribution. :return: An instance of a PythonVersion. - :rtype: :class:`~pythonfinder.models.python.PythonVersion` """ from ..environment import IGNORE_UNSUPPORTED @@ -554,7 +545,7 @@ def from_path(cls, path, name=None, ignore_unsupported=True, company=None) -> "P instance_dict.update( {"comes_from": path, "name": name, "executable": path.path.as_posix()} ) - return cls(**instance_dict) # type: ignore + return cls(**instance_dict) @classmethod def parse_executable(cls, path) -> Dict[str, Optional[Union[str, int, Version]]]: @@ -583,7 +574,6 @@ def from_windows_launcher(cls, launcher_entry, name=None, company=None) -> "Pyth :param Optional[str] name: The name of the distribution. :param Optional[str] company: The name of the distributing company. :return: An instance of a PythonVersion. - :rtype: :class:`~pythonfinder.models.python.PythonVersion` """ creation_dict = cls.parse(launcher_entry.info.version) base_path = ensure_path(launcher_entry.info.install_path.__getattr__("")) @@ -643,7 +633,7 @@ def merge(self, target) -> None: else: current_entries = { p.path - for p in self.versions[version] # type: ignore + for p in self.versions[version] if version in self.versions } new_entries = {p.path for p in entries} From 6598298c37f14d1f626f8d3810c8e9b7886aa4de Mon Sep 17 00:00:00 2001 From: Matt Davis Date: Sun, 30 Apr 2023 10:38:22 -0400 Subject: [PATCH 25/25] PR feedback --- src/pythonfinder/utils.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/pythonfinder/utils.py b/src/pythonfinder/utils.py index 9027d49..b85cd57 100644 --- a/src/pythonfinder/utils.py +++ b/src/pythonfinder/utils.py @@ -10,7 +10,7 @@ from threading import Timer from pathlib import Path from builtins import TimeoutError -from typing import Any, Dict, Iterator, List, Optional, Tuple, Union +from typing import Any, Dict, Iterator, List, Optional, Union from packaging.version import Version, InvalidVersion @@ -100,7 +100,7 @@ def get_python_version(path) -> str: return out.strip() -def parse_python_version(version_str: str) -> Dict[str, Union[str, int, Version]] +def parse_python_version(version_str: str) -> Dict[str, Union[str, int, Version]]: from packaging.version import parse as parse_version is_debug = False