diff --git a/docs/defaults.rst b/docs/defaults.rst index e6d779d4..4a393648 100644 --- a/docs/defaults.rst +++ b/docs/defaults.rst @@ -193,8 +193,8 @@ Content of `styles/poetry.toml None: self.init_errors = [] # type: List[NitpickError] + self.style_errors = [] # type: List[NitpickError] @classmethod def create_app(cls) -> "Nitpick": @@ -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) diff --git a/src/nitpick/config.py b/src/nitpick/config.py index 351b4dde..31908cee 100644 --- a/src/nitpick/config.py +++ b/src/nitpick/config.py @@ -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 @@ -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 diff --git a/src/nitpick/exceptions.py b/src/nitpick/exceptions.py index 2737ecf8..deb6baf2 100644 --- a/src/nitpick/exceptions.py +++ b/src/nitpick/exceptions.py @@ -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: diff --git a/src/nitpick/files/base.py b/src/nitpick/files/base.py index 64f6988a..3b3501c4 100644 --- a/src/nitpick/files/base.py +++ b/src/nitpick/files/base.py @@ -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() diff --git a/src/nitpick/files/setup_cfg.py b/src/nitpick/files/setup_cfg.py index 5e3bab78..e4775f37 100644 --- a/src/nitpick/files/setup_cfg.py +++ b/src/nitpick/files/setup_cfg.py @@ -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 ` config file.""" file_name = "setup.cfg" error_base_number = 320 - schema = SetupCfgSchema COMMA_SEPARATED_VALUES = "comma_separated_values" expected_sections = set() # type: Set[str] diff --git a/src/nitpick/mixin.py b/src/nitpick/mixin.py index bf007a4f..3447b35b 100644 --- a/src/nitpick/mixin.py +++ b/src/nitpick/mixin.py @@ -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: @@ -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 - ) diff --git a/src/nitpick/plugin.py b/src/nitpick/plugin.py index 614465da..cdf88298 100644 --- a/src/nitpick/plugin.py +++ b/src/nitpick/plugin.py @@ -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) @@ -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() @@ -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 diff --git a/src/nitpick/schemas.py b/src/nitpick/schemas.py index 86d40b72..14b03dfd 100644 --- a/src/nitpick/schemas.py +++ b/src/nitpick/schemas.py @@ -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 @@ -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): @@ -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) diff --git a/src/nitpick/style.py b/src/nitpick/style.py index bce3dfe6..9df4620e 100644 --- a/src/nitpick/style.py +++ b/src/nitpick/style.py @@ -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 @@ -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.""" @@ -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) @@ -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.""" diff --git a/styles/poetry.toml b/styles/poetry.toml index 7e455104..bec0c4fc 100644 --- a/styles/poetry.toml +++ b/styles/poetry.toml @@ -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" diff --git a/styles/pre-commit/main.toml b/styles/pre-commit/main.toml index c74b83b5..ae36bc71 100644 --- a/styles/pre-commit/main.toml +++ b/styles/pre-commit/main.toml @@ -1,5 +1,5 @@ # 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'" diff --git a/tests/test_pyproject_toml.py b/tests/test_pyproject_toml.py index 43b2280b..423a9ed9 100644 --- a/tests/test_pyproject_toml.py +++ b/tests/test_pyproject_toml.py @@ -12,7 +12,7 @@ def test_suggest_initial_contents(request): """Suggest poetry init when pyproject.toml does not exist.""" ProjectMock(request, pyproject_toml=False).style( """ - [nitpick.files."pyproject.toml"] - "missing_message" = "Do something" + [nitpick.files.present] + "pyproject.toml" = "Do something" """ - ).flake8().assert_errors_contain("NIP311 File {} was not found. Do something".format(PyProjectTomlFile.file_name)) + ).flake8().assert_errors_contain("NIP103 File {} should exist: Do something".format(PyProjectTomlFile.file_name)) diff --git a/tests/test_setup_cfg.py b/tests/test_setup_cfg.py index 59cd1f5c..139f8231 100644 --- a/tests/test_setup_cfg.py +++ b/tests/test_setup_cfg.py @@ -41,8 +41,8 @@ def test_suggest_initial_contents(request): """Suggest contents when setup.cfg does not exist.""" ProjectMock(request).style( """ - [nitpick.files."setup.cfg"] - "missing_message" = "Do something here" + [nitpick.files.present] + "setup.cfg" = "Do something here" ["setup.cfg".mypy] ignore_missing_imports = true @@ -53,9 +53,9 @@ def test_suggest_initial_contents(request): ["setup.cfg".flake8] max-line-length = 120 """ - ).flake8().assert_single_error( + ).flake8().assert_errors_contain( """ - NIP321 File setup.cfg was not found. Do something here. Create it with this content:\x1b[92m + NIP321 File setup.cfg was not found. Create it with this content:\x1b[92m [flake8] max-line-length = 120 @@ -64,7 +64,10 @@ def test_suggest_initial_contents(request): [mypy] ignore_missing_imports = True\x1b[0m - """ + """, + 2, + ).assert_errors_contain( + "NIP103 File setup.cfg should exist: Do something here" ) diff --git a/tests/test_style.py b/tests/test_style.py index 35819a01..7ea37c98 100644 --- a/tests/test_style.py +++ b/tests/test_style.py @@ -520,7 +520,7 @@ def test_invalid_toml(request): def test_invalid_nitpick_files(request): """Invalid [nitpick.files] section.""" ProjectMock(request).named_style( - "some/sub/style", + "some_style", """ [xxx] wrong = "section" @@ -534,12 +534,17 @@ def test_invalid_nitpick_files(request): ).pyproject_toml( """ [tool.nitpick] - style = ["some/sub/style", "wrong_files"] + style = ["some_style", "wrong_files"] """ ).flake8().assert_errors_contain( """ - NIP001 File some/sub/style has an incorrect style. Invalid TOML:\x1b[92m + NIP001 File some_style has an incorrect style. Invalid config:\x1b[92m xxx: Unknown field.\x1b[0m + """ + ).assert_errors_contain( + """ + NIP001 File wrong_files has an incorrect style. Invalid config:\x1b[92m + nitpick.files.whatever: Unknown field.\x1b[0m """, - 1, # FIXME: it should be 2; the file "whatever" is invalid + 2, )