From 84f19d61585153e1fe94751007ce034ebfc20c93 Mon Sep 17 00:00:00 2001 From: Augusto Wagner Andreoli Date: Fri, 21 Dec 2018 03:32:47 +0100 Subject: [PATCH] feat: Read style from TOML file/URL (or climb directory tree) --- flake8_nitpick/__init__.py | 93 +++++++++++++++++++++++++++----------- flake8_nitpick/generic.py | 17 +++++++ nitpick-style.toml | 31 +++++++++++++ pyproject.toml | 32 ------------- setup.py | 2 +- 5 files changed, 115 insertions(+), 60 deletions(-) create mode 100644 nitpick-style.toml diff --git a/flake8_nitpick/__init__.py b/flake8_nitpick/__init__.py index 12dc6a26..b9c096ba 100644 --- a/flake8_nitpick/__init__.py +++ b/flake8_nitpick/__init__.py @@ -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 @@ -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)) @@ -67,12 +71,46 @@ 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) @@ -80,7 +118,7 @@ class NitpickChecker: """Main plugin class.""" # Plugin config - name = "flake8-nitpick" + name = NAME version = __version__ # Plugin arguments passed by Flake8 @@ -103,7 +141,12 @@ 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()): @@ -111,23 +154,20 @@ def run(self): 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.""" @@ -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.""" @@ -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}") diff --git a/flake8_nitpick/generic.py b/flake8_nitpick/generic.py index c62406b1..2e9e8dc1 100644 --- a/flake8_nitpick/generic.py +++ b/flake8_nitpick/generic.py @@ -1,5 +1,7 @@ """Generic functions and classes.""" import collections +from pathlib import Path +from typing import Iterable, Optional def get_subclasses(cls): @@ -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 diff --git a/nitpick-style.toml b/nitpick-style.toml new file mode 100644 index 00000000..858a477b --- /dev/null +++ b/nitpick-style.toml @@ -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 diff --git a/pyproject.toml b/pyproject.toml index 03c9dcd5..ca2b8328 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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 diff --git a/setup.py b/setup.py index 7a138e5e..f4f3ea12 100644 --- a/setup.py +++ b/setup.py @@ -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 :) # ------------------------------------------------