Skip to content

Commit

Permalink
feat: read local style files from relative and other root dirs
Browse files Browse the repository at this point in the history
  • Loading branch information
andreoliwa committed Mar 8, 2019
1 parent 04189a0 commit 82d3675
Show file tree
Hide file tree
Showing 3 changed files with 178 additions and 51 deletions.
83 changes: 54 additions & 29 deletions flake8_nitpick/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ def fetch_initial_style(self) -> YieldFlake8Error:
LOG.info("Loading default Nitpick style %s", DEFAULT_NITPICK_STYLE_URL)

tree = StyleTree(self.cache_dir)
tree.include_styles(chosen_styles)
tree.include_multiple_styles(chosen_styles)
self.style_dict = tree.merge_toml_dict()

minimum_version = search_dict(NITPICK_MINIMUM_VERSION_JMEX, self.style_dict, None)
Expand All @@ -152,43 +152,39 @@ def __init__(self, cache_dir: Optional[Path]) -> None:
self.cache_dir: Optional[Path] = cache_dir
self._all_flattened: JsonDict = {}
self._already_included: Set[str] = set()
self._first_full_path: Optional[Path] = None

def include_styles(self, chosen_styles: StrOrList) -> None:
"""Include one style or a list of styles into this style tree."""
def include_multiple_styles(self, chosen_styles: StrOrList) -> None:
"""Include a list of styles (or just one) into this style tree."""
style_uris: List[str] = [chosen_styles] if isinstance(chosen_styles, str) else chosen_styles

for style_uri in style_uris:
clean_style_uri = style_uri.strip()
if clean_style_uri.startswith("http"):
if clean_style_uri in self._already_included:
continue

# If the style is a URL, save the contents in the cache dir
style_path = self.load_style_from_url(clean_style_uri)
LOG.info("Loading style from URL %s into %s", clean_style_uri, style_path)
self._already_included.add(clean_style_uri)
elif clean_style_uri:
style_path = Path(clean_style_uri).absolute()
if str(style_path) in self._already_included:
continue
if not style_path.exists():
raise FileNotFoundError(f"Style file does not exist: {clean_style_uri}")
LOG.info("Loading style from file: %s", style_path)
self._already_included.add(str(style_path))
else:
# If the style is an empty string, skip it
style_path: Optional[Path] = self.get_style_path(style_uri)
if not style_path:
continue
toml_dict = toml.load(str(style_path))

sub_styles: StrOrList = search_dict(NITPICK_STYLES_INCLUDE_JMEX, toml_dict, [])

toml_dict = toml.load(str(style_path))
flattened_style_dict: JsonDict = flatten(toml_dict, separator=UNIQUE_SEPARATOR)
self._all_flattened.update(flattened_style_dict)

self.include_styles(sub_styles)
sub_styles: StrOrList = search_dict(NITPICK_STYLES_INCLUDE_JMEX, toml_dict, [])
if sub_styles:
self.include_multiple_styles(sub_styles)

def get_style_path(self, style_uri: str) -> Optional[Path]:
"""Get the style path from the URI."""
clean_style_uri = style_uri.strip()
style_path = None
if clean_style_uri.startswith("http"):
style_path = self.fetch_style_from_url(clean_style_uri)
elif clean_style_uri:
style_path = self.fetch_style_from_local_path(clean_style_uri)
return style_path

def fetch_style_from_url(self, url: str) -> Optional[Path]:
"""Fetch a style file from a URL, saving the contents in the cache dir."""
if url in self._already_included:
return None

def load_style_from_url(self, url: str) -> Path:
"""Load a style file from a URL."""
if not self.cache_dir:
raise FileNotFoundError("Cache dir does not exist")

Expand All @@ -200,6 +196,35 @@ def load_style_from_url(self, url: str) -> Path:
style_path = self.cache_dir / f"{slugify(url)}.toml"
self.cache_dir.mkdir(parents=True, exist_ok=True)
style_path.write_text(contents)

LOG.info("Loading style from URL %s into %s", url, style_path)
self._already_included.add(url)

return style_path

def fetch_style_from_local_path(self, partial_file_name: str) -> Optional[Path]:
"""Fetch a style file from a local path."""
expanded_path = Path(partial_file_name).expanduser()

if not str(expanded_path).startswith("/") and self._first_full_path:
# Prepend the previous path to the partial file name.
style_path = Path(self._first_full_path) / expanded_path
else:
# Get the absolute path, be it from a root path (starting with slash) or from the current dir.
style_path = Path(expanded_path).absolute()

# Save the first full path to be used by the next files without parent.
if not self._first_full_path:
self._first_full_path = style_path.parent

if str(style_path) in self._already_included:
return None

if not style_path.exists():
raise FileNotFoundError(f"Local style file does not exist: {style_path}")

LOG.info("Loading style from file: %s", style_path)
self._already_included.add(str(style_path))
return style_path

def merge_toml_dict(self) -> JsonDict:
Expand Down
49 changes: 33 additions & 16 deletions tests/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@
class ProjectMock:
"""A mocked Python project to help on tests."""

original_errors: List[Flake8Error]
errors: Set[str]
_original_errors: List[Flake8Error]
_errors: Set[str]

fixture_dir: Path = Path(__file__).parent / "fixtures"

Expand Down Expand Up @@ -49,25 +49,35 @@ def create_symlink_to_fixture(self, file_name: str) -> "ProjectMock":
def lint(self, file_index: int = 0) -> "ProjectMock":
"""Lint one of the project files. If no index is provided, use the default file that's always created."""
npc = NitpickChecker(filename=str(self.files_to_lint[file_index]))
self.original_errors = list(npc.run())
self.errors = set()
for flake8_error in self.original_errors:
self._original_errors = list(npc.run())
self._errors = set()
for flake8_error in self._original_errors:
line, col, message, class_ = flake8_error
assert line == 1
assert col == 0
assert message.startswith(ERROR_PREFIX)
assert class_ is NitpickChecker
self.errors.add(message)
self._errors.add(message)
return self

def save_file(self, file_name: PathOrStr, file_contents: str, lint: bool = None) -> "ProjectMock":
"""Save a file in the root dir with the desired contents."""
path: Path = self.root_dir / file_name
if "/" in file_name:
path.parent.mkdir(parents=True)
def save_file(self, partial_file_name: PathOrStr, file_contents: str, lint: bool = None) -> "ProjectMock":
"""Save a file in the root dir with the desired contents.
Create the parent dirs if the file name contains a slash.
:param partial_file_name: If it starts with a slash, then it's already a root.
If it doesn't, then we add the root dir before the partial name.
:param file_contents: Contents to save in the file.
:param lint: Should we lint the file or not? Python (.py) files are always linted.
"""
if str(partial_file_name).startswith("/"):
path: Path = Path(partial_file_name)
else:
path = self.root_dir / partial_file_name
path.parent.mkdir(parents=True, exist_ok=True)
if lint or path.suffix == ".py":
self.files_to_lint.append(path)
path.write_text(dedent(file_contents))
path.write_text(dedent(file_contents).strip())
return self

def touch_file(self, file_name: PathOrStr):
Expand Down Expand Up @@ -95,15 +105,22 @@ def pre_commit(self, file_contents: str):
"""Save .pre-commit-config.yaml."""
return self.save_file(PreCommitFile.file_name, file_contents)

def assert_errors_contain(self, raw_error: str) -> "ProjectMock":
def assert_errors_contain(self, raw_error: str, expected_count: int = None) -> "ProjectMock":
"""Assert the error is in the error set."""
error = dedent(raw_error).strip()
if error in self.errors:
if error in self._errors:
if expected_count is not None:
actual = len(self._errors)
assert expected_count == actual, f"Expected {expected_count} errors, got {actual}"
return self

print(f"Expected error:\n{error}")
print("\nAll errors:")
print(sorted(self.errors))
print(sorted(self._errors))
print("\nAll errors (pprint):")
pprint(sorted(self.errors), width=150)
pprint(sorted(self._errors), width=150)
assert False

def assert_single_error(self, raw_error: str) -> "ProjectMock":
"""Assert there is only one error."""
return self.assert_errors_contain(raw_error, 1)
97 changes: 91 additions & 6 deletions tests/test_config.py
Original file line number Diff line number Diff line change
@@ -1,25 +1,27 @@
"""Config tests."""
from pathlib import Path
from unittest import mock
from unittest.mock import PropertyMock

from flake8_nitpick.constants import ROOT_PYTHON_FILES
from tests.conftest import TEMP_ROOT_PATH
from tests.helpers import ProjectMock


def test_no_root_dir(request):
"""No root dir."""
assert ProjectMock(request, pyproject_toml=False, setup_py=False).create_symlink_to_fixture(
ProjectMock(request, pyproject_toml=False, setup_py=False).create_symlink_to_fixture(
"hello.py"
).lint().errors == {"NIP101 No root dir found (is this a Python project?)"}
).lint().assert_single_error("NIP101 No root dir found (is this a Python project?)")


def test_no_main_python_file_root_dir(request):
"""No main Python file on the root dir."""
project = ProjectMock(request, setup_py=False).pyproject_toml("").save_file("whatever.sh", "", lint=True).lint()
assert project.errors == {
project.assert_single_error(
"NIP102 None of those Python files was found in the root dir "
+ f"{project.root_dir}: {', '.join(ROOT_PYTHON_FILES)}"
}
)


def test_multiple_styles_overriding_values(request):
Expand Down Expand Up @@ -182,6 +184,89 @@ def test_minimum_version(mocked_version, request):
"""
)
.lint()
.errors
== {"NIP203 The style file you're using requires flake8-nitpick>=1.0 (you have 0.5.3). Please upgrade"}
.assert_single_error(
"NIP203 The style file you're using requires flake8-nitpick>=1.0 (you have 0.5.3). Please upgrade"
)
)


def test_relative_and_other_root_dirs(request):
"""Test styles in relative and in other root dirs."""
another_dir: Path = TEMP_ROOT_PATH / "another_dir"
project = (
ProjectMock(request)
.named_style(
f"{another_dir}/main",
"""
[nitpick.styles]
include = "styles/pytest.toml"
""",
)
.named_style(
f"{another_dir}/styles/pytest",
"""
["pyproject.toml".tool.pytest]
some-option = 123
""",
)
.named_style(
f"{another_dir}/styles/black",
"""
["pyproject.toml".tool.black]
line-length = 99
missing = "value"
""",
)
.named_style(
f"{another_dir}/poetry",
"""
["pyproject.toml".tool.poetry]
version = "1.0"
""",
)
)

common_pyproject = """
[tool.black]
line-length = 99
[tool.pytest]
some-option = 123
"""
expected_error = """
NIP311 File pyproject.toml has missing values. Use this:
[tool.black]
missing = "value"
"""

# Use full path on initial styles
project.pyproject_toml(
f"""
[tool.nitpick]
style = ["{another_dir}/main.toml", "{another_dir}/styles/black.toml"]
{common_pyproject}
"""
).lint().assert_single_error(expected_error)

# Reuse the first full path that appears
project.pyproject_toml(
f"""
[tool.nitpick]
style = ["{another_dir}/main.toml", "styles/black.toml"]
{common_pyproject}
"""
).lint().assert_single_error(expected_error)

# Allow relative paths
project.pyproject_toml(
f"""
[tool.nitpick]
style = ["{another_dir}/styles/black.toml", "../poetry.toml"]
{common_pyproject}
"""
).lint().assert_single_error(
f"""
{expected_error}
[tool.poetry]
version = "1.0"
"""
)

0 comments on commit 82d3675

Please sign in to comment.