Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .mypy.ini
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
[mypy]
strict = true
6 changes: 3 additions & 3 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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
14 changes: 11 additions & 3 deletions src/pyprojroot/__init__.py
Original file line number Diff line number Diff line change
@@ -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
63 changes: 45 additions & 18 deletions src/pyprojroot/criterion.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,28 +5,55 @@
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

# 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:
Expand All @@ -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
Expand Down
19 changes: 12 additions & 7 deletions src/pyprojroot/here.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand All @@ -28,15 +31,17 @@

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


# 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
Expand All @@ -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
37 changes: 19 additions & 18 deletions src/pyprojroot/root.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -33,33 +34,33 @@ 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.

Recursively search parents of start path for directory
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:
Expand Down