Skip to content

Commit

Permalink
feat: Read style from TOML file/URL (or climb directory tree)
Browse files Browse the repository at this point in the history
  • Loading branch information
andreoliwa committed Dec 21, 2018
1 parent 22f4c62 commit 84f19d6
Show file tree
Hide file tree
Showing 5 changed files with 115 additions and 60 deletions.
93 changes: 66 additions & 27 deletions flake8_nitpick/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,21 @@
import attr
from pathlib import Path

import requests
import toml

from flake8_nitpick.__version__ import __version__
from flake8_nitpick.generic import get_subclasses, flatten, unflatten
from flake8_nitpick.generic import get_subclasses, flatten, unflatten, climb_directory_tree

# Types
NitpickError = Tuple[int, int, str, Type]

# Constants
NAME = "flake8-nitpick"
ERROR_PREFIX = "NIP"
CACHE_DIR: Path = Path(os.getcwd()) / ".cache" / NAME
PYPROJECT_TOML = "pyproject.toml"
NITPICK_STYLE_TOML = "nitpick-style.toml"
ROOT_PYTHON_FILES = ("setup.py", "manage.py", "autoapp.py")
ROOT_FILES = (PYPROJECT_TOML, "setup.cfg", "requirements*.txt", "Pipfile") + ROOT_PYTHON_FILES

Expand All @@ -31,8 +35,8 @@ class NitpickCache:

def __init__(self, key: str) -> None:
"""Init the cache file."""
self.cache_file: Path = Path(os.getcwd()) / ".cache/flake8-nitpick.toml"
self.cache_file.parent.mkdir(exist_ok=True)
self.cache_file: Path = CACHE_DIR / "variables.toml"
self.cache_file.parent.mkdir(parents=True, exist_ok=True)
self.cache_file.touch(exist_ok=True)
self.toml_dict = toml.load(str(self.cache_file))

Expand Down Expand Up @@ -67,20 +71,54 @@ class NitpickConfig:

def __init__(self, root_dir: Path) -> None:
"""Init instance."""
pyproject_toml_file = root_dir / PYPROJECT_TOML
toml_dict = toml.load(str(pyproject_toml_file))
self.nitpick_config = toml_dict.get("tool", {}).get("nitpick", {})

self.root_dir = root_dir
self.files: Dict[str, bool] = self.nitpick_config.get("files", {})

self.pyproject_toml = toml.load(str(self.root_dir / PYPROJECT_TOML))
self.tool_nitpick_toml = self.pyproject_toml.get("tool", {}).get("nitpick", {})

style_path = self.find_style()
self.style_toml = toml.load(str(style_path))

self.files: Dict[str, bool] = self.style_toml.get("files", {})

def find_style(self) -> Optional[Path]:
"""Search for a style file."""
cache = NitpickCache("style")
style_path = cache.load_path()
if style_path is not None:
return style_path

style: str = self.pyproject_toml.get("style", "")
if style.startswith("http"):
# If the style is a URL, save the contents in the cache dir
response = requests.get(style)
if not response.ok:
raise RuntimeError(f"Error {response} fetching style URL {style}")
contents = response.text
style_path = CACHE_DIR / "style.toml"
style_path.write_text(contents)
elif style:
style_path = Path(style)
if not style_path.exists():
raise RuntimeError(f"Style file does not exist: {style}")
else:
paths = climb_directory_tree(self.root_dir, [NITPICK_STYLE_TOML])
if not paths:
raise RuntimeError(
f"Style not configured on {PYPROJECT_TOML} and {NITPICK_STYLE_TOML} not found in directory tree"
)
style_path = paths[0]

cache.dump_path(style_path)
return style_path


@attr.s(hash=False)
class NitpickChecker:
"""Main plugin class."""

# Plugin config
name = "flake8-nitpick"
name = NAME
version = __version__

# Plugin arguments passed by Flake8
Expand All @@ -103,31 +141,33 @@ def run(self):
# Only report warnings once, for the main Python file of this project.
return

config = NitpickConfig(root_dir)
try:
config = NitpickConfig(root_dir)
except RuntimeError as err:
yield nitpick_error(100, str(err))
return

for checker_class in get_subclasses(BaseChecker):
checker = checker_class(config)
for error in itertools.chain(checker.check_exists(), checker.check_rules()):
yield error

return []

def find_root_dir(self, python_file: str) -> Optional[Path]:
@staticmethod
def find_root_dir(python_file: str) -> Optional[Path]:
"""Find the root dir of the Python project: the dir that has one of the `ROOT_FILES`."""
cache = NitpickCache("root_dir")
root_dir = cache.load_path()
if root_dir is not None:
return root_dir

current_dir: Path = Path(python_file).resolve().parent
while current_dir.root != str(current_dir):
for root_file in ROOT_FILES:
found_files = list(current_dir.glob(root_file))
if found_files:
root_dir = found_files[0].parent
cache.dump_path(root_dir)
return root_dir
current_dir = current_dir.parent
return None
found_files = climb_directory_tree(python_file, ROOT_FILES)
if not found_files:
return None
root_dir = found_files[0].parent
cache.dump_path(root_dir)
return root_dir

def find_main_python_file(self, root_dir: Path, current_file: Path) -> Path:
"""Find the main Python file in the root dir, the one that will be used to report Flake8 warnings."""
Expand Down Expand Up @@ -157,7 +197,7 @@ def __init__(self, config: NitpickConfig) -> None:
"""Init instance."""
self.config = config
self.file_path: Path = self.config.root_dir / self.file_name
self.file_config = self.config.nitpick_config.get(self.file_name, {})
self.file_toml = self.config.style_toml.get(self.file_name, {})

def check_exists(self) -> Generator[NitpickError, Any, Any]:
"""Check if the file should exist or not."""
Expand All @@ -182,13 +222,12 @@ class PyProjectTomlChecker(BaseChecker):

def check_rules(self):
"""Check missing key/value pairs in pyproject.toml."""
pyproject_toml_dict = toml.load(str(self.file_path))
actual = flatten(pyproject_toml_dict)
expected = flatten(self.file_config)
if expected.items() <= actual.items():
project = flatten(self.config.pyproject_toml)
style = flatten(self.file_toml)
if style.items() <= project.items():
return []

missing_dict = unflatten({k: v for k, v in expected.items() if k not in actual})
missing_dict = unflatten({k: v for k, v in style.items() if k not in project})
missing_toml = toml.dumps(missing_dict)
yield nitpick_error(104, f"Missing values in {self.file_name!r}:\n{missing_toml}")

Expand Down
17 changes: 17 additions & 0 deletions flake8_nitpick/generic.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
"""Generic functions and classes."""
import collections
from pathlib import Path
from typing import Iterable, Optional


def get_subclasses(cls):
Expand Down Expand Up @@ -39,3 +41,18 @@ def unflatten(dict_, separator="."):
sub_items[keys[-1]] = v

return items


def climb_directory_tree(starting_path: Path, file_patterns: Iterable[str]) -> Optional[Iterable[Path]]:
"""Climb the directory tree looking for file patterns."""
current_dir: Path = Path(starting_path).resolve()
if current_dir.is_file():
current_dir = current_dir.parent

while current_dir.root != str(current_dir):
for root_file in file_patterns:
found_files = list(current_dir.glob(root_file))
if found_files:
return found_files
current_dir = current_dir.parent
return None
31 changes: 31 additions & 0 deletions nitpick-style.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
["pyproject.toml".tool.black]
line-length = 120

["pyproject.toml".tool.poetry.dev-dependencies]
black = {version = "*", allows-prereleases = true}
"flake8-blind-except" = "*"
"flake8-bugbear" = "*"
"flake8-comprehensions" = "*"
"flake8-debugger" = "*"
"flake8-docstrings" = "*"
"flake8-isort" = "*"
"flake8-mypy" = "*"
"flake8-polyfill" = "*"
"flake8-pytest" = "*"
"flake8" = "*"
pre_commit = "*"
ipython = "*"
ipdb = "*"
pylint = "*"
mypy = "*"

["setup.cfg".flake8]
ignore = "D107,D202,D203,D401,E203,E402,E501,W503"
max-line-length = 120

["setup.cfg".isort]
line_length = 120
multi_line_output = 3
include_trailing_comma = true
force_grid_wrap = 0
combine_as_imports = true
32 changes: 0 additions & 32 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -35,35 +35,3 @@ mypy = "*"
[build-system]
requires = ["poetry>=0.12"]
build-backend = "poetry.masonry.api"

[tool.nitpick."pyproject.toml".tool.black]
line-length = 120

[tool.nitpick."pyproject.toml".tool.poetry.dev-dependencies]
black = {version = "*", allows-prereleases = true}
"flake8-blind-except" = "*"
"flake8-bugbear" = "*"
"flake8-comprehensions" = "*"
"flake8-debugger" = "*"
"flake8-docstrings" = "*"
"flake8-isort" = "*"
"flake8-mypy" = "*"
"flake8-polyfill" = "*"
"flake8-pytest" = "*"
"flake8" = "*"
pre_commit = "*"
ipython = "*"
ipdb = "*"
pylint = "*"
mypy = "*"

[tool.nitpick."setup.cfg".flake8]
ignore = "D107,D202,D203,D401,E203,E402,E501,W503"
max-line-length = 120

[tool.nitpick."setup.cfg".isort]
line_length = 120
multi_line_output = 3
include_trailing_comma = true
force_grid_wrap = 0
combine_as_imports = true
2 changes: 1 addition & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
VERSION = None

# What packages are required for this module to be executed?
REQUIRED = ["flake8 > 3.0.0", "attrs", "toml"]
REQUIRED = ["flake8 > 3.0.0", "attrs", "toml", "requests"]

# The rest you shouldn't have to touch too much :)
# ------------------------------------------------
Expand Down

0 comments on commit 84f19d6

Please sign in to comment.