diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5aaf76d..9258df6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,7 +17,7 @@ jobs: os: [ubuntu-latest, macOS-latest, windows-latest] steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 with: submodules: true - uses: actions/setup-python@v4 diff --git a/news/157.bugfix.rst b/news/157.bugfix.rst new file mode 100644 index 0000000..9ec5fa1 --- /dev/null +++ b/news/157.bugfix.rst @@ -0,0 +1 @@ +* Convert away from pydantic to reduce complexity; simplify the path manipulation logics to use pathlib. diff --git a/pyproject.toml b/pyproject.toml index a4dacb0..897a582 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,7 +35,6 @@ dynamic = ["version"] requires-python = ">=3.8" dependencies = [ "packaging>=22.0", - "pydantic>=1.10.7,<2", ] [project.optional-dependencies] @@ -136,7 +135,6 @@ unfixable = [ [tool.ruff.flake8-type-checking] runtime-evaluated-base-classes = [ - "pydantic.BaseModel", "pythonfinder.models.common.FinderBaseModel", "pythonfinder.models.mixins.PathEntry", ] diff --git a/src/pythonfinder/environment.py b/src/pythonfinder/environment.py index ab51949..ba5d7fc 100644 --- a/src/pythonfinder/environment.py +++ b/src/pythonfinder/environment.py @@ -2,34 +2,14 @@ import os import platform -import re import shutil import sys - - -def possibly_convert_to_windows_style_path(path): - if not isinstance(path, str): - path = str(path) - # Check if the path is in Unix-style (Git Bash) - if os.name != "nt": - return path - if os.path.exists(path): - return path - match = re.match(r"[/\\]([a-zA-Z])[/\\](.*)", path) - if match is None: - return path - drive, rest_of_path = match.groups() - rest_of_path = rest_of_path.replace("/", "\\") - revised_path = f"{drive.upper()}:\\{rest_of_path}" - if os.path.exists(revised_path): - return revised_path - return path - +from pathlib import Path PYENV_ROOT = os.path.expanduser( os.path.expandvars(os.environ.get("PYENV_ROOT", "~/.pyenv")) ) -PYENV_ROOT = possibly_convert_to_windows_style_path(PYENV_ROOT) +PYENV_ROOT = Path(PYENV_ROOT) PYENV_INSTALLED = shutil.which("pyenv") is not None ASDF_DATA_DIR = os.path.expanduser( os.path.expandvars(os.environ.get("ASDF_DATA_DIR", "~/.asdf")) diff --git a/src/pythonfinder/models/common.py b/src/pythonfinder/models/common.py deleted file mode 100644 index 4c439c9..0000000 --- a/src/pythonfinder/models/common.py +++ /dev/null @@ -1,26 +0,0 @@ -from __future__ import annotations - -from pydantic import BaseModel, Extra - - -class FinderBaseModel(BaseModel): - def __setattr__(self, name, value): - 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 diff --git a/src/pythonfinder/models/mixins.py b/src/pythonfinder/models/mixins.py index 58ce99a..f61d057 100644 --- a/src/pythonfinder/models/mixins.py +++ b/src/pythonfinder/models/mixins.py @@ -1,19 +1,16 @@ from __future__ import annotations +import dataclasses import os from collections import defaultdict -from pathlib import Path +from dataclasses import field from typing import ( TYPE_CHECKING, Any, - Dict, Generator, Iterator, - Optional, ) -from pydantic import BaseModel, Field, validator - from ..exceptions import InvalidPythonVersion from ..utils import ( KNOWN_EXTS, @@ -25,51 +22,47 @@ ) if TYPE_CHECKING: - from pythonfinder.models.python import PythonVersion - - -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() + from pathlib import Path + + from .python import PythonVersion + + +@dataclasses.dataclass(unsafe_hash=True) +class PathEntry: + is_root: bool = False + name: str | None = None + path: Path | None = None + children_ref: dict[str, Any] = field(default_factory=dict) + only_python: bool | None = False + py_version_ref: Any | None = None + pythons_ref: dict[str, Any] | None = field( + default_factory=lambda: defaultdict(lambda: None) + ) + is_dir_ref: bool | None = None + is_executable_ref: bool | None = None + is_python_ref: bool | None = None + + def __post_init__(self): + if not self.children_ref: + self._gen_children() def __str__(self) -> str: - return f"{self.path.as_posix()}" + return f"{self.path}" def __lt__(self, other) -> bool: - return self.path.as_posix() < other.path.as_posix() + return self.path < other.path def __lte__(self, other) -> bool: - return self.path.as_posix() <= other.path.as_posix() + return self.path <= other.path def __gt__(self, other) -> bool: - return self.path.as_posix() > other.path.as_posix() + return self.path > other.path def __gte__(self, other) -> bool: - return self.path.as_posix() >= other.path.as_posix() + return self.path >= other.path def __eq__(self, other) -> bool: - return self.path.as_posix() == other.path.as_posix() + return self.path == other.path def which(self, name) -> PathEntry | None: """Search in this path for an executable. @@ -87,9 +80,9 @@ def which(self, name) -> PathEntry | None: if self.path is not None: found = next( ( - children[(self.path / child).as_posix()] + children[(self.path / child)] for child in valid_names - if (self.path / child).as_posix() in children + if (self.path / child) in children ), None, ) @@ -210,7 +203,7 @@ def pythons(self) -> dict[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() + python_path = python.path self.pythons_ref[python_path] = python return self.pythons_ref @@ -295,17 +288,10 @@ def version_matcher(py_version): if self.is_python and self.as_python and version_matcher(self.py_version): return self - matching_pythons = [ - [entry, entry.as_python.version_sort] - for entry in self._iter_pythons() - if ( - entry is not None - and entry.as_python is not None - and version_matcher(entry.py_version) - ) - ] - 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) + for entry in self._iter_pythons(): + if entry is not None and entry.as_python is not None: + if version_matcher(entry.as_python): + return entry def _filter_children(self) -> Iterator[Path]: if not os.access(str(self.path), os.R_OK): @@ -316,39 +302,26 @@ def _filter_children(self) -> Iterator[Path]: children = self.path.iterdir() return children - def _gen_children(self) -> Iterator: - 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 - elif self.path is not None and isinstance(self.path.name, str): - pass_args["name"] = self.path.name - - if not self.is_dir: - yield (self.path.as_posix(), self) - elif self.is_root: - for child in self._filter_children(): - if self.only_python: - try: - entry = PathEntry.create(path=child, **pass_args) - except (InvalidPythonVersion, ValueError): - continue - else: - try: - entry = PathEntry.create(path=child, **pass_args) - except (InvalidPythonVersion, ValueError): - continue - yield (child.as_posix(), entry) - return + def _gen_children(self): + if self.is_dir and self.is_root and self.path is not None: + # Assuming _filter_children returns an iterator over child paths + for child_path in self._filter_children(): + 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 + elif self.path is not None and isinstance(self.path.name, str): + pass_args["name"] = self.path.name + + try: + entry = PathEntry.create(path=child_path, **pass_args) + self.children_ref[child_path] = entry + except (InvalidPythonVersion, ValueError): + continue # Or handle as needed @property 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 - self.children_ref = children return self.children_ref @classmethod @@ -360,7 +333,7 @@ def create( pythons: dict[str, PythonVersion] | None = None, name: str | None = None, ) -> PathEntry: - """Helper method for creating new :class:`pythonfinder.models.PathEntry` instances. + """Helper method for creating new :class:`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 @@ -390,7 +363,7 @@ def create( child_creation_args["name"] = _new.name for pth, python in pythons.items(): pth = ensure_path(pth) - children[pth.as_posix()] = PathEntry( + children[str(path)] = 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 107c9e6..572dec7 100644 --- a/src/pythonfinder/models/path.py +++ b/src/pythonfinder/models/path.py @@ -1,27 +1,22 @@ from __future__ import annotations +import dataclasses import errno import operator import os import sys -from collections import ChainMap, defaultdict +from collections import defaultdict +from dataclasses import field from functools import cached_property from itertools import chain from pathlib import Path from typing import ( Any, DefaultDict, - Dict, Generator, Iterator, - List, - Optional, - Tuple, - Union, ) -from pydantic import Field, root_validator - from ..environment import ( ASDF_DATA_DIR, ASDF_INSTALLED, @@ -32,12 +27,10 @@ dedup, ensure_path, is_in_path, - normalize_path, parse_asdf_version_order, parse_pyenv_version_order, - split_version_and_name, + resolve_path, ) -from .common import FinderBaseModel from .mixins import PathEntry from .python import PythonFinder @@ -52,38 +45,32 @@ def exists_and_is_accessible(path): raise -class SystemPath(FinderBaseModel): +@dataclasses.dataclass(unsafe_hash=True) +class SystemPath: global_search: bool = True - paths: Dict[str, Union[PythonFinder, PathEntry]] = Field( + paths: dict[str, PythonFinder | PathEntry] = field( default_factory=lambda: defaultdict(PathEntry) ) - executables_tracking: List[PathEntry] = Field(default_factory=lambda: list()) - python_executables_tracking: Dict[str, PathEntry] = Field( - default_factory=lambda: dict() + executables_tracking: list[PathEntry] = field(default_factory=list) + python_executables_tracking: dict[str, PathEntry] = field( + default_factory=dict, init=False ) - path_order: List[str] = Field(default_factory=lambda: list()) - python_version_dict: Dict[Tuple, Any] = Field( + path_order: list[str] = field(default_factory=list) + python_version_dict: dict[tuple, Any] = field( default_factory=lambda: defaultdict(list) ) - version_dict_tracking: Dict[Tuple, List[PathEntry]] = Field( + 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 + pyenv_finder: PythonFinder | None = None + asdf_finder: PythonFinder | None = 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,) + finders_dict: dict[str, PythonFinder] = field(default_factory=dict) - def __init__(self, **data): - super().__init__(**data) + def __post_init__(self): + # Initialize python_executables_tracking python_executables = {} for child in self.paths.values(): if child.pythons: @@ -93,24 +80,20 @@ def __init__(self, **data): 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) - 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 + self.python_version_dict = defaultdict(list) + self.pyenv_finder = self.pyenv_finder or None + self.asdf_finder = self.asdf_finder or None + self.path_order = [str(p) for p in self.path_order] or [] + self.finders_dict = self.finders_dict or {} + + # The part with 'paths' seems to be setting up 'executables' + if self.paths: + self.executables_tracking = [ + child + for path_entry in self.paths.values() + for child in path_entry.children_ref.values() + if child.is_executable ] - return values def _register_finder(self, finder_name, finder): if finder_name not in self.finders_dict: @@ -123,11 +106,11 @@ def finders(self) -> list[str]: @staticmethod def check_for_pyenv(): - return PYENV_INSTALLED or os.path.exists(normalize_path(PYENV_ROOT)) + return PYENV_INSTALLED or os.path.exists(resolve_path(PYENV_ROOT)) @staticmethod def check_for_asdf(): - return ASDF_INSTALLED or os.path.exists(normalize_path(ASDF_DATA_DIR)) + return ASDF_INSTALLED or os.path.exists(resolve_path(ASDF_DATA_DIR)) @property def executables(self) -> list[PathEntry]: @@ -171,57 +154,59 @@ def version_dict(self) -> DefaultDict[tuple, list[PathEntry]]: self.version_dict_tracking[version].append(entry) return self.version_dict_tracking + def _handle_virtualenv_and_system_paths(self): + venv = os.environ.get("VIRTUAL_ENV") + bin_dir = "Scripts" if os.name == "nt" else "bin" + if venv: + venv_path = Path(venv).resolve() + venv_bin_path = venv_path / bin_dir + if venv_bin_path.exists() and (self.system or self.global_search): + self.path_order = [str(venv_bin_path), *self.path_order] + self.paths[str(venv_bin_path)] = self.get_path(venv_bin_path) + + if self.system: + syspath_bin = Path(sys.executable).resolve().parent + if (syspath_bin / bin_dir).exists(): + syspath_bin = syspath_bin / bin_dir + if str(syspath_bin) not in self.path_order: + self.path_order = [str(syspath_bin), *self.path_order] + self.paths[str(syspath_bin)] = PathEntry.create( + path=syspath_bin, is_root=True, only_python=False + ) + 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 += os.environ["PATH"].split(os.pathsep) path_order = list(dedup(path_order)) - path_instances = [ensure_path(p.strip('"')) for p in path_order] + path_instances = [ + Path(p.strip('"')).resolve() + for p in path_order + if exists_and_is_accessible(Path(p.strip('"')).resolve()) + ] + + # Update paths with PathEntry objects self.paths.update( { - p.as_posix(): PathEntry.create( - path=p.absolute(), is_root=True, only_python=self.only_python + str(p): PathEntry.create( + path=p, is_root=True, only_python=self.only_python ) for p in path_instances - if exists_and_is_accessible(p) } ) - 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: - self._setup_pyenv() - #: slice in asdf - if self.check_for_asdf() and "asdf" not in self.finders: - self._setup_asdf() - venv = os.environ.get("VIRTUAL_ENV") - if venv: - venv = ensure_path(venv) - if os.name == "nt": - bin_dir = "Scripts" - else: - bin_dir = "bin" - if venv and (self.system or self.global_search): - path_order = [(venv / bin_dir).as_posix(), *self.path_order] - self.path_order = path_order - self.paths[venv] = self.get_path(venv.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(), *self.path_order] - self.paths[syspath_bin] = PathEntry.create( - path=syspath_bin, is_root=True, only_python=False - ) - self.path_order = path_order + + # Update path_order to use absolute paths + self.path_order = [str(p) for p in path_instances] + + # Handle virtual environment and system paths + self._handle_virtualenv_and_system_paths() + return self 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) + paths = [resolve_path(p) for p in reversed_paths] + normalized_target = resolve_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}") @@ -238,7 +223,7 @@ def _slice_in_paths(self, start_idx, paths) -> SystemPath: else: 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 + path_order = before_path + [str(p) for p in paths] + after_path self.path_order = path_order return self @@ -247,23 +232,23 @@ def _remove_shims(self): new_order = [] for current_path in path_copy: if not current_path.endswith("shims"): - normalized = normalize_path(current_path) + normalized = resolve_path(current_path) new_order.append(normalized) - new_order = [ensure_path(p).as_posix() for p in new_order] + new_order = [ensure_path(p) for p in new_order] self.path_order = new_order 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()} + target = resolve_path(path) + path_map = {resolve_path(pth): pth for pth in self.paths.keys()} if target in path_map: del self.paths[path_map[target]] for current_path in path_copy: - normalized = normalize_path(current_path) + normalized = resolve_path(current_path) if normalized != target: new_order.append(normalized) - new_order = [ensure_path(p).as_posix() for p in reversed(new_order)] + new_order = [str(p) for p in reversed(new_order)] self.path_order = new_order return self @@ -272,28 +257,31 @@ def _setup_asdf(self) -> SystemPath: return self os_path = os.environ["PATH"].split(os.pathsep) + asdf_data_dir = Path(ASDF_DATA_DIR) asdf_finder = PythonFinder.create( - root=ASDF_DATA_DIR, + root=asdf_data_dir, ignore_unsupported=True, sort_function=parse_asdf_version_order, version_glob_path="installs/python/*", ) asdf_index = None try: - asdf_index = self._get_last_instance(ASDF_DATA_DIR) + asdf_index = self._get_last_instance(asdf_data_dir) except ValueError: - asdf_index = 0 if is_in_path(next(iter(os_path), ""), ASDF_DATA_DIR) else -1 + asdf_index = 0 if is_in_path(next(iter(os_path), ""), asdf_data_dir) else -1 if asdf_index is None: # we are in a virtualenv without global pyenv on the path, so we should # not write pyenv to the path here return self # * These are the root paths for the finder _ = [p for p in asdf_finder.roots] - self._slice_in_paths(asdf_index, [asdf_finder.root]) - self.paths[asdf_finder.root] = asdf_finder - self.paths.update(asdf_finder.roots) + self._slice_in_paths(asdf_index, [str(asdf_finder.root)]) + self.paths[str(asdf_finder.root)] = asdf_finder + self.paths.update( + {str(root): asdf_finder.roots[root] for root in asdf_finder.roots} + ) self.asdf_finder = asdf_finder - self._remove_path(normalize_path(os.path.join(ASDF_DATA_DIR, "shims"))) + self._remove_path(asdf_data_dir / "shims") self._register_finder("asdf", asdf_finder) return self @@ -302,26 +290,28 @@ def _setup_pyenv(self) -> SystemPath: return self os_path = os.environ["PATH"].split(os.pathsep) - + pyenv_root = Path(PYENV_ROOT) pyenv_finder = PythonFinder.create( - root=PYENV_ROOT, + root=pyenv_root, sort_function=parse_pyenv_version_order, version_glob_path="versions/*", ignore_unsupported=self.ignore_unsupported, ) try: - pyenv_index = self._get_last_instance(PYENV_ROOT) + pyenv_index = self._get_last_instance(pyenv_root) except ValueError: - pyenv_index = 0 if is_in_path(next(iter(os_path), ""), PYENV_ROOT) else -1 + pyenv_index = 0 if is_in_path(next(iter(os_path), ""), pyenv_root) else -1 if pyenv_index is None: # we are in a virtualenv without global pyenv on the path, so we should # not write pyenv to the path here return self # * These are the root paths for the finder _ = [p for p in pyenv_finder.roots] - self._slice_in_paths(pyenv_index, [pyenv_finder.root]) - self.paths[pyenv_finder.root] = pyenv_finder - self.paths.update(pyenv_finder.roots) + self._slice_in_paths(pyenv_index, [str(pyenv_finder.root)]) + self.paths[str(pyenv_finder.root)] = pyenv_finder + self.paths.update( + {str(root): pyenv_finder.roots[root] for root in pyenv_finder.roots} + ) self.pyenv_finder = pyenv_finder self._remove_shims() self._register_finder("pyenv", pyenv_finder) @@ -330,15 +320,15 @@ def _setup_pyenv(self) -> SystemPath: def get_path(self, path) -> PythonFinder | PathEntry: if path is None: raise TypeError("A path must be provided in order to generate a path entry.") - path = ensure_path(path) - _path = self.paths.get(path) + path_str = path if isinstance(path, str) else str(path.absolute()) + _path = self.paths.get(path_str) if not _path: - _path = self.paths.get(path.as_posix()) - if not _path and path.as_posix() in self.path_order and path.exists(): + _path = self.paths.get(path_str) + if not _path and path_str in self.path_order and path.exists(): _path = PathEntry.create( path=path.absolute(), is_root=True, only_python=self.only_python ) - self.paths[path.as_posix()] = _path + self.paths[path_str] = _path if not _path: raise ValueError(f"Path not found or generated: {path!r}") return _path @@ -385,17 +375,15 @@ def which(self, executable) -> PathEntry | None: def _filter_paths(self, finder) -> Iterator: for path in self._get_paths(): - if path is None: + if not path: continue - python_versions = finder(path) - if python_versions is not None: - for python in python_versions: - if python is not None: - yield python + python_version = finder(path) + if python_version: + yield python_version def _get_all_pythons(self, finder) -> Iterator: for python in self._filter_paths(finder): - if python is not None and python.is_python: + if python: yield python def get_pythons(self, finder) -> Iterator: @@ -451,35 +439,50 @@ def sub_finder(obj): 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) - 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 - if sort_by_path: - paths = [self.get_path(k) for k in self.path_order] - for path in paths: - found_version = sub_finder(path) - if found_version: - return found_version - if name and not (minor or patch or pre or dev or arch or major): - for path in paths: - found_version = alternate_sub_finder(path) - if found_version: - return found_version + found_version = self._find_version_by_path( + sub_finder, + alternate_sub_finder, + name, + minor, + patch, + pre, + dev, + arch, + major, + ) + if found_version: + return found_version ver = next(iter(self.get_pythons(sub_finder)), None) - if not ver and name and not (minor or patch or pre or dev or arch or major): + if not ver and name and not any([minor, patch, pre, dev, arch, major]): 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] + self._update_python_version_dict(ver) return ver + def _find_version_by_path(self, sub_finder, alternate_sub_finder, name, *args): + paths = [self.get_path(k) for k in self.path_order] + for path in paths: + found_version = sub_finder(path) + if found_version: + return found_version + if name and not any(args): + for path in paths: + found_version = alternate_sub_finder(path) + if found_version: + return found_version + return None + + def _update_python_version_dict(self, ver): + if ver: + version_key = ver.as_python.version_tuple[:5] + if version_key in self.python_version_dict: + self.python_version_dict[version_key].append(ver) + else: + self.python_version_dict[version_key] = [ver] + @classmethod def create( cls, @@ -506,24 +509,24 @@ def create( if global_search: if "PATH" in os.environ: paths = os.environ["PATH"].split(os.pathsep) - path_order = [] + path_order = [str(path)] if path: path_order = [path] path_instance = ensure_path(path) path_entries.update( { - path_instance.as_posix(): PathEntry.create( - path=path_instance.absolute(), + path_instance: PathEntry.create( + path=path_instance.resolve(), is_root=True, only_python=only_python, ) } ) paths = [path, *paths] - _path_objects = [ensure_path(p.strip('"')) for p in paths] + _path_objects = [ensure_path(p) for p in paths] path_entries.update( { - p.as_posix(): PathEntry.create( + str(p): PathEntry.create( path=p.absolute(), is_root=True, only_python=only_python ) for p in _path_objects diff --git a/src/pythonfinder/models/python.py b/src/pythonfinder/models/python.py index c5e0345..6a72d44 100644 --- a/src/pythonfinder/models/python.py +++ b/src/pythonfinder/models/python.py @@ -1,10 +1,13 @@ from __future__ import annotations +import dataclasses import logging import os import platform import sys from collections import defaultdict +from dataclasses import field +from functools import cached_property from pathlib import Path, WindowsPath from typing import ( Any, @@ -14,12 +17,9 @@ Iterator, List, Optional, - Tuple, - Union, ) from packaging.version import Version -from pydantic import Field, validator from ..environment import ASDF_DATA_DIR, PYENV_ROOT, SYSTEM_ARCH from ..exceptions import InvalidPythonVersion @@ -34,35 +34,25 @@ parse_pyenv_version_order, parse_python_version, ) -from .common import FinderBaseModel from .mixins import PathEntry logger = logging.getLogger(__name__) +@dataclasses.dataclass 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 + root: Path = field(default_factory=Path) ignore_unsupported: bool = True - #: Glob path for python versions off of the root directory version_glob_path: str = "versions/*" - #: The function to use to sort version order when returning an ordered version set sort_function: Optional[Callable] = None - #: The root locations used for discovery - roots: Dict = Field(default_factory=lambda: defaultdict()) - #: List of paths discovered during search - paths: List = Field(default_factory=lambda: list()) - #: Versions discovered in the specified paths - _versions: Dict = Field(default_factory=lambda: defaultdict()) - pythons_ref: Dict = Field(default_factory=lambda: defaultdict()) - - class Config: - validate_assignment = True - arbitrary_types_allowed = True - allow_mutation = True - include_private_attributes = True - # keep_untouched = (cached_property,) + roots: Dict = field(default_factory=lambda: defaultdict()) + paths: Optional[List[PathEntry]] = field(default_factory=list) + _versions: Dict = field(default_factory=lambda: defaultdict()) + pythons_ref: Dict = field(default_factory=lambda: defaultdict()) + + def __post_init__(self): + # Ensuring that paths are set correctly + self.paths = self.get_paths(self.paths) @property def version_paths(self) -> Any: @@ -157,7 +147,7 @@ def _iter_versions(self) -> Iterator[tuple[Path, PathEntry, tuple]]: ) yield (base_path, entry, version_tuple) - @property + @cached_property def versions(self) -> DefaultDict[tuple, PathEntry]: if not self._versions: for _, entry, version_tuple in self._iter_versions(): @@ -166,20 +156,21 @@ def versions(self) -> DefaultDict[tuple, PathEntry]: def _iter_pythons(self) -> Iterator: for path, entry, version_tuple in self._iter_versions(): - if path.as_posix() in self._pythons: - yield self._pythons[path.as_posix()] + if str(path) in self._pythons: + yield self._pythons[str(path)] elif version_tuple not in self.versions: for python in entry.find_all_python_versions(): yield python else: yield self.versions[version_tuple] - @validator("paths", pre=True, always=True) - def get_paths(cls, v) -> list[PathEntry]: - if v is not None: - return v + def get_paths(self, paths) -> list[PathEntry]: + # If paths are provided, use them + if paths is not None: + return paths - _paths = [base for _, base in cls._iter_version_bases()] + # Otherwise, generate paths using _iter_version_bases + _paths = [base for _, base in self._iter_version_bases()] return _paths @property @@ -189,7 +180,7 @@ def pythons(self) -> dict: self.pythons_ref = defaultdict(PathEntry) for python in self._iter_pythons(): - python_path = python.path.as_posix() + python_path = str(python.path) self.pythons_ref[python_path] = python return self.pythons_ref @@ -312,27 +303,21 @@ def which(self, name) -> PathEntry | None: return non_empty_match -class PythonVersion(FinderBaseModel): +@dataclasses.dataclass +class PythonVersion: major: int = 0 - minor: Optional[int] = None - patch: Optional[int] = None + minor: int | None = None + patch: int | None = 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,) + version: Version | None = None + architecture: str | None = None + comes_from: PathEntry | None = None + executable: str | WindowsPath | Path | None = None + company: str | None = None + name: str | None = None def __getattribute__(self, key): result = super().__getattribute__(key) @@ -341,10 +326,10 @@ def __getattribute__(self, key): if self.executable: executable = self.executable elif self.comes_from: - executable = self.comes_from.path.as_posix() + executable = self.comes_from.path if executable is not None: if not isinstance(executable, str): - executable = executable.as_posix() + executable = executable instance_dict = self.parse_executable(executable) for k in instance_dict.keys(): try: @@ -419,9 +404,11 @@ def matches( own_arch = self.get_architecture() if arch.isdigit(): arch = f"{arch}bit" + if ( (major is None or self.major == major) and (minor is None or self.minor == minor) + # Check if patch is None OR self.patch equals patch and (patch is None or self.patch == patch) and (pre is None or self.is_prerelease == pre) and (dev is None or self.is_devrelease == dev) @@ -434,6 +421,7 @@ def matches( ) ): result = True + return result def as_major(self) -> PythonVersion: @@ -501,7 +489,7 @@ def get_architecture(self) -> str: return self.architecture arch = None if self.comes_from is not None: - arch, _ = platform.architecture(self.comes_from.path.as_posix()) + arch, _ = platform.architecture(str(self.comes_from.path)) elif self.executable is not None: arch, _ = platform.architecture(self.executable) if arch is None: @@ -529,17 +517,31 @@ def from_path( from ..environment import IGNORE_UNSUPPORTED ignore_unsupported = ignore_unsupported or IGNORE_UNSUPPORTED - path_name = getattr(path, "name", path.path.name) # str - if not path.is_python: - if not (ignore_unsupported or IGNORE_UNSUPPORTED): - raise ValueError("Not a valid python path: %s" % path.path) + + # Check if path is a string, a PathEntry, or a PythonFinder object + if isinstance(path, str): + path_obj = Path(path) # Convert string to Path object + path_name = path_obj.name + path_str = path + elif hasattr(path, "path") and isinstance(path.path, Path): + path_obj = path.path + path_name = getattr(path, "name", path_obj.name) + path_str = str(path.path.absolute()) + elif isinstance(path, PythonFinder): # If path is a PythonFinder object + path_name = None + path_str = path.path + else: + raise ValueError( + f"Invalid path type: {type(path)}. Expected str, PathEntry, or PythonFinder." + ) + try: instance_dict = cls.parse(path_name) except Exception: - instance_dict = cls.parse_executable(path.path.absolute().as_posix()) + instance_dict = cls.parse_executable(path_str) else: - if instance_dict.get("minor") is None and looks_like_python(path.path.name): - instance_dict = cls.parse_executable(path.path.absolute().as_posix()) + if instance_dict.get("minor") is None and looks_like_python(path_obj.name): + instance_dict = cls.parse_executable(path_str) if ( not isinstance(instance_dict.get("version"), Version) @@ -547,14 +549,12 @@ def from_path( ): raise ValueError("Not a valid python path: %s" % path) if instance_dict.get("patch") is None: - instance_dict = cls.parse_executable(path.path.absolute().as_posix()) + instance_dict = cls.parse_executable(path_str) if name is None: name = path_name if company is None: - company = guess_company(path.path.as_posix()) - instance_dict.update( - {"comes_from": path, "name": name, "executable": path.path.as_posix()} - ) + company = guess_company(path_str) + instance_dict.update({"comes_from": path, "name": name, "executable": path_str}) return cls(**instance_dict) @classmethod @@ -564,7 +564,7 @@ def parse_executable(cls, path) -> dict[str, str | int | Version | None]: if path is None: raise TypeError("Must pass a valid path to parse.") if not isinstance(path, str): - path = path.as_posix() + path = str(path) # if not looks_like_python(path): # raise ValueError("Path %r does not look like a valid python path" % path) try: @@ -595,7 +595,7 @@ def from_windows_launcher( exe_path = ensure_path( getattr(launcher_entry.info.install_path, "executable_path", default_path) ) - company = getattr(launcher_entry, "company", guess_company(exe_path.as_posix())) + company = getattr(launcher_entry, "company", guess_company(exe_path)) creation_dict.update( { "architecture": getattr( @@ -620,17 +620,11 @@ def create(cls, **kwargs) -> PythonVersion: return cls(**kwargs) -class VersionMap(FinderBaseModel): +@dataclasses.dataclass +class VersionMap: 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,) + tuple[int, int | None, int | None, bool, bool, bool], list[PathEntry] + ] = field(default_factory=lambda: defaultdict(list)) def add_entry(self, entry) -> None: version = entry.as_python diff --git a/src/pythonfinder/pythonfinder.py b/src/pythonfinder/pythonfinder.py index 6477cdf..b9d723e 100644 --- a/src/pythonfinder/pythonfinder.py +++ b/src/pythonfinder/pythonfinder.py @@ -1,43 +1,34 @@ from __future__ import annotations +import dataclasses import operator -from collections.abc import Iterable -from typing import Any, Optional +from typing import Any, Iterable from .environment import set_asdf_paths, set_pyenv_paths from .exceptions import InvalidPythonVersion -from .models.common import FinderBaseModel from .models.path import PathEntry, SystemPath from .models.python import PythonVersion from .utils import version_re -class Finder(FinderBaseModel): - path_prepend: Optional[str] = None +@dataclasses.dataclass(unsafe_hash=True) +class Finder: + path: str | None = None system: bool = False global_search: bool = True ignore_unsupported: bool = True sort_by_path: bool = False - system_path: Optional[SystemPath] = None + system_path: SystemPath | None = dataclasses.field(default=None, init=False) - def __init__(self, **data) -> None: - super().__init__(**data) + def __post_init__(self): 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) -> bool: - return self.__hash__ == other.__hash__ - def create_system_path(self) -> SystemPath: + # Implementation of set_asdf_paths and set_pyenv_paths might need to be adapted. set_asdf_paths() set_pyenv_paths() return SystemPath.create( - path=self.path_prepend, + path=self.path, system=self.system, global_search=self.global_search, ignore_unsupported=self.ignore_unsupported, @@ -180,7 +171,7 @@ def find_python_version( dev=dev, arch=arch, name=name, - sort_by_path=self.sort_by_path, + sort_by_path=sort_by_path, ) def find_all_python_versions( @@ -213,11 +204,11 @@ def find_all_python_versions( filter(lambda v: v and v.as_python, versions), key=version_sort, reverse=True ) path_map = {} - for path in path_list: + for p in path_list: try: - resolved_path = path.path.resolve() - except OSError: - resolved_path = path.path.absolute() - if not path_map.get(resolved_path.as_posix()): - path_map[resolved_path.as_posix()] = path - return path_list + resolved_path = p.path.resolve(strict=True) + except (OSError, RuntimeError): + resolved_path = p.path.absolute() + if resolved_path not in path_map: + path_map[resolved_path] = p + return [path_map[p] for p in path_map] diff --git a/src/pythonfinder/utils.py b/src/pythonfinder/utils.py index 623fa5a..3871bb3 100644 --- a/src/pythonfinder/utils.py +++ b/src/pythonfinder/utils.py @@ -13,7 +13,7 @@ from packaging.version import InvalidVersion, Version -from .environment import PYENV_ROOT, possibly_convert_to_windows_style_path +from .environment import PYENV_ROOT from .exceptions import InvalidPythonVersion version_re_str = ( @@ -234,19 +234,27 @@ def ensure_path(path: Path | str) -> Path: :type path: str or :class:`~pathlib.Path` :return: A fully expanded Path object. """ - path = possibly_convert_to_windows_style_path(path) if isinstance(path, Path): - return path - path = Path(os.path.expandvars(path)) - return path.absolute() + return path.absolute() + # Expand environment variables and user tilde in the path + expanded_path = os.path.expandvars(os.path.expanduser(path)) + return Path(expanded_path).absolute() -def normalize_path(path: str) -> str: - return os.path.normpath( - os.path.normcase( - os.path.abspath(os.path.expandvars(os.path.expanduser(str(path)))) - ) - ) +def resolve_path(path: Path | str) -> Path: + """ + Resolves the path to an absolute path, expanding user variables and environment variables. + """ + # Convert to Path object if it's a string + if isinstance(path, str): + path = Path(path) + + # Expand user and variables + path = path.expanduser() + path = Path(os.path.expandvars(str(path))) + + # Resolve to absolute path + return path.resolve() def filter_pythons(path: str | Path) -> Iterable | Path: @@ -276,7 +284,7 @@ def unnest(item) -> Iterable[Any]: def parse_pyenv_version_order(filename="version") -> list[str]: - version_order_file = normalize_path(os.path.join(PYENV_ROOT, filename)) + version_order_file = resolve_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: contents = fh.read() @@ -286,7 +294,7 @@ def parse_pyenv_version_order(filename="version") -> list[str]: def parse_asdf_version_order(filename: str = ".tool-versions") -> list[str]: - version_order_file = normalize_path(os.path.join("~", filename)) + version_order_file = resolve_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: contents = fh.read() @@ -330,9 +338,8 @@ def split_version_and_name( return (major, minor, patch, name) -# TODO: Reimplement in vistir def is_in_path(path, parent): - return normalize_path(str(path)).startswith(normalize_path(str(parent))) + return resolve_path(str(path)).startswith(resolve_path(str(parent))) def expand_paths(path, only_python=True) -> Iterator: diff --git a/tests/test_environment.py b/tests/test_environment.py deleted file mode 100644 index 0c1656a..0000000 --- a/tests/test_environment.py +++ /dev/null @@ -1,28 +0,0 @@ -from __future__ import annotations - -import os -import re -import tempfile - -import pytest - -from pythonfinder.environment import possibly_convert_to_windows_style_path - - -@pytest.mark.skipif(os.name != "nt", reason="Only run on Windows") -def test_possibly_convert_to_windows_style_path(): - # Create a temporary directory - with tempfile.TemporaryDirectory() as tmpdirname: - # Get an input path in the form "\path\to\tempdir" - drive, tail = os.path.splitdrive(tmpdirname) - input_path = tail.replace("/", "\\") - assert re.match(r"(\\[^/\\]+)+", input_path) - revised_path = possibly_convert_to_windows_style_path(input_path) - assert input_path == revised_path - - # Get an input path in the form "/c/path/to/tempdir" - input_path = "/" + drive[0].lower() + tail.replace("\\", "/") - assert re.match(r"/[a-z](/[^/\\]+)+", input_path) - expected = drive.upper() + tail.replace("/", "\\") - revised_path = possibly_convert_to_windows_style_path(input_path) - assert expected == revised_path