Skip to content

Commit

Permalink
feat: allow multiple style files
Browse files Browse the repository at this point in the history
  • Loading branch information
andreoliwa committed Mar 4, 2019
1 parent 8afa376 commit 22505ce
Show file tree
Hide file tree
Showing 12 changed files with 176 additions and 60 deletions.
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,14 @@ Change your project config on `pyproject.toml`, and configure your own style lik

You can set `style` with any local file or URL. E.g.: you can use the raw URL of a [GitHub Gist](https://gist.github.com).

You can also use multiple styles and mix local files and URLs:

[tool.nitpick]
style = ["/path/to/first.toml", "/another/path/to/second.toml", "https://example.com/on/the/web/third.toml"]

The order is important: each style will override any keys that might be set by the previous .toml file.
If a key is defined in more than one file, the value from the last file will prevail.

### Default search order for a style file

1. A file or URL configured in the `pyproject.toml` file, `[tool.nitpick]` section, `style` key, as [described above](#configure-your-own-style-file).
Expand Down
92 changes: 53 additions & 39 deletions flake8_nitpick/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,24 @@
import logging
from pathlib import Path
from shutil import rmtree
from typing import Any, Dict, MutableMapping, Optional
from typing import Any, Dict, List, MutableMapping, Optional, Union

import requests
import toml

from flake8_nitpick.constants import DEFAULT_NITPICK_STYLE_URL, NAME, NITPICK_STYLE_TOML, ROOT_FILES, ROOT_PYTHON_FILES
from slugify import slugify

from flake8_nitpick.constants import (
DEFAULT_NITPICK_STYLE_URL,
NAME,
NITPICK_STYLE_TOML,
ROOT_FILES,
ROOT_PYTHON_FILES,
UNIQUE_SEPARATOR,
)
from flake8_nitpick.files.pyproject_toml import PyProjectTomlFile
from flake8_nitpick.files.setup_cfg import SetupCfgFile
from flake8_nitpick.generic import climb_directory_tree, rmdir_if_empty
from flake8_nitpick.types import PathOrStr, YieldFlake8Error
from flake8_nitpick.generic import climb_directory_tree, flatten, rmdir_if_empty, unflatten
from flake8_nitpick.types import PathOrStr, TomlDict, YieldFlake8Error
from flake8_nitpick.utils import NitpickMixin

LOG = logging.getLogger("flake8.nitpick")
Expand All @@ -31,10 +39,10 @@ def __init__(self) -> None:
self.cache_dir: Optional[Path] = None
self.main_python_file: Optional[Path] = None

self.pyproject_toml: MutableMapping[str, Any] = {}
self.tool_nitpick_toml: Dict[str, Any] = {}
self.style_toml: MutableMapping[str, Any] = {}
self.nitpick_toml: MutableMapping[str, Any] = {}
self.pyproject_dict: MutableMapping[str, Any] = {}
self.tool_nitpick_dict: Dict[str, Any] = {}
self.style_dict: MutableMapping[str, Any] = {}
self.nitpick_dict: MutableMapping[str, Any] = {}
self.files: Dict[str, Any] = {}

def find_root_dir(self, starting_file: PathOrStr) -> bool:
Expand Down Expand Up @@ -82,40 +90,46 @@ def load_toml(self) -> YieldFlake8Error:
"""Load TOML configuration from files."""
pyproject_path: Path = self.root_dir / PyProjectTomlFile.file_name
if pyproject_path.exists():
self.pyproject_toml = toml.load(str(pyproject_path))
self.tool_nitpick_toml = self.pyproject_toml.get("tool", {}).get("nitpick", {})
self.pyproject_dict: TomlDict = toml.load(str(pyproject_path))
self.tool_nitpick_dict: TomlDict = self.pyproject_dict.get("tool", {}).get("nitpick", {})

try:
style_path = self.find_style()
self.style_dict: TomlDict = self.find_styles()
except FileNotFoundError as err:
yield self.flake8_error(2, str(err))
return
self.style_toml = toml.load(str(style_path))
self.nitpick_toml = self.style_toml.get("nitpick", {})
self.files = self.nitpick_toml.get("files", {})

def find_style(self) -> Optional[Path]:
"""Search for a style file."""
configured_style: str = self.tool_nitpick_toml.get("style", "")
if configured_style.startswith("http"):
# If the style is a URL, save the contents in the cache dir
style_path = self.load_style_from_url(configured_style)
LOG.info("Loading style from URL: %s", style_path)
elif configured_style:
style_path = Path(configured_style)
if not style_path.exists():
raise FileNotFoundError(f"Style file does not exist: {configured_style}")
LOG.info("Loading style from file: %s", style_path)
else:
paths = climb_directory_tree(self.root_dir, [NITPICK_STYLE_TOML])
if paths:
style_path = paths[0]
LOG.info("Loading style from directory tree: %s", style_path)
self.nitpick_dict: TomlDict = self.style_dict.get("nitpick", {})
self.files = self.nitpick_dict.get("files", {})

def find_styles(self) -> TomlDict:
"""Search for one or multiple style files."""
style_value: Union[str, List[str]] = self.tool_nitpick_dict.get("style", "")
styles: List[str] = [style_value] if isinstance(style_value, str) else style_value

all_flattened: TomlDict = {}
for style in styles:
if style.startswith("http"):
# If the style is a URL, save the contents in the cache dir
style_path = self.load_style_from_url(style)
LOG.info("Loading style from URL: %s", style_path)
elif style:
style_path = Path(style)
if not style_path.exists():
raise FileNotFoundError(f"Style file does not exist: {style}")
LOG.info("Loading style from file: %s", style_path)
else:
style_path = self.load_style_from_url(DEFAULT_NITPICK_STYLE_URL)
LOG.info("Loading default Nitpick style %s into local file %s", DEFAULT_NITPICK_STYLE_URL, style_path)

return style_path
paths = climb_directory_tree(self.root_dir, [NITPICK_STYLE_TOML])
if paths:
style_path = paths[0]
LOG.info("Loading style from directory tree: %s", style_path)
else:
style_path = self.load_style_from_url(DEFAULT_NITPICK_STYLE_URL)
LOG.info(
"Loading default Nitpick style %s into local file %s", DEFAULT_NITPICK_STYLE_URL, style_path
)
flattened_style_dict: TomlDict = flatten(toml.load(str(style_path)), separator=UNIQUE_SEPARATOR)
all_flattened.update(flattened_style_dict)
return unflatten(all_flattened, separator=UNIQUE_SEPARATOR)

def load_style_from_url(self, url: str) -> Path:
"""Load a style file from a URL."""
Expand All @@ -127,13 +141,13 @@ def load_style_from_url(self, url: str) -> Path:
raise FileNotFoundError(f"Error {response} fetching style URL {url}")

contents = response.text
style_path = self.cache_dir / "cached_style.toml"
style_path = self.cache_dir / f"{slugify(url)}.toml"
self.cache_dir.mkdir(parents=True, exist_ok=True)
style_path.write_text(contents)
return style_path

@classmethod
def get_singleton(cls):
def get_singleton(cls) -> "NitpickConfig":
"""Init the global singleton instance of the plugin configuration, needed by all file checkers."""
if cls._singleton_instance is None:
cls._singleton_instance = cls()
Expand Down
4 changes: 4 additions & 0 deletions flake8_nitpick/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,7 @@
DEFAULT_NITPICK_STYLE_URL = f"https://raw.githubusercontent.com/andreoliwa/flake8-nitpick/master/{NITPICK_STYLE_TOML}"
ROOT_PYTHON_FILES = ("setup.py", "manage.py", "autoapp.py")
ROOT_FILES = ("requirements*.txt", "Pipfile") + ROOT_PYTHON_FILES

#: Special unique separator for :py:meth:`flatten()` and :py:meth:`unflatten()`,
# to avoid collision with existing key values (e.g. the default dot separator "." can be part of a pyproject.toml key).
UNIQUE_SEPARATOR = "$#@"
10 changes: 5 additions & 5 deletions flake8_nitpick/files/base.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"""Base file checker."""
from pathlib import Path

from flake8_nitpick.types import YieldFlake8Error
from flake8_nitpick.types import TomlDict, YieldFlake8Error
from flake8_nitpick.utils import NitpickMixin


Expand All @@ -20,10 +20,10 @@ def __init__(self) -> None:
self.file_path: Path = self.config.root_dir / self.file_name

# Configuration for this file as a TOML dict, taken from the style file.
self.file_toml = self.config.style_toml.get(self.toml_key, {})
self.file_dict: TomlDict = self.config.style_dict.get(self.toml_key, {})

# Nitpick configuration for this file as a TOML dict, taken from the style file.
self.nitpick_file_toml = self.config.nitpick_toml.get("files", {}).get(self.file_name, {})
self.nitpick_file_dict: TomlDict = self.config.nitpick_dict.get("files", {}).get(self.file_name, {})

@property
def toml_key(self):
Expand All @@ -36,13 +36,13 @@ def check_exists(self) -> YieldFlake8Error:
The file should exist when there is any rule configured for it in the style file,
TODO: add this to the docs
"""
should_exist: bool = self.config.files.get(self.toml_key, bool(self.file_toml or self.nitpick_file_toml))
should_exist: bool = self.config.files.get(self.toml_key, bool(self.file_dict or self.nitpick_file_dict))
file_exists = self.file_path.exists()

if should_exist and not file_exists:
suggestion = self.suggest_initial_contents()
phrases = ["Missing file"]
missing_message = self.nitpick_file_toml.get("missing_message", "")
missing_message = self.nitpick_file_dict.get("missing_message", "")
if missing_message:
phrases.append(missing_message)
if suggestion:
Expand Down
6 changes: 3 additions & 3 deletions flake8_nitpick/files/pre_commit.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ class PreCommitFile(BaseFile):

def suggest_initial_contents(self) -> str:
"""Suggest the initial content for this missing file."""
suggested = self.file_toml.copy()
suggested = self.file_dict.copy()
for repo in suggested.get(self.KEY_REPOS, []):
repo[self.KEY_HOOKS] = yaml.load(repo[self.KEY_HOOKS])
return yaml.dump(suggested, default_flow_style=False)
Expand All @@ -35,7 +35,7 @@ def check_rules(self) -> YieldFlake8Error:

actual_root = actual.copy()
actual_root.pop(self.KEY_REPOS, None)
expected_root = self.file_toml.copy()
expected_root = self.file_dict.copy()
expected_root.pop(self.KEY_REPOS, None)
for diff_type, key, values in dictdiffer.diff(expected_root, actual_root):
if diff_type == dictdiffer.REMOVE:
Expand All @@ -48,7 +48,7 @@ def check_rules(self) -> YieldFlake8Error:
def check_repos(self, actual: Dict[str, Any]):
"""Check the repositories configured in pre-commit."""
actual_repos: List[dict] = actual[self.KEY_REPOS] or []
expected_repos: List[dict] = self.file_toml.get(self.KEY_REPOS, [])
expected_repos: List[dict] = self.file_dict.get(self.KEY_REPOS, [])
for index, expected_repo_dict in enumerate(expected_repos):
repo_name = expected_repo_dict.get(self.KEY_REPO)
if not repo_name:
Expand Down
4 changes: 2 additions & 2 deletions flake8_nitpick/files/pyproject_toml.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@ class PyProjectTomlFile(BaseFile):

def check_rules(self) -> YieldFlake8Error:
"""Check missing key/value pairs in pyproject.toml."""
actual = flatten(self.config.pyproject_toml)
expected = flatten(self.file_toml)
actual = flatten(self.config.pyproject_dict)
expected = flatten(self.file_dict)
if expected.items() <= actual.items():
return []

Expand Down
8 changes: 4 additions & 4 deletions flake8_nitpick/files/setup_cfg.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,13 +28,13 @@ def suggest_initial_contents(self) -> str:

def get_missing_output(self, actual_sections: Set[str] = None) -> str:
"""Get a missing output string example from the missing sections in setup.cfg."""
self.expected_sections = set(self.file_toml.keys())
self.expected_sections = set(self.file_dict.keys())
self.missing_sections = self.expected_sections - (actual_sections or set())

if self.missing_sections:
missing_cfg = ConfigParser()
for section in sorted(self.missing_sections):
missing_cfg[section] = self.file_toml[section]
missing_cfg[section] = self.file_dict[section]
return self.get_example_cfg(missing_cfg)
return ""

Expand All @@ -43,7 +43,7 @@ def check_rules(self) -> YieldFlake8Error:
if not self.file_path.exists():
return

self.comma_separated_values = set(self.nitpick_file_toml.pop(self.COMMA_SEPARATED_VALUES, []))
self.comma_separated_values = set(self.nitpick_file_dict.pop(self.COMMA_SEPARATED_VALUES, []))

setup_cfg = ConfigParser()
setup_cfg.read_file(self.file_path.open())
Expand All @@ -55,7 +55,7 @@ def check_rules(self) -> YieldFlake8Error:

generators = []
for section in self.expected_sections - self.missing_sections:
expected_dict = self.file_toml[section]
expected_dict = self.file_dict[section]
actual_dict = dict(setup_cfg[section])
for diff_type, key, values in dictdiffer.diff(expected_dict, actual_dict):
if diff_type == dictdiffer.CHANGE:
Expand Down
3 changes: 2 additions & 1 deletion flake8_nitpick/types.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
"""Types."""
from pathlib import Path
from typing import Any, Generator, List, Tuple, Type, Union
from typing import Any, Dict, Generator, List, Tuple, Type, Union

PathOrStr = Union[Path, str]
TomlDict = Dict[str, Any]
Flake8Error = Tuple[int, int, str, Type]
YieldFlake8Error = Union[List, Generator[Flake8Error, Any, Any]]
23 changes: 22 additions & 1 deletion poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ toml = "*"
requests = "*"
dictdiffer = "*"
pyyaml = "*"
python-slugify = "*"

[tool.poetry.dev-dependencies]
flake8-blind-except = "*"
Expand Down
11 changes: 7 additions & 4 deletions tests/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,20 +22,18 @@ class ProjectMock:
original_errors: List[Flake8Error]
errors: Set[str]

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

def __init__(self, pytest_request: FixtureRequest, **kwargs) -> None:
"""Create the root dir and make it the current dir (needed by NitpickChecker)."""
self.root_dir: Path = TEMP_ROOT_PATH / pytest_request.node.name
self.root_dir.mkdir()
os.chdir(str(self.root_dir))

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

self.files_to_lint: List[Path] = []

if kwargs.get("setup_py", True):
self.save_file("setup.py", "x = 1")
if kwargs.get("pyproject_toml", True):
self.pyproject_toml("")

def create_symlink_to_fixture(self, file_name: str) -> "ProjectMock":
"""Create a symlink to a fixture file inside the temp dir, instead of creating a file."""
Expand Down Expand Up @@ -78,6 +76,11 @@ def style(self, file_contents: str) -> "ProjectMock":
"""Save the default style file."""
return self.save_file(NITPICK_STYLE_TOML, file_contents)

def named_style(self, file_name: PathOrStr, file_contents: str) -> "ProjectMock":
"""Save a style file with a name. Add the .toml extension if needed."""
clean_file_name = file_name if str(file_name).endswith(".toml") else f"{file_name}.toml"
return self.save_file(clean_file_name, file_contents)

def setup_cfg(self, file_contents: str) -> "ProjectMock":
"""Save setup.cfg."""
return self.save_file(SetupCfgFile.file_name, file_contents)
Expand Down
Loading

0 comments on commit 22505ce

Please sign in to comment.