Skip to content

Commit

Permalink
feat: validate [nitpick.files.present] and [nitpick.files.absent]
Browse files Browse the repository at this point in the history
  • Loading branch information
andreoliwa committed Aug 20, 2019
1 parent 0485ebf commit ab068b5
Show file tree
Hide file tree
Showing 17 changed files with 145 additions and 89 deletions.
8 changes: 4 additions & 4 deletions docs/defaults.rst
Original file line number Diff line number Diff line change
Expand Up @@ -193,8 +193,8 @@ Content of `styles/poetry.toml <https://raw.githubusercontent.com/andreoliwa/nit

.. code-block:: toml
[nitpick.files."pyproject.toml"]
missing_message = "Install poetry and run 'poetry init' to create it"
[nitpick.files.present]
"pyproject.toml" = "Install poetry and run 'poetry init' to create it"
Bash_
-----
Expand Down Expand Up @@ -259,8 +259,8 @@ Content of `styles/pre-commit/main.toml <https://raw.githubusercontent.com/andre
# See https://pre-commit.com for more information
# See https://pre-commit.com/hooks.html for more hooks
[nitpick.files."pre-commit-config.yaml"]
missing_message = "Create the file with the contents below, then run 'pre-commit install'"
[nitpick.files.present]
".pre-commit-config.yaml" = "Create the file with the contents below, then run 'pre-commit install'"
pre-commit_ (Python hooks)
--------------------------
Expand Down
5 changes: 3 additions & 2 deletions docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,9 @@ Useful if you maintain multiple projects and want to use the same configs in all

This project is still a work in progress, so the API is not fully defined:

- :ref:`the-style-file` syntax might have changes before the 1.0 stable release.
- The numbers in the ``NIP*`` error codes might change; don't fully rely on them.
- :ref:`the-style-file` syntax might have changes before the 1.0 stable release;
- The numbers in the ``NIP*`` error codes might change; don't fully rely on them;
- See also :ref:`breaking-changes`.

.. toctree::
:caption: Contents:
Expand Down
29 changes: 29 additions & 0 deletions docs/styles.rst
Original file line number Diff line number Diff line change
Expand Up @@ -96,3 +96,32 @@ To enforce all your projects to ignore missing imports, add this to your `nitpic
["setup.cfg".mypy]
ignore_missing_imports = true
.. _breaking-changes:

Breaking style changes
----------------------

.. warning::

Below are the breaking changes in the style before the API is stable.
If your style was working in a previous version and now it's not, check below.

``missing_message`` key was removed
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

``missing_message`` was removed. Use ``[nitpick.files.present]`` now.

Before:

.. code-block:: toml
[nitpick.files."pyproject.toml"]
missing_message = "Install poetry and run 'poetry init' to create it"
Now:

.. code-block:: toml
[nitpick.files.present]
"pyproject.toml" = "Install poetry and run 'poetry init' to create it"
11 changes: 10 additions & 1 deletion src/nitpick/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
import click

from nitpick.constants import CACHE_DIR_NAME, ERROR_PREFIX, MANAGE_PY, PROJECT_NAME, ROOT_FILES, ROOT_PYTHON_FILES
from nitpick.exceptions import NitpickError, NoPythonFile, NoRootDir
from nitpick.exceptions import NitpickError, NoPythonFile, NoRootDir, StyleError
from nitpick.generic import climb_directory_tree
from nitpick.typedefs import Flake8Error

Expand All @@ -30,6 +30,7 @@ class Nitpick:

def __init__(self) -> None:
self.init_errors = [] # type: List[NitpickError]
self.style_errors = [] # type: List[NitpickError]

@classmethod
def create_app(cls) -> "Nitpick":
Expand Down Expand Up @@ -154,3 +155,11 @@ def as_flake8_warning(nitpick_error: NitpickError) -> Flake8Error:
),
NitpickChecker,
)

def add_style_error(self, file_name: str, message: str, invalid_data: str = None) -> None:
"""Add a style error to the internal list."""
err = StyleError(file_name)
err.message = "File {} has an incorrect style. {}".format(file_name, message)
if invalid_data:
err.suggestion = invalid_data
self.style_errors.append(err)
30 changes: 14 additions & 16 deletions src/nitpick/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@
TOOL_NITPICK,
TOOL_NITPICK_JMEX,
)
from nitpick.exceptions import StyleError
from nitpick.files.pyproject_toml import PyProjectTomlFile
from nitpick.formats import TomlFormat
from nitpick.generic import search_dict, version_to_tuple
Expand All @@ -36,37 +35,36 @@ def __init__(self) -> None:
self.nitpick_section = {} # type: JsonDict
self.nitpick_files_section = {} # type: JsonDict

def validate_pyproject(self):
"""Validate the pyroject.toml against a Marshmallow schema."""
def validate_pyproject_tool_nitpick(self) -> bool:
"""Validate the ``pyroject.toml``'s ``[tool.nitpick]`` section against a Marshmallow schema."""
pyproject_path = Nitpick.current_app().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, {})
pyproject_errors = ToolNitpickSchema().validate(self.tool_nitpick_dict)
if pyproject_errors:
raise StyleError(PyProjectTomlFile.file_name, flatten_marshmallow_errors(pyproject_errors))
Nitpick.current_app().add_style_error(
PyProjectTomlFile.file_name,
"Invalid data in [{}]:".format(TOOL_NITPICK),
flatten_marshmallow_errors(pyproject_errors),
)
return False
return True

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])
if not self.validate_pyproject_tool_nitpick():
# If the project is misconfigured, don't even continue.
return

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

self.style_dict = style.merge_toml_dict()
try:
if not Nitpick.current_app().style_errors:
# Don't show duplicated errors: if there are style errors already, don't validate the merged style.
style.validate_style(MERGED_STYLE_TOML, self.style_dict)
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
1 change: 1 addition & 0 deletions src/nitpick/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ def __init__(self, root_dir: Path, *args: object) -> None:
class StyleError(NitpickError):
"""An error in a style file."""

number = 1
add_to_base_number = False

def __init__(self, style_file_name: str, *args: object) -> None:
Expand Down
9 changes: 4 additions & 5 deletions src/nitpick/files/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,16 +72,15 @@ def check_exists(self) -> YieldFlake8Error:
if config_data_exists and not file_exists:
suggestion = self.suggest_initial_contents()
phrases = [" was not found"]
# FIXME: add validation for missing_message on the BaseFileSchema
missing_message = self.nitpick_file_dict.get("missing_message", "")
if missing_message:
phrases.append(missing_message)
message = Nitpick.current_app().config.nitpick_files_section.get(self.file_name)
if message:
phrases.append(message)
if suggestion:
phrases.append("Create it with this content:")
yield self.flake8_error(1, ". ".join(phrases), suggestion)
elif not should_exist and file_exists:
# Only display this message if the style is valid.
if not Nitpick.current_app().config.has_style_errors:
if not Nitpick.current_app().style_errors:
yield self.flake8_error(2, " should be deleted")
elif file_exists:
yield from self.check_rules()
Expand Down
24 changes: 0 additions & 24 deletions src/nitpick/files/setup_cfg.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,40 +4,16 @@
from typing import Any, Dict, List, Set, Tuple

import dictdiffer
from marshmallow import Schema, ValidationError, fields

from nitpick.files.base import BaseFile
from nitpick.typedefs import YieldFlake8Error


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


class SetupCfgSchema(Schema):
"""Validation schema for setup.cfg."""

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


class SetupCfgFile(BaseFile):
"""Checker for the `setup.cfg <https://docs.python.org/3/distutils/configfile.html>` config file."""

file_name = "setup.cfg"
error_base_number = 320
schema = SetupCfgSchema
COMMA_SEPARATED_VALUES = "comma_separated_values"

expected_sections = set() # type: Set[str]
Expand Down
8 changes: 0 additions & 8 deletions src/nitpick/mixin.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ class NitpickMixin:

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

# TODO: remove this after all errors are converted to Nitpick.as_flake8_warning()
def flake8_error(self, number: int, message: str, suggestion: str = None, add_to_base_number=True) -> Flake8Error:
Expand Down Expand Up @@ -36,10 +35,3 @@ def warn_missing_different(self, comparison: Comparison, prefix_message: str = "
yield self.flake8_error(
9, "{} has different values. Use this:".format(prefix_message), comparison.diff_format.reformatted
)

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
)
18 changes: 12 additions & 6 deletions src/nitpick/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,11 @@ class NitpickChecker(NitpickMixin):

def run(self) -> YieldFlake8Error:
"""Run the check plugin."""
has_init_errors = False
for init_error in Nitpick.current_app().init_errors:
has_init_errors = True
yield Nitpick.as_flake8_warning(init_error)
if has_init_errors:
has_errors = False
for err in Nitpick.current_app().init_errors:
has_errors = True
yield Nitpick.as_flake8_warning(err)
if has_errors:
return []

current_python_file = Path(self.filename)
Expand All @@ -50,6 +50,13 @@ def run(self) -> YieldFlake8Error:
Nitpick.current_app().config.merge_styles(), self.check_files(True), self.check_files(False)
)

has_errors = False
for err in Nitpick.current_app().style_errors:
has_errors = True
yield Nitpick.as_flake8_warning(err)
if has_errors:
return []

for checker_class in get_subclasses(BaseFile):
checker = checker_class()
yield from checker.check_exists()
Expand All @@ -58,7 +65,6 @@ def run(self) -> YieldFlake8Error:

def check_files(self, present: bool) -> YieldFlake8Error:
"""Check files that should be present or absent."""
# FIXME: validate files.absent and files.present with schemas
key = "present" if present else "absent"
message = "exist" if present else "be deleted"
absent = not present
Expand Down
38 changes: 35 additions & 3 deletions src/nitpick/schemas.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
"""Marshmallow schemas."""
from typing import Dict

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

from nitpick.files.setup_cfg import SetupCfgFile
from nitpick.generic import flatten
from nitpick.validators import TrimmedLength

Expand Down Expand Up @@ -40,6 +41,22 @@ def string_or_list_field(object_dict, parent_object_dict): # pylint: disable=un
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):
Expand All @@ -65,13 +82,28 @@ class NitpickJsonFileSchema(Schema):
file_names = fields.List(fields.String)


class SetupCfgSchema(Schema):
"""Validation schema for setup.cfg."""

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


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

absent = fields.Dict(NotEmptyString(), fields.String())
present = fields.Dict(NotEmptyString(), 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 NitpickSchema(Schema):
"""Validation schema for the ``[nitpick]`` section on the style file."""

minimum_version = NotEmptyString()
styles = fields.Nested(NitpickStylesSchema)
# FIXME: validate=validate.OneOf(app.configured_file_names | {"present", "absent"})
files = fields.Dict(fields.String(), fields.Dict())
files = fields.Nested(NitpickFilesSchema)
# TODO: load this schema dynamically, then add this next field JsonFile
JsonFile = fields.Nested(NitpickJsonFileSchema)


Expand Down
13 changes: 9 additions & 4 deletions src/nitpick/style.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@
RAW_GITHUB_CONTENT_BASE_URL,
TOML_EXTENSION,
)
from nitpick.exceptions import StyleError
from nitpick.files.base import BaseFile
from nitpick.files.pyproject_toml import PyProjectTomlFile
from nitpick.formats import TomlFormat
Expand Down Expand Up @@ -76,7 +75,9 @@ def validate_style(self, style_file_name: str, original_data: JsonDict):
self.rebuild_dynamic_schema(original_data)
style_errors = self._dynamic_schema_class().validate(original_data)
if style_errors:
raise StyleError(style_file_name, flatten_marshmallow_errors(style_errors))
Nitpick.current_app().add_style_error(
style_file_name, "Invalid config:", flatten_marshmallow_errors(style_errors)
)

def include_multiple_styles(self, chosen_styles: StrOrList) -> None:
"""Include a list of styles (or just one) into this style tree."""
Expand All @@ -90,7 +91,11 @@ def include_multiple_styles(self, chosen_styles: StrOrList) -> None:
try:
toml_dict = toml.as_data
except TomlDecodeError as err:
raise StyleError(style_path.name, "{}: {}".format(err.__class__.__name__, err)) from err
Nitpick.current_app().add_style_error(
style_path.name, "Invalid TOML:", "{}: {}".format(err.__class__.__name__, err)
)
# If the TOML itself could not be parsed, we can't go on
return

self.validate_style(style_uri, toml_dict)
self._all_styles.add(toml_dict)
Expand Down Expand Up @@ -203,7 +208,7 @@ def append_field_from_file(schema_fields: Dict[str, fields.Field], subclass: Typ
"""Append a schema field with info from a config file class."""
field_name = subclass.__name__
valid_toml_key = TomlFormat.group_name_for(file_name)
schema_fields[field_name] = fields.Dict(fields.String(), attribute=valid_toml_key, data_key=valid_toml_key)
schema_fields[field_name] = fields.Dict(fields.String(), data_key=valid_toml_key)

def rebuild_dynamic_schema(self, data: JsonDict = None) -> None:
"""Rebuild the dynamic Marshmallow schema when needed, adding new fields that were found on the style."""
Expand Down
4 changes: 2 additions & 2 deletions styles/poetry.toml
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
[nitpick.files."pyproject.toml"]
missing_message = "Install poetry and run 'poetry init' to create it"
[nitpick.files.present]
"pyproject.toml" = "Install poetry and run 'poetry init' to create it"
Loading

0 comments on commit ab068b5

Please sign in to comment.