Skip to content

Commit

Permalink
Merge 2575481 into a6fbb8c
Browse files Browse the repository at this point in the history
  • Loading branch information
andreoliwa committed Dec 24, 2021
2 parents a6fbb8c + 2575481 commit 9e48a2a
Show file tree
Hide file tree
Showing 7 changed files with 46 additions and 87 deletions.
2 changes: 1 addition & 1 deletion Makefile
Expand Up @@ -27,7 +27,7 @@ build: .cache/make/pytest .cache/make/pre-commit # Quick build for local develop
touch .cache/make/pytest

.cache/make/pre-commit: $(ANY)
invoke pre-commit
pre-commit run -a
touch .cache/make/pre-commit

.cache/make/doc: $(DOCS) $(STYLES)
Expand Down
12 changes: 4 additions & 8 deletions docs/flake8_plugin.rst
Expand Up @@ -77,9 +77,9 @@ So basically the pre-commit hook would be useless to guarantee that your config
Root dir of the project
-----------------------

Nitpick_ tries to find the root dir of the project using some hardcoded assumptions.
You should run Nitpick_ in the *root dir* of your project.

#. Starting from the current working directory, it will search for files that are usually in the root of a project:
A directory is considered a root dir if it contains one of the following files:

- ``.pre-commit-config.yaml`` (pre-commit_)
- ``pyproject.toml``
Expand All @@ -93,16 +93,12 @@ Nitpick_ tries to find the root dir of the project using some hardcoded assumpti
- ``go.mod``, ``go.sum`` (Golang)
- ``app.py`` and ``wsgi.py`` (`Flask CLI`_)
- ``autoapp.py`` (Flask_)

#. If none of these root files were found, search for ``manage.py``.
On Django_ projects, it can be in another dir inside the root dir (:issue:`21`).
#. If multiple roots are found, get the top one in the dir tree.
- ``manage.py`` (Django_)

Main Python file
----------------

After finding the `root dir of the project`_, Nitpick searches for a
main Python file.
On the `root dir of the project`_ Nitpick searches for a main Python file.
Every project must have at least one ``*.py`` file, otherwise flake8_ won't even work.

Those are the Python files that are considered:
Expand Down
2 changes: 1 addition & 1 deletion src/nitpick/constants.py
Expand Up @@ -20,8 +20,8 @@
SETUP_CFG = "setup.cfg"
REQUIREMENTS_STAR_TXT = "requirements*.txt"
PIPFILE_STAR = "Pipfile*"
ROOT_PYTHON_FILES = ("app.py", "wsgi.py", "autoapp.py")
MANAGE_PY = "manage.py"
ROOT_PYTHON_FILES = ("app.py", "wsgi.py", "autoapp.py", MANAGE_PY)
TOX_INI = "tox.ini"
PYLINTRC = ".pylintrc"
# Tools
Expand Down
79 changes: 16 additions & 63 deletions src/nitpick/project.py
Expand Up @@ -37,73 +37,26 @@
from nitpick.violations import Fuss, ProjectViolations, Reporter, StyleViolations


def climb_directory_tree(starting_path: PathOrStr, file_patterns: Iterable[str]) -> Set[Path]: # TODO: add unit test
"""Climb the directory tree looking for file patterns."""
current_dir: Path = Path(starting_path).absolute()
while current_dir.anchor != str(current_dir):
for root_file in file_patterns:
found_files = list(current_dir.glob(root_file))
if found_files:
return set(found_files)
current_dir = current_dir.parent
def glob_files(dir_: Path, file_patterns: Iterable[str]) -> Set[Path]:
"""Search a directory looking for file patterns."""
for pattern in file_patterns:
found_files = list(dir_.glob(pattern))
if found_files:
return set(found_files)
return set()


def find_starting_dir(current_dir: PathOrStr) -> Path:
"""Find the starting dir from the current dir."""
logger.debug(f"Searching root from current dir: {str(current_dir)!r}")
all_files_dirs = list(Path(current_dir).glob("*"))
logger.debug("All files/dirs in the current dir:\n{}", "\n".join(str(file) for file in all_files_dirs))
def confirm_project_root(dir_: Optional[PathOrStr] = None) -> Path:
"""Confirm this is the root dir of the project (the one that has one of the ``ROOT_FILES``)."""
possible_root_dir = Path(dir_ or Path.cwd())
root_files = glob_files(possible_root_dir, ROOT_FILES)
logger.debug(f"Root files found: {root_files}")

# Don't fail if the current dir is empty
starting_file = str(all_files_dirs[0]) if all_files_dirs else ""
if starting_file:
return Path(starting_file).parent.absolute()
if root_files:
return next(iter(root_files)).parent

return Path(current_dir).absolute()


def find_root(current_dir: Optional[PathOrStr] = None) -> Path:
"""Find the root dir of the Python project (the one that has one of the ``ROOT_FILES``).
Start from the current working dir.
"""
root_dirs: Set[Path] = set()
seen: Set[Path] = set()

starting_dir = find_starting_dir(current_dir or Path.cwd())
while starting_dir: # pragma: no cover # starting_dir will always have a value on the first run
logger.debug(f"Climbing dir: {starting_dir}")
project_files = climb_directory_tree(starting_dir, ROOT_FILES)
if project_files and project_files & seen:
break
seen.update(project_files)
logger.debug(f"Project files seen: {str(project_files)}")

if not project_files:
# If none of the root files were found, try again with manage.py.
# On Django projects, it can be in another dir inside the root dir.
project_files = climb_directory_tree(starting_dir, [MANAGE_PY])
if not project_files or project_files & seen:
break
seen.update(project_files)
logger.debug(f"Django project files seen: {project_files}")

for found in project_files:
root_dirs.add(found.parent)
logger.debug(f"Root dirs: {str(root_dirs)}")

# Climb up one directory to search for more project files
starting_dir = starting_dir.parent

if not root_dirs:
logger.error(f"No files found while climbing directory tree from {starting_dir}")
raise QuitComplainingError(Reporter().make_fuss(ProjectViolations.NO_ROOT_DIR))

# If multiple roots are found, get the top one (grandparent dir)
top_dir = sorted(root_dirs)[0]
logger.debug(f"Top root dir found: {top_dir}")
return top_dir
logger.error(f"No root files found on directory {possible_root_dir}")
raise QuitComplainingError(Reporter().make_fuss(ProjectViolations.NO_ROOT_DIR))


def find_main_python_file(root_dir: Path) -> Path:
Expand Down Expand Up @@ -165,7 +118,7 @@ def __init__(self, root: PathOrStr = None) -> None:
@lru_cache()
def root(self) -> Path:
"""Root dir of the project."""
return find_root(self._chosen_root)
return confirm_project_root(self._chosen_root)

@mypy_property
@lru_cache()
Expand Down
4 changes: 2 additions & 2 deletions src/nitpick/style/core.py
Expand Up @@ -30,7 +30,7 @@
from nitpick.generic import MergeDict, is_url, search_dict
from nitpick.plugins.base import NitpickPlugin
from nitpick.plugins.info import FileInfo
from nitpick.project import Project, climb_directory_tree
from nitpick.project import Project, glob_files
from nitpick.schemas import BaseStyleSchema, flatten_marshmallow_errors
from nitpick.style.config import ConfigValidator
from nitpick.style.fetchers import StyleFetcherManager
Expand Down Expand Up @@ -90,7 +90,7 @@ def find_initial_styles(self, configured_styles: StrOrIterable) -> Iterator[Fuss
chosen_styles: StrOrIterable = list(configured_styles)
log_message = f"Using styles configured in {PYPROJECT_TOML}"
else:
paths = climb_directory_tree(self.project.root, [NITPICK_STYLE_TOML])
paths = glob_files(self.project.root, [NITPICK_STYLE_TOML])
if paths:
chosen_styles = str(sorted(paths)[0])
log_message = "Using local style found climbing the directory tree"
Expand Down
31 changes: 21 additions & 10 deletions tests/test_project.py
Expand Up @@ -2,6 +2,7 @@
import os

import pytest
import responses

from nitpick.constants import (
DOT_NITPICK_TOML,
Expand All @@ -17,7 +18,8 @@
TOX_INI,
)
from nitpick.core import Nitpick
from nitpick.project import Configuration, find_main_python_file, find_root
from nitpick.exceptions import QuitComplainingError
from nitpick.project import Configuration, confirm_project_root, find_main_python_file
from nitpick.violations import ProjectViolations
from tests.helpers import ProjectMock

Expand Down Expand Up @@ -116,8 +118,11 @@ def test_django_project_structure(tmp_path):
).api_check_then_fix()


def test_no_config_file(tmp_path, caplog):
@responses.activate
def test_when_no_config_file_the_default_style_is_requested(tmp_path, caplog):
"""There is a root dir (setup.py), but no config file."""
responses.add(responses.GET, "https://api.github.com/repos/andreoliwa/nitpick", '{"default_branch": "develop"}')

project = ProjectMock(tmp_path, pyproject_toml=False, setup_py=True).api_check(offline=True)
assert project.nitpick_instance.project.read_configuration() == Configuration(None, [], "")
assert "Config file: none found" in caplog.text
Expand Down Expand Up @@ -188,18 +193,24 @@ def test_has_multiple_config_files(tmp_path, caplog):
NITPICK_STYLE_TOML,
],
)
def test_find_root_from_sub_dir(tmp_path, root_file):
"""Find the root dir from a subdir."""
def test_use_current_dir_dont_climb_dirs_to_find_project_root(tmp_path, root_file):
"""Use current dir; don't climb dirs to find the project root."""
root = tmp_path / "deep" / "root"
root.mkdir(parents=True)
(root / root_file).write_text("")

curdir = root / "going" / "down" / "the" / "rabbit" / "hole"
curdir.mkdir(parents=True)
os.chdir(str(curdir))
os.chdir(str(root))
assert confirm_project_root(root) == root, root_file
assert confirm_project_root(str(root)) == root, root_file

inner_dir = root / "going" / "down" / "the" / "rabbit" / "hole"
inner_dir.mkdir(parents=True)

assert find_root(curdir) == root, root_file
assert find_root(str(curdir)) == root, root_file
os.chdir(str(inner_dir))
with pytest.raises(QuitComplainingError):
confirm_project_root(inner_dir)
with pytest.raises(QuitComplainingError):
confirm_project_root(str(inner_dir))


def test_find_root_django(tmp_path):
Expand All @@ -208,7 +219,7 @@ def test_find_root_django(tmp_path):
apps_dir.mkdir(parents=True)
(apps_dir / MANAGE_PY).write_text("")

assert find_root(apps_dir) == apps_dir
assert confirm_project_root(apps_dir) == apps_dir

# Search 2 levels of directories
assert find_main_python_file(tmp_path) == apps_dir / MANAGE_PY
Expand Down
3 changes: 1 addition & 2 deletions tox.ini
Expand Up @@ -31,7 +31,7 @@ commands =
# https://pytest-cov.readthedocs.io/en/latest/config.html#caveats
# https://docs.pytest.org/en/stable/skipping.html
# show extra test summary info for all tests except passed (failed/error/skipped/xfail/xpassed):
python -m pytest --cov-config=tox.ini --cov --cov-append --cov-report=term-missing --doctest-modules -s -ra {posargs:-vv}
python -m pytest --cov-config=tox.ini --cov --cov-append --cov-report=term-missing --doctest-modules -s -ra {posargs:}

[testenv:clean]
description = Erase data for the coverage report before running tests
Expand Down Expand Up @@ -103,7 +103,6 @@ commands =
[pytest]
# https://docs.pytest.org/en/stable/customize.html#tox-ini
addopts =
-v
# Disable HTTP requests on tests (any network calls, actually)
# https://github.com/miketheman/pytest-socket#usage
--disable-socket
Expand Down

0 comments on commit 9e48a2a

Please sign in to comment.