diff --git a/.mypy.ini b/.mypy.ini new file mode 100644 index 0000000..be0671c --- /dev/null +++ b/.mypy.ini @@ -0,0 +1,2 @@ +[mypy] +strict = true diff --git a/Makefile b/Makefile index 7156972..ad66ea3 100644 --- a/Makefile +++ b/Makefile @@ -10,14 +10,14 @@ test_install: .PHONY: lint lint: - python -m mypy --strict src/pyprojroot + python -m mypy src/pyprojroot python -m flake8 src/pyprojroot tests python -m black --check --diff src/pyprojroot tests .PHONY: fmt fmt: - python -m black pyprojroot tests + python -m black src/pyprojroot tests .PHONY: test test: - python -m pytest + PYTHONPATH="src" python -m pytest diff --git a/src/pyprojroot/__init__.py b/src/pyprojroot/__init__.py index b17b019..a24c270 100644 --- a/src/pyprojroot/__init__.py +++ b/src/pyprojroot/__init__.py @@ -1,3 +1,11 @@ -from .criterion import * -from .root import find_root, find_root_with_reason -from .here import here +from .criterion import ( # noqa: F401 + CriterionFunction, + Criterion, + Criteria, + as_root_criterion, + has_file, + has_dir, + matches_glob, +) +from .root import find_root, find_root_with_reason # noqa: F401 +from .here import here # noqa: F401 diff --git a/src/pyprojroot/criterion.py b/src/pyprojroot/criterion.py index a90094d..55408b4 100644 --- a/src/pyprojroot/criterion.py +++ b/src/pyprojroot/criterion.py @@ -5,9 +5,15 @@ It is intended for interactive or programmatic only. """ -import pathlib as _pathlib -import typing -from os import PathLike as _PathLike +__all__ = [ + "CriterionFunction", + "Criterion", + "Criteria", + "as_root_criterion", + "has_file", + "has_dir", + "matches_glob", +] # TODO: It would be nice to have a class that encapsulates these checks, # so that we can implement methods like |, !, &, ^ operators @@ -15,18 +21,39 @@ # TODO: Refactor in a way that allows creation of reasons -def as_root_criterion(criterion) -> typing.Callable: - if callable(criterion): +from pathlib import Path +from typing import Union, Iterable, AnyStr +from typing_extensions import Protocol, runtime_checkable + +from os import PathLike + + +@runtime_checkable +class CriterionFunction(Protocol): + def __call__(self, path: Path) -> bool: + ... + + +Criterion = Union[CriterionFunction, "PathLike[AnyStr]"] + + +Criteria = Iterable[Criterion] + + +def as_root_criterion(criterion: Union[Criterion, Criteria]) -> CriterionFunction: + if isinstance(criterion, CriterionFunction): return criterion # criterion must be a Collection, rather than just Iterable - if isinstance(criterion, _PathLike): - criterion = [criterion] - criterion = list(criterion) - - def f(path: _pathlib.Path) -> bool: - for c in criterion: - if isinstance(c, _PathLike): + criteria: Criteria + if isinstance(criterion, PathLike): + criteria = [criterion] + else: + criteria = list(criterion) + + def f(path: Path) -> bool: + for c in criteria: + if isinstance(c, PathLike): if (path / c).exists(): return True else: @@ -37,38 +64,38 @@ def f(path: _pathlib.Path) -> bool: return f -def has_file(file: _PathLike) -> typing.Callable: +def has_file(file: Union[str, "PathLike[str]"]) -> CriterionFunction: """ Check that specified file exists in path. Note that a directory with that name will not match. """ - def f(path: _pathlib.Path) -> bool: + def f(path: Path) -> bool: return (path / file).is_file() return f -def has_dir(file: _PathLike) -> typing.Callable: +def has_dir(file: Union[str, "PathLike[str]"]) -> CriterionFunction: """ Check that specified directory exists. Note that a regular file with that name will not match. """ - def f(path: _pathlib.Path) -> bool: + def f(path: Path) -> bool: return (path / file).is_dir() return f -def matches_glob(pat: str) -> typing.Callable: +def matches_glob(pat: str) -> CriterionFunction: """ Check that glob has at least one match. """ - def f(path: _pathlib.Path) -> bool: + def f(path: Path) -> bool: matches = path.glob(pat) try: # Only need to get one item from generator diff --git a/src/pyprojroot/here.py b/src/pyprojroot/here.py index 4578aac..80a66a6 100644 --- a/src/pyprojroot/here.py +++ b/src/pyprojroot/here.py @@ -5,12 +5,15 @@ It is intended for interactive use only. """ -import pathlib as _pathlib -import warnings as _warnings -from os import PathLike as _PathLike +__all__ = ["CRITERIA", "get_here", "here"] + +from os import PathLike +from pathlib import Path +from typing import Union +from warnings import warn from . import criterion -from .root import find_root, find_root_with_reason +from .root import find_root_with_reason CRITERIA = [ criterion.has_file(".here"), @@ -28,7 +31,7 @@ def get_here(): # TODO: This should only find_root once per session - start = _pathlib.Path.cwd() + start = Path.cwd() path, reason = find_root_with_reason(CRITERIA, start=start) return path, reason @@ -36,7 +39,9 @@ def get_here(): # TODO: Implement set_here -def here(relative_project_path: _PathLike = "", warn_missing=False) -> _pathlib.Path: +def here( + relative_project_path: Union[str, "PathLike[str]"] = "", warn_missing=False +) -> Path: """ Returns the path relative to the projects root directory. :param relative_project_path: relative path from project root @@ -51,5 +56,5 @@ def here(relative_project_path: _PathLike = "", warn_missing=False) -> _pathlib. path = path / relative_project_path if warn_missing and not path.exists(): - _warnings.warn(f"Path doesn't exist: {path!s}") + warn(f"Path doesn't exist: {path!s}") return path diff --git a/src/pyprojroot/root.py b/src/pyprojroot/root.py index b8ef64a..0354475 100644 --- a/src/pyprojroot/root.py +++ b/src/pyprojroot/root.py @@ -4,26 +4,27 @@ It is intended for interactive or programmatic only. """ +__all__ = ["as_start_path", "find_root_with_reason", "find_root"] -import pathlib as _pathlib -import typing as _typing -from os import PathLike as _PathLike +from os import PathLike +from pathlib import Path +from typing import Tuple, Optional, Union -from .criterion import as_root_criterion as _as_root_criterion +from .criterion import as_root_criterion, Criterion, CriterionFunction -def as_start_path(start: _PathLike) -> _pathlib.Path: +def as_start_path(start: Optional[Union["PathLike[str]"]]) -> Path: if start is None: - return _pathlib.Path.cwd() - if not isinstance(start, _pathlib.Path): - start = _pathlib.Path(start) + return Path.cwd() + if not isinstance(start, Path): + start = Path(start) # TODO: consider `start = start.resolve()` return start def find_root_with_reason( - criterion, start: _PathLike = None -) -> _typing.Tuple[_pathlib.Path, str]: + criterion: Criterion, start: Optional["PathLike[str]"] = None +) -> Tuple[Path, str]: """ Find directory matching root criterion with reason. @@ -33,25 +34,25 @@ def find_root_with_reason( # TODO: Implement reasons # Prepare inputs - criterion = _as_root_criterion(criterion) - start = as_start_path(start) + root_criterion: CriterionFunction = as_root_criterion(criterion) + start_path: Path = as_start_path(start) # Check start - if start.is_dir() and criterion(start): - return start, "Pass" + if start_path.is_dir() and root_criterion(start_path): + return start_path, "Pass" # Iterate over all parents # TODO: Consider adding maximum depth # TODO: Consider limiting depth to path (e.g. "if p == stop: raise") - for p in start.parents: - if criterion(p): + for p in start_path.parents: + if root_criterion(p): return p, "Pass" # Not found raise RuntimeError("Project root not found.") -def find_root(criterion, start: _PathLike = None, **kwargs) -> _pathlib.Path: +def find_root(criterion: Criterion, start: Optional["PathLike[str]"] = None) -> Path: """ Find directory matching root criterion. @@ -59,7 +60,7 @@ def find_root(criterion, start: _PathLike = None, **kwargs) -> _pathlib.Path: matching root criterion. """ try: - root, _ = find_root_with_reason(criterion, start=start, **kwargs) + root, _ = find_root_with_reason(criterion, start) except RuntimeError as ex: raise ex else: