Skip to content

Commit

Permalink
feat: Check missing key/value pairs in pyproject.toml
Browse files Browse the repository at this point in the history
  • Loading branch information
andreoliwa committed Dec 20, 2018
1 parent 265daa5 commit 190aa6c
Show file tree
Hide file tree
Showing 4 changed files with 102 additions and 35 deletions.
71 changes: 37 additions & 34 deletions flake8_nitpick/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,36 +9,23 @@
import toml

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

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

# Constants
ERROR_PREFIX = "NIP"
PYPROJECT_TOML = "pyproject.toml"
ROOT_PYTHON_FILES = ("setup.py", "manage.py")
ROOT_FILES = (
PYPROJECT_TOML,
"setup.cfg",
"requirements*.txt",
"Pipfile",
) + ROOT_PYTHON_FILES
ROOT_PYTHON_FILES = ("setup.py", "manage.py", "autoapp.py")
ROOT_FILES = (PYPROJECT_TOML, "setup.cfg", "requirements*.txt", "Pipfile") + ROOT_PYTHON_FILES


def nitpick_error(error_number: int, error_message: str) -> NitpickError:
"""Return a nitpick error as a tuple"""
return 1, 0, f"{ERROR_PREFIX}{error_number} {error_message}", NitpickChecker


def get_subclasses(cls):
"""Recursively get subclasses of a parent class."""
subclasses = []
for subclass in cls.__subclasses__():
subclasses.append(subclass)
subclasses += get_subclasses(subclass)
return subclasses


class NitpickCache:
"""A cache file in the current dir (in .toml format), to store data that will be reused by the plugin."""

Expand Down Expand Up @@ -82,10 +69,10 @@ def __init__(self, root_dir: Path) -> None:
"""Init instance."""
pyproject_toml_file = root_dir / PYPROJECT_TOML
toml_dict = toml.load(str(pyproject_toml_file))
config = toml_dict.get("tool", {}).get("nitpick", {})
self.nitpick_config = toml_dict.get("tool", {}).get("nitpick", {})

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


@attr.s(hash=False)
Expand All @@ -110,17 +97,16 @@ def run(self):
current_python_file = Path(self.filename)
main_python_file = self.find_main_python_file(root_dir, current_python_file)
if not main_python_file:
yield nitpick_error(
100, f"No Python file was found in the root dir {root_dir}"
)
yield nitpick_error(100, f"No Python file was found in the root dir {root_dir}")
return
if current_python_file.resolve() != main_python_file.resolve():
# Only report warnings once, for the main Python file of this project.
return

config = NitpickConfig(root_dir)
for file_checker in get_subclasses(BaseChecker):
for error in file_checker(config).check_exists():
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 []
Expand Down Expand Up @@ -151,8 +137,7 @@ def find_main_python_file(self, root_dir: Path, current_file: Path) -> Path:
return main_python_file

for the_file in itertools.chain(
[root_dir / root_file for root_file in ROOT_PYTHON_FILES],
root_dir.glob("*.py"),
[root_dir / root_file for root_file in ROOT_PYTHON_FILES], root_dir.glob("*.py")
):
if the_file.exists():
found = the_file
Expand All @@ -165,47 +150,65 @@ def find_main_python_file(self, root_dir: Path, current_file: Path) -> Path:
class BaseChecker:
"""Base class for file checkers."""

filename: str
file_name: str
should_exist_default: bool

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, {})

def check_exists(self) -> Generator[List[NitpickError], Any, Any]:
"""Check if the file should exist or not."""
should_exist = self.config.files.get(self.filename, self.should_exist_default)
file_exists = (self.config.root_dir / self.filename).exists()
should_exist = self.config.files.get(self.file_name, self.should_exist_default)
file_exists = self.file_path.exists()

if should_exist and not file_exists:
yield nitpick_error(102, f"Missing file {self.filename!r}")
yield nitpick_error(102, f"Missing file {self.file_name!r}")
elif not should_exist and file_exists:
yield nitpick_error(103, f"File {self.filename!r} should be deleted")
yield nitpick_error(103, f"File {self.file_name!r} should be deleted")

def check_rules(self):
"""Check rules for this file. It should be overridden by inherited class if they need."""
return []


class PyProjectTomlChecker(BaseChecker):
"""Check pyproject.toml."""

filename = "pyproject.toml"
file_name = "pyproject.toml"
should_exist_default = True

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():
return []

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


class SetupCfgChecker(BaseChecker):
"""Check setup.cfg."""

filename = "setup.cfg"
file_name = "setup.cfg"
should_exist_default = True


class PipfileChecker(BaseChecker):
"""Check Pipfile."""

filename = "Pipfile"
file_name = "Pipfile"
should_exist_default = False


class PipfileLockChecker(BaseChecker):
"""Check Pipfile.lock."""

filename = "Pipfile.lock"
file_name = "Pipfile.lock"
should_exist_default = False
38 changes: 38 additions & 0 deletions flake8_nitpick/generic.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import collections


def get_subclasses(cls):
"""Recursively get subclasses of a parent class."""
subclasses = []
for subclass in cls.__subclasses__():
subclasses.append(subclass)
subclasses += get_subclasses(subclass)
return subclasses


def flatten(d, parent_key="", sep="."):
items = []
for k, v in d.items():
new_key = parent_key + sep + k if parent_key else k
if isinstance(v, collections.MutableMapping):
items.extend(flatten(v, new_key, sep=sep).items())
else:
items.append((new_key, v))
return dict(items)


def unflatten(d, sep="."):
items = dict()
for k, v in d.items():
keys = k.split(sep)
sub_items = items
for ki in keys[:-1]:
try:
sub_items = sub_items[ki]
except KeyError:
sub_items[ki] = dict()
sub_items = sub_items[ki]

sub_items[keys[-1]] = v

return items
26 changes: 26 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
[tool.black]
line-length = 120

[tool.poetry]
name = "flake8-nitpick"
version = "0.1.0"
Expand All @@ -24,11 +27,34 @@ pytest = "*"
twine = "*"
keyring = "*"
wheel = "*"
pre_commit = "*"
black = {version = "*", allows-prereleases = true}
pylint = "*"

[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 = "*"

[tool.nitpick."setup.cfg".flake8]
ignore = "D107,D202,D203,D401,E203,E402,E501,W503"
max-line-length = 120
Expand Down
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"]
REQUIRED = ["flake8 > 3.0.0", "attrs", "toml"]

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

0 comments on commit 190aa6c

Please sign in to comment.