Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Convert pythonfinder from pydantic to vanilla dataclasses #157

Merged
merged 14 commits into from
Jan 28, 2024
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions news/157.bugfix.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
* Convert away from pydantic to reduce complexity; simplify the path manipulation logics to use pathlib.
2 changes: 0 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,6 @@ dynamic = ["version"]
requires-python = ">=3.8"
dependencies = [
"packaging>=22.0",
"pydantic>=1.10.7,<2",
]

[project.optional-dependencies]
Expand Down Expand Up @@ -136,7 +135,6 @@ unfixable = [

[tool.ruff.flake8-type-checking]
runtime-evaluated-base-classes = [
"pydantic.BaseModel",
"pythonfinder.models.common.FinderBaseModel",
"pythonfinder.models.mixins.PathEntry",
]
Expand Down
24 changes: 2 additions & 22 deletions src/pythonfinder/environment.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Expand Down
26 changes: 0 additions & 26 deletions src/pythonfinder/models/common.py

This file was deleted.

141 changes: 57 additions & 84 deletions src/pythonfinder/models/mixins.py
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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.
Expand All @@ -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,
)
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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):
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading