Skip to content

Commit

Permalink
feat: validate configuration of JSON files
Browse files Browse the repository at this point in the history
  • Loading branch information
andreoliwa committed Aug 21, 2019
1 parent af6d0e5 commit e1192a4
Show file tree
Hide file tree
Showing 18 changed files with 209 additions and 113 deletions.
6 changes: 4 additions & 2 deletions docs/config_files.rst
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,10 @@ JSON files

Checker for any JSON file.

First, configure the list of files to be checked in the :ref:`[nitpick.JsonFile] section <nitpick-jsonfile>`.
First, configure the list of files to be checked in the :ref:`[nitpick.JSONFile] section <nitpick-jsonfile>`.

Then add the configuration for the file name you just declared.

Example: :ref:`the default config for package.json <default-package-json>`.

If a JSON file is configured on ``[nitpick.JSONFile] file_names``, then a configuration for it should exist.
Otherwise, a style validation error will be raised.
2 changes: 1 addition & 1 deletion docs/defaults.rst
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,7 @@ Content of `styles/package-json.toml <https://raw.githubusercontent.com/andreoli

.. code-block:: toml
[nitpick.JsonFile]
[nitpick.JSONFile]
file_names = ["package.json"]
["package.json"]
Expand Down
4 changes: 2 additions & 2 deletions docs/generate_rst.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
from nitpick import __version__
from nitpick.constants import RAW_GITHUB_CONTENT_BASE_URL
from nitpick.files.base import BaseFile
from nitpick.files.json import JsonFile
from nitpick.files.json import JSONFile
from nitpick.files.pre_commit import PreCommitFile
from nitpick.files.pyproject_toml import PyProjectTomlFile
from nitpick.files.setup_cfg import SetupCfgFile
Expand Down Expand Up @@ -46,7 +46,7 @@
"python37.toml": "Python 3.7",
}
)
file_classes = [PyProjectTomlFile, SetupCfgFile, PreCommitFile, JsonFile]
file_classes = [PyProjectTomlFile, SetupCfgFile, PreCommitFile, JSONFile]

divider = ".. auto-generated-from-here"
docs_dir = Path(__file__).parent.absolute() # type: Path
Expand Down
4 changes: 2 additions & 2 deletions docs/nitpick_section.rst
Original file line number Diff line number Diff line change
Expand Up @@ -76,8 +76,8 @@ If a key/value pair appears in more than one sub-style, it will be overridden; t

.. _nitpick-jsonfile:

[nitpick.JsonFile]
[nitpick.JSONFile]
------------------

Configure the list of filenames that should be checked by the :py:class:`nitpick.files.json.JsonFile` class.
Configure the list of filenames that should be checked by the :py:class:`nitpick.files.json.JSONFile` class.
See :ref:`the default package.json style <default-package-json>` for an example of usage.
7 changes: 7 additions & 0 deletions docs/source/nitpick.fields.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
nitpick.fields module
=====================

.. automodule:: nitpick.fields
:members:
:undoc-members:
:show-inheritance:
2 changes: 1 addition & 1 deletion docs/source/nitpick.rst
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,11 @@ Submodules
nitpick.config
nitpick.constants
nitpick.exceptions
nitpick.fields
nitpick.formats
nitpick.generic
nitpick.mixin
nitpick.plugin
nitpick.schemas
nitpick.style
nitpick.typedefs
nitpick.validators
7 changes: 0 additions & 7 deletions docs/source/nitpick.validators.rst

This file was deleted.

73 changes: 73 additions & 0 deletions src/nitpick/fields.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
"""Custom Marshmallow fields and validators."""
import json

from marshmallow import ValidationError, fields
from marshmallow.fields import Dict, Field, List, Nested, String
from marshmallow.validate import Length

from nitpick.generic import pretty_exception

__all__ = ("Dict", "List", "String", "Nested", "Field")


def is_valid_json(json_string: str) -> bool:
"""Validate the string as JSON."""
try:
json.loads(json_string)
except json.JSONDecodeError as err:
raise ValidationError(pretty_exception(err, "Invalid JSON"))
return True


class TrimmedLength(Length): # pylint: disable=too-few-public-methods
"""Trim the string before validating the length."""

def __call__(self, value):
"""Validate the trimmed string."""
return super().__call__(value.strip())


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

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


class JSONString(fields.String):
"""A string field with valid JSON content."""

def __init__(self, **kwargs):
validate = kwargs.pop("validate", [])
validate.append(is_valid_json)
super().__init__(validate=validate, **kwargs)


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(FilledString(required=True, allow_none=False))
return FilledString()


def validate_section_dot_field(section_field: str) -> bool:
"""Validate if the combinatio section/field has a dot separating them."""
# FIXME: add tests for these situations
common = "Use this format: section_name.field_name"
if "." not in section_field:
raise ValidationError("Dot is missing. {}".format(common))
parts = section_field.split(".")
if len(parts) > 2:
raise ValidationError("There's more than one dot. {}".format(common))
if not parts[0].strip():
raise ValidationError("Empty section name. {}".format(common))
if not parts[1].strip():
raise ValidationError("Empty field name. {}".format(common))
return True


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
10 changes: 9 additions & 1 deletion src/nitpick/files/base.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
"""Base class for file checkers."""
import abc
from pathlib import Path
from typing import Generator, List, Set, Type
from typing import Generator, List, Optional, Set, Type

import jmespath
from marshmallow import Schema

from nitpick import Nitpick
from nitpick.formats import TomlFormat
Expand All @@ -24,6 +25,13 @@ class BaseFile(NitpickMixin, metaclass=abc.ABCMeta):
file_dict = {} # type: JsonDict
nitpick_file_dict = {} # type: JsonDict

#: Nested validation field for this file, to be applied in runtime when the validation schema is rebuilt.
#: Useful when you have a strict configuration for a file type (e.g. :py:class:`nitpick.files.json.JSONFile`).
nested_field = None # type: Optional[Schema]

#: ``kwargs`` to be passes to the nested Marshmallow field above.
nested_field_kwargs = {} # type: JsonDict

fixed_name_classes = set() # type: Set[Type[BaseFile]]
dynamic_name_classes = set() # type: Set[Type[BaseFile]]

Expand Down
38 changes: 29 additions & 9 deletions src/nitpick/files/json.py
Original file line number Diff line number Diff line change
@@ -1,30 +1,45 @@
"""JSON files."""
import json
import logging

from sortedcontainers import SortedDict

from nitpick import fields
from nitpick.files.base import BaseFile
from nitpick.formats import JsonFormat
from nitpick.generic import flatten, unflatten
from nitpick.schemas import BaseNitpickSchema
from nitpick.typedefs import JsonDict, YieldFlake8Error

KEY_CONTAINS_KEYS = "contains_keys"
KEY_CONTAINS_JSON = "contains_json"
LOGGER = logging.getLogger(__name__)


class JsonFile(BaseFile):
class JSONFileSchema(BaseNitpickSchema):
"""Validation schema for any JSON file added to the style."""

contains_keys = fields.List(fields.FilledString)
contains_json = fields.Dict(fields.FilledString, fields.JSONString)


class JSONFile(BaseFile):
"""Checker for any JSON file.
First, configure the list of files to be checked in the :ref:`[nitpick.JsonFile] section <nitpick-jsonfile>`.
First, configure the list of files to be checked in the :ref:`[nitpick.JSONFile] section <nitpick-jsonfile>`.
Then add the configuration for the file name you just declared.
Example: :ref:`the default config for package.json <default-package-json>`.
If a JSON file is configured on ``[nitpick.JSONFile] file_names``, then a configuration for it should exist.
Otherwise, a style validation error will be raised.
"""

has_multiple_files = True
error_base_number = 340

nested_field = JSONFileSchema

SOME_VALUE_PLACEHOLDER = "<some value here>"

def check_rules(self) -> YieldFlake8Error:
Expand Down Expand Up @@ -57,10 +72,15 @@ def _check_contained_keys(self) -> YieldFlake8Error:

def _check_contained_json(self) -> YieldFlake8Error:
actual_fmt = JsonFormat(path=self.file_path)
expected = {
# FIXME: deal with invalid JSON
# TODO: accept key as a jmespath expression, value is valid JSON
key: json.loads(json_string)
for key, json_string in (self.file_dict.get(KEY_CONTAINS_JSON) or {}).items()
}
expected = {}
# TODO: accept key as a jmespath expression, value is valid JSON
for key, json_string in (self.file_dict.get(KEY_CONTAINS_JSON) or {}).items():
try:
expected[key] = json.loads(json_string)
except json.JSONDecodeError as err:
# This should not happen, because the style was already validated before.
# Maybe the NIP??? code was disabled by the user?
LOGGER.error("%s on %s while checking %s", err, KEY_CONTAINS_JSON, self.file_path)
continue

yield from self.warn_missing_different(JsonFormat(data=actual_fmt.as_data).compare_with_dictdiffer(expected))
5 changes: 5 additions & 0 deletions src/nitpick/generic.py
Original file line number Diff line number Diff line change
Expand Up @@ -207,3 +207,8 @@ def is_url(url: str) -> bool:
True
"""
return url.startswith("http")


def pretty_exception(err: Exception, message: str):
"""Return a pretty error message with the full path of the Exception."""
return "{} ({}.{}: {})".format(message, err.__module__, err.__class__.__name__, str(err))
61 changes: 12 additions & 49 deletions src/nitpick/schemas.py
Original file line number Diff line number Diff line change
@@ -1,21 +1,14 @@
"""Marshmallow schemas."""
from typing import Dict

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

from nitpick import fields
from nitpick.constants import READ_THE_DOCS_URL
from nitpick.files.setup_cfg import SetupCfgFile
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:
Expand All @@ -35,36 +28,6 @@ def flatten_marshmallow_errors(errors: Dict) -> str:
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 validate_section_dot_field(section_field: str) -> bool:
"""Validate if the combinatio section/field has a dot separating them."""
# FIXME: add tests for these situations
common = "Use this format: section_name.field_name"
if "." not in section_field:
raise ValidationError("Dot is missing. {}".format(common))
parts = section_field.split(".")
if len(parts) > 2:
raise ValidationError("There's more than one dot. {}".format(common))
if not parts[0].strip():
raise ValidationError("Empty section name. {}".format(common))
if not parts[1].strip():
raise ValidationError("Empty field name. {}".format(common))
return True


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


def help_message(sentence: str, help_page: str) -> str:
"""Show help with the documentation URL on validation errors."""
clean_sentence = sentence.strip(" .")
Expand All @@ -82,19 +45,19 @@ class ToolNitpickSectionSchema(BaseNitpickSchema):

error_messages = {"unknown": help_message("Unknown configuration", "tool_nitpick_section.html")}

style = PolyField(deserialization_schema_selector=string_or_list_field)
style = PolyField(deserialization_schema_selector=fields.string_or_list_field)


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

error_messages = {"unknown": help_message("Unknown configuration", "nitpick_section.html#nitpick-styles")}

include = PolyField(deserialization_schema_selector=string_or_list_field)
include = PolyField(deserialization_schema_selector=fields.string_or_list_field)


class NitpickJsonFileSectionSchema(BaseNitpickSchema):
"""Validation schema for the ``[nitpick.JsonFile]`` section on the style file."""
class NitpickJSONFileSectionSchema(BaseNitpickSchema):
"""Validation schema for the ``[nitpick.JSONFile]`` section on the style file."""

error_messages = {"unknown": help_message("Unknown configuration", "nitpick_section.html#nitpick-jsonfile")}

Expand All @@ -106,28 +69,28 @@ class SetupCfgSchema(BaseNitpickSchema):

error_messages = {"unknown": help_message("Unknown configuration", "nitpick_section.html#comma-separated-values")}

comma_separated_values = fields.List(fields.String(validate=validate_section_dot_field))
comma_separated_values = fields.List(fields.String(validate=fields.validate_section_dot_field))


class NitpickFilesSectionSchema(BaseNitpickSchema):
"""Validation schema for the ``[nitpick.files]`` section on the style file."""

error_messages = {"unknown": help_message("Unknown file", "nitpick_section.html#nitpick-files")}

absent = fields.Dict(NotEmptyString(), fields.String())
present = fields.Dict(NotEmptyString(), fields.String())
absent = fields.Dict(fields.FilledString, fields.String())
present = fields.Dict(fields.FilledString, fields.String())
# TODO: load this schema dynamically, then add this next field setup_cfg
setup_cfg = fields.Nested(SetupCfgSchema, data_key=SetupCfgFile.file_name)


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

minimum_version = NotEmptyString()
minimum_version = fields.FilledString()
styles = fields.Nested(NitpickStylesSectionSchema)
files = fields.Nested(NitpickFilesSectionSchema)
# TODO: load this schema dynamically, then add this next field JsonFile
JsonFile = fields.Nested(NitpickJsonFileSectionSchema)
# TODO: load this schema dynamically, then add this next field JSONFile
JSONFile = fields.Nested(NitpickJSONFileSectionSchema)


class BaseStyleSchema(Schema):
Expand Down
Loading

0 comments on commit e1192a4

Please sign in to comment.