Skip to content

Commit

Permalink
feat: validate the merged style file schema
Browse files Browse the repository at this point in the history
Still not validating each file checker class

see #69
  • Loading branch information
andreoliwa committed Aug 11, 2019
1 parent b48e0a4 commit 1e31d0a
Show file tree
Hide file tree
Showing 12 changed files with 149 additions and 78 deletions.
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ sphinx:
$(MAKE)

# $(O) is meant as a shortcut for $(SPHINXOPTS).
.cache/make/sphinx: src/* docs *.rst *.md
.cache/make/sphinx: docs *.rst *.md
-rm -rf docs/source
sphinx-apidoc --force --module-first --separate --implicit-namespaces --output-dir docs/source src/
@$(SPHINXBUILD) "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
Expand Down
11 changes: 4 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,15 +113,12 @@ To enforce all your projects to ignore missing imports, add this to your `nitpic

To enforce that certain files should not exist in the project, you can add them to the style file.

[[files.absent]]
file = "myfile1.txt"

[[files.absent]]
file = "another_file.env"
message = "This is an optional extra string to display after the warning"
[nitpick.files.absent]
"some_file.txt" = "This is an optional extra string to display after the warning"
"another_file.env" = ""

Multiple files can be configured as above.
The `message` is optional.
The message is optional.

## setup.cfg

Expand Down
8 changes: 4 additions & 4 deletions poetry.lock

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

4 changes: 3 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,9 @@ python-slugify = "*"
jmespath = "*"
sortedcontainers = "*"
click = "*"
marshmallow = {version = "*", allows-prereleases = true}
# Pin marshmallow to avoid error on "pip install -U nitpick":
# marshmallow-polyfield 5.7 has requirement marshmallow>=3.0.0b10, but you'll have marshmallow 2.19.5 which is incompatible.
marshmallow = {version = ">=3.0.0b10", allows-prereleases = true}
marshmallow-polyfield = "*"

[tool.poetry.dev-dependencies]
Expand Down
92 changes: 41 additions & 51 deletions src/nitpick/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,12 @@
import logging
from pathlib import Path
from shutil import rmtree
from typing import Dict, Optional

from marshmallow import Schema, fields
from marshmallow_polyfield import PolyField
from sortedcontainers import SortedDict
from typing import Optional

from nitpick.constants import (
CACHE_DIR_NAME,
MANAGE_PY,
MERGED_STYLE_TOML,
NITPICK_MINIMUM_VERSION_JMEX,
PROJECT_NAME,
ROOT_FILES,
Expand All @@ -20,49 +17,19 @@
TOOL_NITPICK_JMEX,
)
from nitpick.exceptions import StyleError
from nitpick.files.base import BaseFile
from nitpick.files.pyproject_toml import PyProjectTomlFile
from nitpick.files.setup_cfg import SetupCfgFile
from nitpick.formats import TomlFormat
from nitpick.generic import climb_directory_tree, search_dict, version_to_tuple
from nitpick.generic import climb_directory_tree, get_subclasses, search_dict, version_to_tuple
from nitpick.mixin import NitpickMixin
from nitpick.schemas import MergedStyleSchema, ToolNitpickSchema, flatten_marshmallow_errors
from nitpick.style import Style
from nitpick.typedefs import JsonDict, PathOrStr, StrOrList, YieldFlake8Error
from nitpick.validators import TrimmedLength

LOGGER = logging.getLogger(__name__)


def detect_string_or_list(object_dict, parent_object_dict): # pylint: disable=unused-argument
"""Detect if the field is a string or a list."""
common = {"validate": TrimmedLength(min=1)}
if isinstance(object_dict, list):
return fields.List(fields.String(required=True, allow_none=False, **common))
return fields.String(**common)


class ToolNitpickSchema(Schema):
"""Validation schema for the ``[tool.nitpick]`` section on ``pyproject.toml``."""

style = PolyField(deserialization_schema_selector=detect_string_or_list, required=False)

@staticmethod
def flatten_errors(errors: Dict) -> str:
"""Flatten Marshmallow errors to a string."""
formatted = []
for field, data in SortedDict(errors).items():
if isinstance(data, list):
messages_per_field = ["{}: {}".format(field, ", ".join(data))]
elif isinstance(data, dict):
messages_per_field = [
"{}[{}]: {}".format(field, index, ", ".join(messages)) for index, messages in data.items()
]
else:
# This should never happen; if it does, let's just convert to a string
messages_per_field = [str(errors)]
formatted.append("\n".join(messages_per_field))
return "\n".join(formatted)


class NitpickConfig(NitpickMixin): # pylint: disable=too-many-instance-attributes
"""Plugin configuration, read from the project config."""

Expand All @@ -80,7 +47,6 @@ def __init__(self) -> None:
self.style_dict = {} # type: JsonDict
self.nitpick_dict = {} # type: JsonDict
self.files = {} # type: JsonDict
self.has_style_errors = False

@classmethod
def get_singleton(cls) -> "NitpickConfig":
Expand Down Expand Up @@ -146,31 +112,55 @@ def clear_cache_dir(self) -> None:
self.cache_dir = cache_root / PROJECT_NAME
rmtree(str(self.cache_dir), ignore_errors=True)

def merge_styles(self) -> YieldFlake8Error:
"""Merge one or multiple style files."""
def validate_pyproject(self):
"""Validate the pyroject.toml against a Marshmallow schema."""
pyproject_path = self.root_dir / PyProjectTomlFile.file_name # type: Path
if pyproject_path.exists():
self.pyproject_toml = TomlFormat(path=pyproject_path)
self.tool_nitpick_dict = search_dict(TOOL_NITPICK_JMEX, self.pyproject_toml.as_data, {})
schema = ToolNitpickSchema()
pyproject_errors = schema.validate(self.tool_nitpick_dict)
pyproject_errors = ToolNitpickSchema().validate(self.tool_nitpick_dict)
if pyproject_errors:
self.has_style_errors = True
yield self.style_error(
PyProjectTomlFile.file_name,
"Invalid data in [{}]:".format(TOOL_NITPICK),
schema.flatten_errors(pyproject_errors),
)
return
raise StyleError(PyProjectTomlFile.file_name, flatten_marshmallow_errors(pyproject_errors))

def validate_merged_style(self):
"""Validate the merged style file (TOML) against a Marshmallow schema."""
validation_dict = self.style_dict.copy()
for file_class in get_subclasses(BaseFile):
root_keys_to_remove = []
file_key = file_class().toml_key
if file_key:
root_keys_to_remove.append(file_key)
root_keys_to_remove.extend(
search_dict("nitpick.{}.file_names".format(file_class.__name__), validation_dict, [])
)
for key in root_keys_to_remove:
if key in validation_dict:
validation_dict.pop(key)
style_errors = MergedStyleSchema().validate(validation_dict)
if style_errors:
raise StyleError(MERGED_STYLE_TOML, flatten_marshmallow_errors(style_errors))

def merge_styles(self) -> YieldFlake8Error:
"""Merge one or multiple style files."""
try:
self.validate_pyproject()
except StyleError as err:
yield self.style_error(err.style_file_name, "Invalid data in [{}]:".format(TOOL_NITPICK), err.args[0])
return

configured_styles = self.tool_nitpick_dict.get("style", "") # type: StrOrList
style = Style(self)
try:
style.find_initial_styles(configured_styles)
except StyleError as err:
yield self.style_error(err.style_file_name.name, "Invalid TOML:", err.args[0])
yield self.style_error(err.style_file_name, "Invalid TOML:", err.args[0])

self.style_dict = style.merge_toml_dict()
try:
self.validate_merged_style()
except StyleError as err:
yield self.style_error(err.style_file_name, "Invalid data in the merged style file:", err.args[0])
return

from nitpick.plugin import NitpickChecker

Expand Down
3 changes: 1 addition & 2 deletions src/nitpick/exceptions.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
"""Nitpick exceptions."""
from pathlib import Path


class StyleError(Exception):
"""An error in a style file."""

def __init__(self, style_file_name: Path, *args: object) -> None:
def __init__(self, style_file_name: str, *args: object) -> None:
self.style_file_name = style_file_name
super().__init__(*args)
2 changes: 1 addition & 1 deletion src/nitpick/generic.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ def flatten(dict_, parent_key="", separator=".", current_lists=None) -> JsonDict

items = [] # type: List[Tuple[str, Any]]
for key, value in dict_.items():
new_key = parent_key + separator + key if parent_key else key
new_key = str(parent_key) + separator + str(key) if parent_key else key
if isinstance(value, collections.abc.MutableMapping):
items.extend(flatten(value, new_key, separator, current_lists).items())
elif isinstance(value, (list, tuple)):
Expand Down
2 changes: 2 additions & 0 deletions src/nitpick/mixin.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ class NitpickMixin:

error_base_number = 0 # type: int
error_prefix = "" # type: str
has_style_errors = False

def flake8_error(self, number: int, message: str, suggestion: str = None, add_to_base_number=True) -> Flake8Error:
"""Return a flake8 error as a tuple."""
Expand Down Expand Up @@ -43,6 +44,7 @@ def warn_missing_different(self, comparison: Comparison, prefix_message: str = "

def style_error(self, file_name: str, message: str, invalid_data: str = None) -> Flake8Error:
"""Raise a style error."""
self.has_style_errors = True
return self.flake8_error(
1, "File {} has an incorrect style. {}".format(file_name, message), invalid_data, add_to_base_number=False
)
80 changes: 80 additions & 0 deletions src/nitpick/schemas.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
"""Marshmallow schemas."""
from typing import Dict

from marshmallow import Schema, fields
from marshmallow_polyfield import PolyField
from sortedcontainers import SortedDict

from nitpick.generic import flatten
from nitpick.validators import TrimmedLength


class NotEmptyString(fields.String):
"""A string field that must not be empty even after trimmed."""

def __init__(self, **kwargs):
super().__init__(validate=TrimmedLength(min=1), **kwargs)


def flatten_marshmallow_errors(errors: Dict) -> str:
"""Flatten Marshmallow errors to a string."""
formatted = []
for field, data in SortedDict(flatten(errors)).items():
if isinstance(data, list):
messages_per_field = ["{}: {}".format(field, ", ".join(data))]
elif isinstance(data, dict):
messages_per_field = [
"{}[{}]: {}".format(field, index, ", ".join(messages)) for index, messages in data.items()
]
else:
# This should never happen; if it does, let's just convert to a string
messages_per_field = [str(errors)]
formatted.append("\n".join(messages_per_field))
return "\n".join(formatted)


def string_or_list_field(object_dict, parent_object_dict): # pylint: disable=unused-argument
"""Detect if the field is a string or a list."""
if isinstance(object_dict, list):
return fields.List(NotEmptyString(required=True, allow_none=False))
return NotEmptyString()


def boolean_or_dict_field(object_dict, parent_object_dict): # pylint: disable=unused-argument
"""Detect if the field is a boolean or a dict."""
if isinstance(object_dict, dict):
return fields.Dict
return fields.Bool


class ToolNitpickSchema(Schema):
"""Validation schema for the ``[tool.nitpick]`` section on ``pyproject.toml``."""

style = PolyField(deserialization_schema_selector=string_or_list_field)


class NitpickStylesSchema(Schema):
"""Validation schema for the ``[nitpick.styles]`` section on the style file."""

include = PolyField(deserialization_schema_selector=string_or_list_field)


class NitpickJsonFileSchema(Schema):
"""Validation schema for the ``[nitpick.JsonFile]`` section on the style file."""

file_names = fields.List(fields.String)


class NitpickSchema(Schema):
"""Validation schema for the ``[nitpick]`` section on the style file."""

minimum_version = NotEmptyString()
styles = fields.Nested(NitpickStylesSchema)
files = fields.Dict(fields.String, PolyField(deserialization_schema_selector=boolean_or_dict_field))
JsonFile = fields.Nested(NitpickJsonFileSchema)


class MergedStyleSchema(Schema):
"""Validation schema for the merged style file."""

nitpick = fields.Nested(NitpickSchema)
2 changes: 1 addition & 1 deletion src/nitpick/style.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ def include_multiple_styles(self, chosen_styles: StrOrList) -> None:
try:
self._all_styles.add(toml.as_data)
except TomlDecodeError as err:
raise StyleError(style_path, "{}: {}".format(err.__class__.__name__, err)) from err
raise StyleError(style_path.name, "{}: {}".format(err.__class__.__name__, err)) from err

sub_styles = search_dict(NITPICK_STYLES_INCLUDE_JMEX, toml.as_data, []) # type: StrOrList
if sub_styles:
Expand Down
Loading

0 comments on commit 1e31d0a

Please sign in to comment.