diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 03cdfacc..4d04b35f 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -9,7 +9,7 @@ repos: - id: end-of-file-fixer - id: trailing-whitespace - repo: https://github.com/asottile/pyupgrade - rev: v2.6.2 + rev: v2.7.0 hooks: - id: pyupgrade - repo: https://github.com/python/black @@ -27,7 +27,7 @@ repos: hooks: - id: seed-isort-config - repo: https://github.com/pre-commit/mirrors-isort - rev: v4.3.21 + rev: v5.0.9 hooks: - id: isort - repo: https://github.com/pre-commit/pygrep-hooks diff --git a/Makefile b/Makefile index db302f9a..3c97b6f3 100644 --- a/Makefile +++ b/Makefile @@ -29,8 +29,10 @@ always-run: .PHONY: always-run pre-commit .cache/make/long-pre-commit: .pre-commit-config.yaml .pre-commit-hooks.yaml # Update and install pre-commit hooks -# TODO: isort 5.0.0 is apparently broken, so we can't autoupdate for now -# pre-commit autoupdate + @# Uncomment the lines below to autoupdate all repos except a few filtered out with egrep +# yq -r '.repos[].repo' .pre-commit-config.yaml | egrep -v -e '^local' -e mirrors-isort | \ +# sed -E -e 's/http/--repo http/g' | xargs pre-commit autoupdate + pre-commit autoupdate pre-commit install --install-hooks pre-commit install --hook-type commit-msg pre-commit gc diff --git a/docs/defaults.rst b/docs/defaults.rst index 602f5561..0603e9a8 100644 --- a/docs/defaults.rst +++ b/docs/defaults.rst @@ -137,7 +137,7 @@ Content of `styles/isort.toml ` for an example of usage. diff --git a/docs/config_files.rst b/docs/plugins.rst similarity index 74% rename from docs/config_files.rst rename to docs/plugins.rst index bc643d3a..7ba73acb 100644 --- a/docs/config_files.rst +++ b/docs/plugins.rst @@ -1,14 +1,23 @@ .. include:: targets.rst -.. _config_files: +.. _plugins: -Configuration files -=================== +Plugins +======= -Below are the currently supported configuration files. +Below are the currently included plugins. .. auto-generated-from-here -.. _pyprojecttomlfile: +.. _setupcfgplugin: + +setup.cfg +--------- + +Checker for the `setup.cfg `_ config file. + +Example: :ref:`flake8 configuration `. + +.. _pyprojecttomlplugin: pyproject.toml -------------- @@ -20,16 +29,7 @@ See also `PEP 518 `_. Example: :ref:`the Python 3.7 default `. There are many other examples in :ref:`defaults`. -.. _setupcfgfile: - -setup.cfg ---------- - -Checker for the `setup.cfg `_ config file. - -Example: :ref:`flake8 configuration `. - -.. _precommitfile: +.. _precommitplugin: .pre-commit-config.yaml ----------------------- @@ -38,7 +38,7 @@ Checker for the `.pre-commit-config.yaml `. -.. _jsonfile: +.. _jsonplugin: JSON files ---------- @@ -52,3 +52,20 @@ Example: :ref:`the default config for 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. + +.. _textplugin: + +Text files +---------- + +Checker for text files. + +To check if ``some.txt`` file contains the lines ``abc`` and ``def`` (in any order): + +.. code-block:: toml + + [["some.txt".contains]] + line = "abc" + + [["some.txt".contains]] + line = "def" diff --git a/docs/source/nitpick.plugins.rst b/docs/source/nitpick.plugins.rst index d5d8a4f5..6fea4b6e 100644 --- a/docs/source/nitpick.plugins.rst +++ b/docs/source/nitpick.plugins.rst @@ -17,3 +17,4 @@ Submodules nitpick.plugins.pre_commit nitpick.plugins.pyproject_toml nitpick.plugins.setup_cfg + nitpick.plugins.text diff --git a/docs/source/nitpick.plugins.text.rst b/docs/source/nitpick.plugins.text.rst new file mode 100644 index 00000000..86780c9f --- /dev/null +++ b/docs/source/nitpick.plugins.text.rst @@ -0,0 +1,7 @@ +nitpick.plugins.text module +=========================== + +.. automodule:: nitpick.plugins.text + :members: + :undoc-members: + :show-inheritance: diff --git a/poetry.lock b/poetry.lock index 6e5d1aa3..3d5a0252 100644 --- a/poetry.lock +++ b/poetry.lock @@ -195,7 +195,7 @@ description = "File identification library for Python" name = "identify" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,>=2.7" -version = "1.4.21" +version = "1.4.23" [package.extras] license = ["editdistance"] @@ -364,12 +364,12 @@ description = "A lightweight library for converting complex datatypes to and fro name = "marshmallow" optional = false python-versions = ">=3.5" -version = "3.6.1" +version = "3.7.0" [package.extras] -dev = ["pytest", "pytz", "simplejson", "mypy (0.770)", "flake8 (3.8.2)", "flake8-bugbear (20.1.4)", "pre-commit (>=2.4,<3.0)", "tox"] -docs = ["sphinx (3.0.4)", "sphinx-issues (1.2.0)", "alabaster (0.7.12)", "sphinx-version-warning (1.1.2)", "autodocsumm (0.1.13)"] -lint = ["mypy (0.770)", "flake8 (3.8.2)", "flake8-bugbear (20.1.4)", "pre-commit (>=2.4,<3.0)"] +dev = ["pytest", "pytz", "simplejson", "mypy (0.782)", "flake8 (3.8.3)", "flake8-bugbear (20.1.4)", "pre-commit (>=2.4,<3.0)", "tox"] +docs = ["sphinx (3.1.2)", "sphinx-issues (1.2.0)", "alabaster (0.7.12)", "sphinx-version-warning (1.1.2)", "autodocsumm (0.1.13)"] +lint = ["mypy (0.782)", "flake8 (3.8.3)", "flake8-bugbear (20.1.4)", "pre-commit (>=2.4,<3.0)"] tests = ["pytest", "pytz", "simplejson"] [[package]] @@ -985,8 +985,8 @@ icecream = [ {file = "icecream-2.0.0.tar.gz", hash = "sha256:434e14a50da01f9dc1e5757efec7613db5df048ebdcecd460236db41688f779a"}, ] identify = [ - {file = "identify-1.4.21-py2.py3-none-any.whl", hash = "sha256:dac33eff90d57164e289fb20bf4e131baef080947ee9bf45efcd0da8d19064bf"}, - {file = "identify-1.4.21.tar.gz", hash = "sha256:c4d07f2b979e3931894170a9e0d4b8281e6905ea6d018c326f7ffefaf20db680"}, + {file = "identify-1.4.23-py2.py3-none-any.whl", hash = "sha256:882c4b08b4569517b5f2257ecca180e01f38400a17f429f5d0edff55530c41c7"}, + {file = "identify-1.4.23.tar.gz", hash = "sha256:f89add935982d5bc62913ceee16c9297d8ff14b226e9d3072383a4e38136b656"}, ] idna = [ {file = "idna-2.10-py2.py3-none-any.whl", hash = "sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0"}, @@ -1086,8 +1086,8 @@ markupsafe = [ {file = "MarkupSafe-1.1.1.tar.gz", hash = "sha256:29872e92839765e546828bb7754a68c418d927cd064fd4708fab9fe9c8bb116b"}, ] marshmallow = [ - {file = "marshmallow-3.6.1-py2.py3-none-any.whl", hash = "sha256:9aa20f9b71c992b4782dad07c51d92884fd0f7c5cb9d3c737bea17ec1bad765f"}, - {file = "marshmallow-3.6.1.tar.gz", hash = "sha256:35ee2fb188f0bd9fc1cf9ac35e45fd394bd1c153cee430745a465ea435514bd5"}, + {file = "marshmallow-3.7.0-py2.py3-none-any.whl", hash = "sha256:0f3a630f6a2fd124929f1bdcb5df65bd14cc8f49f52a18d0bdcfa0c42414e4a7"}, + {file = "marshmallow-3.7.0.tar.gz", hash = "sha256:ba949379cb6ef73655f72075e82b31cf57012a5557ede642fc8614ab0354f869"}, ] marshmallow-polyfield = [ {file = "marshmallow-polyfield-5.9.tar.gz", hash = "sha256:448f4b1ac5cbd671c0fb8a5452e99da7c0e8be924dd2cda2a21ee59457a4748f"}, diff --git a/pyproject.toml b/pyproject.toml index 79b5b863..72b08634 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,6 +28,7 @@ classifiers = [ NIP = "nitpick.flake8:NitpickExtension" [tool.poetry.plugins.nitpick] +text = "nitpick.plugins.text" json = "nitpick.plugins.json" pre_commit = "nitpick.plugins.pre_commit" setup_cfg = "nitpick.plugins.setup_cfg" diff --git a/src/nitpick/app.py b/src/nitpick/app.py index a19b8b4e..e6fbcd4d 100644 --- a/src/nitpick/app.py +++ b/src/nitpick/app.py @@ -50,7 +50,7 @@ def create_app(cls, offline=False) -> "NitpickApp": """Create a single application.""" # pylint: disable=import-outside-toplevel from nitpick.config import Config # pylint: disable=redefined-outer-name - from nitpick.plugins.base import BaseFile + from nitpick.plugins.base import NitpickPlugin app = cls() cls._current_app = app @@ -63,7 +63,7 @@ def create_app(cls, offline=False) -> "NitpickApp": app.main_python_file = app.find_main_python_file() app.config = Config() app.plugin_manager = app.load_plugins() - BaseFile.load_fixed_dynamic_classes() + NitpickPlugin.load_fixed_dynamic_classes() except (NoRootDir, NoPythonFile) as err: app.init_errors.append(err) @@ -89,8 +89,8 @@ def find_root_dir() -> Path: Start from the current working dir. """ # pylint: disable=import-outside-toplevel - from nitpick.plugins.pyproject_toml import PyProjectTomlFile - from nitpick.plugins.setup_cfg import SetupCfgFile + from nitpick.plugins.pyproject_toml import PyProjectTomlPlugin + from nitpick.plugins.setup_cfg import SetupCfgPlugin root_dirs = set() # type: Set[Path] seen = set() # type: Set[Path] @@ -101,7 +101,7 @@ def find_root_dir() -> Path: starting_dir = Path(starting_file).parent.absolute() while True: project_files = climb_directory_tree( - starting_dir, ROOT_FILES + (PyProjectTomlFile.file_name, SetupCfgFile.file_name) + starting_dir, ROOT_FILES + (PyProjectTomlPlugin.file_name, SetupCfgPlugin.file_name) ) if project_files and project_files & seen: break diff --git a/src/nitpick/config.py b/src/nitpick/config.py index 5b5ba8b1..ee220dd6 100644 --- a/src/nitpick/config.py +++ b/src/nitpick/config.py @@ -10,16 +10,17 @@ TOOL_NITPICK, TOOL_NITPICK_JMEX, ) -from nitpick.formats import TomlFormat +from nitpick.formats import TOMLFormat from nitpick.generic import search_dict, version_to_tuple from nitpick.mixin import NitpickMixin -from nitpick.plugins.pyproject_toml import PyProjectTomlFile +from nitpick.plugins.pyproject_toml import PyProjectTomlPlugin from nitpick.schemas import ToolNitpickSectionSchema, flatten_marshmallow_errors from nitpick.style import Style from nitpick.typedefs import YieldFlake8Error if TYPE_CHECKING: from pathlib import Path + from nitpick.typedefs import JsonDict, StrOrList LOGGER = logging.getLogger(__name__) @@ -32,7 +33,7 @@ class Config(NitpickMixin): # pylint: disable=too-many-instance-attributes def __init__(self) -> None: - self.pyproject_toml = None # type: Optional[TomlFormat] + self.pyproject_toml = None # type: Optional[TOMLFormat] self.tool_nitpick_dict = {} # type: JsonDict self.style_dict = {} # type: JsonDict self.nitpick_section = {} # type: JsonDict @@ -40,14 +41,14 @@ def __init__(self) -> None: def validate_pyproject_tool_nitpick(self) -> bool: """Validate the ``pyroject.toml``'s ``[tool.nitpick]`` section against a Marshmallow schema.""" - pyproject_path = NitpickApp.current().root_dir / PyProjectTomlFile.file_name # type: Path + pyproject_path = NitpickApp.current().root_dir / PyProjectTomlPlugin.file_name # type: Path if pyproject_path.exists(): - self.pyproject_toml = TomlFormat(path=pyproject_path) + self.pyproject_toml = TOMLFormat(path=pyproject_path) self.tool_nitpick_dict = search_dict(TOOL_NITPICK_JMEX, self.pyproject_toml.as_data, {}) pyproject_errors = ToolNitpickSectionSchema().validate(self.tool_nitpick_dict) if pyproject_errors: NitpickApp.current().add_style_error( - PyProjectTomlFile.file_name, + PyProjectTomlPlugin.file_name, "Invalid data in [{}]:".format(TOOL_NITPICK), flatten_marshmallow_errors(pyproject_errors), ) diff --git a/src/nitpick/formats.py b/src/nitpick/formats.py index dcac334a..2665c094 100644 --- a/src/nitpick/formats.py +++ b/src/nitpick/formats.py @@ -88,7 +88,7 @@ class BaseFormat(metaclass=abc.ABCMeta): :param path: Path of the config file to be loaded. :param string: Config in string format. - :param data: Config data in Python format (dict, YamlFormat, TomlFormat instances). + :param data: Config data in Python format (dict, YAMLFormat, TOMLFormat instances). :param ignore_keys: List of keys to ignore when using the comparison methods. """ @@ -137,7 +137,7 @@ def reformatted(self) -> str: @classmethod def cleanup(cls, *args: List[Any]) -> List[Any]: - """Cleanup similar values according to the specific format. E.g.: YamlFormat accepts 'True' or 'true'.""" + """Cleanup similar values according to the specific format. E.g.: YAMLFormat accepts 'True' or 'true'.""" return list(*args) def _create_comparison(self, expected: Union[JsonDict, YamlData, "BaseFormat"]): @@ -202,7 +202,7 @@ def compare_with_dictdiffer( return comparison -class TomlFormat(BaseFormat): +class TOMLFormat(BaseFormat): """TOML configuration format.""" @staticmethod @@ -231,7 +231,7 @@ def load(self) -> bool: return True -class YamlFormat(BaseFormat): +class YAMLFormat(BaseFormat): """YAML configuration format.""" def load(self) -> bool: @@ -278,7 +278,7 @@ def cleanup(cls, *args: List[Any]) -> List[Any]: RoundTripRepresenter.add_representer(SortedDict, RoundTripRepresenter.represent_dict) -class JsonFormat(BaseFormat): +class JSONFormat(BaseFormat): """JSON configuration format.""" def load(self) -> bool: @@ -299,5 +299,5 @@ def load(self) -> bool: @classmethod def cleanup(cls, *args: List[Any]) -> List[Any]: - """Cleanup similar values according to the specific format. E.g.: YamlFormat accepts 'True' or 'true'.""" + """Cleanup similar values according to the specific format. E.g.: YAMLFormat accepts 'True' or 'true'.""" return list(args) diff --git a/src/nitpick/plugins/__init__.py b/src/nitpick/plugins/__init__.py index 36b42ea1..6646ed83 100644 --- a/src/nitpick/plugins/__init__.py +++ b/src/nitpick/plugins/__init__.py @@ -5,7 +5,7 @@ The hook specifications and the plugin classes are still experimental and considered as an internal API. They might change at any time; use at your own risk. """ -from typing import TYPE_CHECKING, Optional, Set +from typing import TYPE_CHECKING, Optional, Set, Type import pluggy @@ -13,15 +13,24 @@ from nitpick.typedefs import JsonDict if TYPE_CHECKING: - from nitpick.plugins.base import BaseFile + from nitpick.plugins.base import NitpickPlugin hookspec = pluggy.HookspecMarker(PROJECT_NAME) hookimpl = pluggy.HookimplMarker(PROJECT_NAME) +@hookspec +def plugin_class() -> Type["NitpickPlugin"]: + """You should return your plugin class here.""" + + @hookspec def handle_config_file( # pylint: disable=unused-argument config: JsonDict, file_name: str, tags: Set[str] -) -> Optional["BaseFile"]: - """Return a BaseFile if this plugin handles the relative filename or any of its :py:package:`identify` tags.""" +) -> Optional["NitpickPlugin"]: + """You should return a valid :py:class:`nitpick.plugins.base.NitpickPlugin` instance or ``None``. + + :return: A plugin instance if your plugin handles this file name or any of its ``identify`` tags. + Return ``None`` if your plugin doesn't handle this file or file type. + """ diff --git a/src/nitpick/plugins/base.py b/src/nitpick/plugins/base.py index 4ec62ebc..d16c3111 100644 --- a/src/nitpick/plugins/base.py +++ b/src/nitpick/plugins/base.py @@ -5,33 +5,34 @@ import jmespath from nitpick.app import NitpickApp -from nitpick.formats import TomlFormat -from nitpick.generic import get_subclasses, search_dict +from nitpick.formats import TOMLFormat +from nitpick.generic import search_dict from nitpick.mixin import NitpickMixin from nitpick.typedefs import JsonDict, YieldFlake8Error if TYPE_CHECKING: from pathlib import Path + from marshmallow import Schema -class BaseFile(NitpickMixin, metaclass=abc.ABCMeta): +class NitpickPlugin(NitpickMixin, metaclass=abc.ABCMeta): """Base class for file checkers.""" file_name = "" error_base_number = 300 #: 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.plugins.json.JSONFile`). - nested_field = None # type: Optional[Schema] + #: Useful when you have a strict configuration for a file type (e.g. :py:class:`nitpick.plugins.json.JSONPlugin`). + validation_schema = None # type: Optional[Schema] - fixed_name_classes = set() # type: Set[Type[BaseFile]] - dynamic_name_classes = set() # type: Set[Type[BaseFile]] + fixed_name_classes = set() # type: Set[Type[NitpickPlugin]] + dynamic_name_classes = set() # type: Set[Type[NitpickPlugin]] # TODO: This info is duplicated. Use the value passed on the hook spec, and remove this attribute. # For this to work, validation and dynamic schema have to be done in a different way # (maybe NOT using dynamic schemas) - #: Which :py:package:`identify` tags this :py:class:`nitpick.files.base.BaseFile` child recognises. + #: Which ``identify`` tags this :py:class:`nitpick.plugins.base.NitpickPlugin` child recognises. identify_tags = set() # type: Set[str] def __init__(self, config: JsonDict, file_name: str = None) -> None: @@ -54,11 +55,11 @@ def load_fixed_dynamic_classes(cls) -> None: """Separate classes with fixed file names from classes with dynamic files names.""" cls.fixed_name_classes = set() cls.dynamic_name_classes = set() - for subclass in get_subclasses(BaseFile): - if subclass.file_name: - cls.fixed_name_classes.add(subclass) + for plugin_class in NitpickApp.current().plugin_manager.hook.plugin_class(): + if plugin_class.file_name: + cls.fixed_name_classes.add(plugin_class) else: - cls.dynamic_name_classes.add(subclass) + cls.dynamic_name_classes.add(plugin_class) @classmethod def get_compiled_jmespath_file_names(cls): @@ -69,7 +70,7 @@ def check_exists(self) -> YieldFlake8Error: """Check if the file should exist.""" config_data_exists = bool(self.file_dict or self.nitpick_file_dict) should_exist = NitpickApp.current().config.nitpick_files_section.get( - TomlFormat.group_name_for(self.file_name), True + TOMLFormat.group_name_for(self.file_name), True ) # type: bool file_exists = self.file_path.exists() diff --git a/src/nitpick/plugins/json.py b/src/nitpick/plugins/json.py index b7ce33a5..a843a2ad 100644 --- a/src/nitpick/plugins/json.py +++ b/src/nitpick/plugins/json.py @@ -1,15 +1,15 @@ """JSON files.""" import json import logging -from typing import Optional, Set +from typing import Optional, Set, Type from sortedcontainers import SortedDict from nitpick import fields -from nitpick.formats import JsonFormat +from nitpick.formats import JSONFormat from nitpick.generic import flatten, unflatten from nitpick.plugins import hookimpl -from nitpick.plugins.base import BaseFile +from nitpick.plugins.base import NitpickPlugin from nitpick.schemas import BaseNitpickSchema from nitpick.typedefs import JsonDict, YieldFlake8Error @@ -25,7 +25,7 @@ class JSONFileSchema(BaseNitpickSchema): contains_json = fields.Dict(fields.NonEmptyString, fields.JSONString) -class JSONFile(BaseFile): +class JSONPlugin(NitpickPlugin): """Checker for any JSON file. First, configure the list of files to be checked in the :ref:`[nitpick.JSONFile] section `. @@ -39,7 +39,7 @@ class JSONFile(BaseFile): error_base_number = 340 - nested_field = JSONFileSchema + validation_schema = JSONFileSchema identify_tags = {"json"} SOME_VALUE_PLACEHOLDER = "" @@ -63,17 +63,17 @@ def get_suggested_json(self, raw_actual: JsonDict = None) -> JsonDict: def suggest_initial_contents(self) -> str: """Suggest the initial content for this missing file.""" suggestion = self.get_suggested_json() - return JsonFormat(data=suggestion).reformatted if suggestion else "" + return JSONFormat(data=suggestion).reformatted if suggestion else "" def _check_contained_keys(self) -> YieldFlake8Error: - json_fmt = JsonFormat(path=self.file_path) + json_fmt = JSONFormat(path=self.file_path) suggested_json = self.get_suggested_json(json_fmt.as_data) if not suggested_json: return - yield self.flake8_error(8, " has missing keys:", JsonFormat(data=suggested_json).reformatted) + yield self.flake8_error(8, " has missing keys:", JSONFormat(data=suggested_json).reformatted) def _check_contained_json(self) -> YieldFlake8Error: - actual_fmt = JsonFormat(path=self.file_path) + actual_fmt = JSONFormat(path=self.file_path) 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(): @@ -86,11 +86,17 @@ def _check_contained_json(self) -> YieldFlake8Error: continue yield from self.warn_missing_different( - JsonFormat(data=actual_fmt.as_data).compare_with_dictdiffer(expected, unflatten) + JSONFormat(data=actual_fmt.as_data).compare_with_dictdiffer(expected, unflatten) ) @hookimpl -def handle_config_file(config: JsonDict, file_name: str, tags: Set[str]) -> Optional["BaseFile"]: +def plugin_class() -> Type["NitpickPlugin"]: + """You should return your plugin class here.""" + return JSONPlugin + + +@hookimpl +def handle_config_file(config: JsonDict, file_name: str, tags: Set[str]) -> Optional["NitpickPlugin"]: """Handle JSON files.""" - return JSONFile(config, file_name) if "json" in tags else None + return JSONPlugin(config, file_name) if "json" in tags else None diff --git a/src/nitpick/plugins/pre_commit.py b/src/nitpick/plugins/pre_commit.py index fb953668..eaa6d769 100644 --- a/src/nitpick/plugins/pre_commit.py +++ b/src/nitpick/plugins/pre_commit.py @@ -1,13 +1,13 @@ """Checker for the `.pre-commit-config.yaml `_ file.""" from collections import OrderedDict -from typing import Any, Dict, List, Optional, Set, Tuple, Union +from typing import Any, Dict, List, Optional, Set, Tuple, Type, Union import attr -from nitpick.formats import TomlFormat, YamlFormat +from nitpick.formats import TOMLFormat, YAMLFormat from nitpick.generic import find_object_by_key, search_dict from nitpick.plugins import hookimpl -from nitpick.plugins.base import BaseFile +from nitpick.plugins.base import NitpickPlugin from nitpick.typedefs import JsonDict, YamlData, YieldFlake8Error KEY_REPOS = "repos" @@ -23,7 +23,7 @@ class PreCommitHook: repo = attr.ib(type=str) hook_id = attr.ib(type=str) - yaml = attr.ib(type=YamlFormat) + yaml = attr.ib(type=YAMLFormat) @property def unique_key(self) -> str: @@ -43,7 +43,7 @@ def single_hook(self) -> JsonDict: @classmethod def get_all_hooks_from(cls, str_or_yaml: Union[str, YamlData]): """Get all hooks from a YAML string. Split the string in hooks and copy the repo info for each.""" - yaml = YamlFormat(string=str_or_yaml).as_list if isinstance(str_or_yaml, str) else str_or_yaml + yaml = YAMLFormat(string=str_or_yaml).as_list if isinstance(str_or_yaml, str) else str_or_yaml hooks = [] for repo in yaml: for index, hook in enumerate(repo.get(KEY_HOOKS, [])): @@ -52,12 +52,12 @@ def get_all_hooks_from(cls, str_or_yaml: Union[str, YamlData]): hook_data_only = search_dict("{}[{}]".format(KEY_HOOKS, index), repo, {}) repo_data_only.update({KEY_HOOKS: [hook_data_only]}) hooks.append( - PreCommitHook(repo.get(KEY_REPO), hook[KEY_ID], YamlFormat(data=[repo_data_only])).key_value_pair + PreCommitHook(repo.get(KEY_REPO), hook[KEY_ID], YAMLFormat(data=[repo_data_only])).key_value_pair ) return OrderedDict(hooks) -class PreCommitFile(BaseFile): +class PreCommitPlugin(NitpickPlugin): """Checker for the `.pre-commit-config.yaml `_ file. Example: :ref:`the default pre-commit hooks `. @@ -66,7 +66,7 @@ class PreCommitFile(BaseFile): file_name = ".pre-commit-config.yaml" error_base_number = 330 - actual_yaml = None # type: YamlFormat + actual_yaml = None # type: YAMLFormat actual_hooks = OrderedDict() # type: OrderedDict[str, PreCommitHook] actual_hooks_by_key = {} # type: Dict[str, int] actual_hooks_by_index = [] # type: List[str] @@ -80,18 +80,18 @@ def suggest_initial_contents(self) -> str: new_repo = dict(repo) hooks_or_yaml = repo.get(KEY_HOOKS, repo.get(KEY_YAML, {})) if KEY_YAML in repo: - repo_list = YamlFormat(string=hooks_or_yaml).as_list + repo_list = YAMLFormat(string=hooks_or_yaml).as_list suggested[KEY_REPOS].extend(repo_list) else: # TODO: show a deprecation warning for this case - new_repo[KEY_HOOKS] = YamlFormat(string=hooks_or_yaml).as_data + new_repo[KEY_HOOKS] = YAMLFormat(string=hooks_or_yaml).as_data suggested[KEY_REPOS].append(new_repo) suggested.update(original) - return YamlFormat(data=suggested).reformatted + return YAMLFormat(data=suggested).reformatted def check_rules(self) -> YieldFlake8Error: """Check the rules for the pre-commit hooks.""" - self.actual_yaml = YamlFormat(path=self.file_path) + self.actual_yaml = YAMLFormat(path=self.file_path) if KEY_REPOS not in self.actual_yaml.as_data: # TODO: if the 'repos' key doesn't exist, assume repos are in the root of the .yml file # Having the 'repos' key is not actually a requirement. 'pre-commit-validate-config' works without it. @@ -100,7 +100,7 @@ def check_rules(self) -> YieldFlake8Error: # Check the root values in the configuration file yield from self.warn_missing_different( - YamlFormat(data=self.actual_yaml.as_data, ignore_keys=[KEY_REPOS]).compare_with_dictdiffer(self.file_dict) + YAMLFormat(data=self.actual_yaml.as_data, ignore_keys=[KEY_REPOS]).compare_with_dictdiffer(self.file_dict) ) yield from self.check_hooks() @@ -121,17 +121,17 @@ def check_hooks(self) -> YieldFlake8Error: def check_repo_block(self, expected_repo_block: OrderedDict) -> YieldFlake8Error: """Check a repo with a YAML string configuration.""" - expected_hooks = PreCommitHook.get_all_hooks_from(YamlFormat(string=expected_repo_block.get(KEY_YAML)).as_list) + expected_hooks = PreCommitHook.get_all_hooks_from(YAMLFormat(string=expected_repo_block.get(KEY_YAML)).as_list) for unique_key, hook in expected_hooks.items(): if unique_key not in self.actual_hooks: yield self.flake8_error( 2, ": hook {!r} not found. Use this:".format(hook.hook_id), - YamlFormat(data=hook.yaml.as_data).reformatted, + YAMLFormat(data=hook.yaml.as_data).reformatted, ) continue - comparison = YamlFormat(data=self.actual_hooks[unique_key].single_hook).compare_with_dictdiffer( + comparison = YAMLFormat(data=self.actual_hooks[unique_key].single_hook).compare_with_dictdiffer( hook.single_hook ) @@ -164,7 +164,7 @@ def check_repo_old_format(self, index: int, repo_data: OrderedDict) -> YieldFlak yield self.flake8_error(5, ": style file is missing {!r} in repo {!r}".format(KEY_HOOKS, repo_name)) return - expected_hooks = YamlFormat(string=yaml_expected_hooks).as_data + expected_hooks = YAMLFormat(string=yaml_expected_hooks).as_data for expected_dict in expected_hooks: hook_id = expected_dict.get(KEY_ID) if not hook_id: @@ -180,7 +180,7 @@ def check_repo_old_format(self, index: int, repo_data: OrderedDict) -> YieldFlak @staticmethod def format_hook(expected_dict) -> str: """Format the hook so it's easy to copy and paste it to the .yaml file: ID goes first, indent with spaces.""" - lines = YamlFormat(data=expected_dict).reformatted + lines = YAMLFormat(data=expected_dict).reformatted output = [] # type: List[str] for line in lines.split("\n"): if line.startswith("id:"): @@ -190,10 +190,15 @@ def format_hook(expected_dict) -> str: return "\n".join(output) +@hookimpl +def plugin_class() -> Type["NitpickPlugin"]: + """You should return your plugin class here.""" + return PreCommitPlugin + + @hookimpl def handle_config_file( # pylint: disable=unused-argument config: JsonDict, file_name: str, tags: Set[str] -) -> Optional["BaseFile"]: +) -> Optional["NitpickPlugin"]: """Handle pre-commit config file.""" - base_file = PreCommitFile(config) - return base_file if file_name == TomlFormat.group_name_for(base_file.file_name) else None + return PreCommitPlugin(config) if file_name == TOMLFormat.group_name_for(PreCommitPlugin.file_name) else None diff --git a/src/nitpick/plugins/pyproject_toml.py b/src/nitpick/plugins/pyproject_toml.py index 69b250fc..ee3610b3 100644 --- a/src/nitpick/plugins/pyproject_toml.py +++ b/src/nitpick/plugins/pyproject_toml.py @@ -1,13 +1,13 @@ """Checker for `pyproject.toml `_.""" -from typing import Optional, Set +from typing import Optional, Set, Type from nitpick.app import NitpickApp from nitpick.plugins import hookimpl -from nitpick.plugins.base import BaseFile +from nitpick.plugins.base import NitpickPlugin from nitpick.typedefs import JsonDict, YieldFlake8Error -class PyProjectTomlFile(BaseFile): +class PyProjectTomlPlugin(NitpickPlugin): """Checker for `pyproject.toml `_. See also `PEP 518 `_. @@ -30,10 +30,16 @@ def suggest_initial_contents(self) -> str: return "" +@hookimpl +def plugin_class() -> Type["NitpickPlugin"]: + """You should return your plugin class here.""" + return PyProjectTomlPlugin + + @hookimpl def handle_config_file( # pylint: disable=unused-argument config: JsonDict, file_name: str, tags: Set[str] -) -> Optional["BaseFile"]: +) -> Optional["NitpickPlugin"]: """Handle pyproject.toml file.""" - base_file = PyProjectTomlFile(config) + base_file = PyProjectTomlPlugin(config) return base_file if file_name == base_file.file_name else None diff --git a/src/nitpick/plugins/setup_cfg.py b/src/nitpick/plugins/setup_cfg.py index 555fdeeb..48d28130 100644 --- a/src/nitpick/plugins/setup_cfg.py +++ b/src/nitpick/plugins/setup_cfg.py @@ -1,16 +1,16 @@ """Checker for the `setup.cfg ` config file.""" from configparser import ConfigParser from io import StringIO -from typing import Any, Dict, List, Optional, Set, Tuple +from typing import Any, Dict, List, Optional, Set, Tuple, Type import dictdiffer from nitpick.plugins import hookimpl -from nitpick.plugins.base import BaseFile +from nitpick.plugins.base import NitpickPlugin from nitpick.typedefs import JsonDict, YieldFlake8Error -class SetupCfgFile(BaseFile): +class SetupCfgPlugin(NitpickPlugin): """Checker for the `setup.cfg `_ config file. Example: :ref:`flake8 configuration `. @@ -51,7 +51,8 @@ def get_missing_output(self, actual_sections: Set[str] = None) -> str: def check_rules(self) -> YieldFlake8Error: """Check missing sections and missing key/value pairs in setup.cfg.""" setup_cfg = ConfigParser() - setup_cfg.read_file(self.file_path.open()) + with self.file_path.open() as handle: + setup_cfg.read_file(handle) actual_sections = set(setup_cfg.sections()) missing = self.get_missing_output(actual_sections) @@ -116,9 +117,15 @@ def get_example_cfg(config_parser: ConfigParser) -> str: return output +@hookimpl +def plugin_class() -> Type["NitpickPlugin"]: + """You should return your plugin class here.""" + return SetupCfgPlugin + + @hookimpl def handle_config_file( # pylint: disable=unused-argument config: JsonDict, file_name: str, tags: Set[str] -) -> Optional["BaseFile"]: +) -> Optional["NitpickPlugin"]: """Handle the setup.cfg file.""" - return SetupCfgFile(config) if file_name == SetupCfgFile.file_name else None + return SetupCfgPlugin(config) if file_name == SetupCfgPlugin.file_name else None diff --git a/src/nitpick/plugins/text.py b/src/nitpick/plugins/text.py new file mode 100644 index 00000000..a632d93a --- /dev/null +++ b/src/nitpick/plugins/text.py @@ -0,0 +1,76 @@ +"""Text files.""" +import logging +from typing import Optional, Set, Type + +from marshmallow import Schema +from marshmallow.orderedset import OrderedSet + +from nitpick import fields +from nitpick.plugins import hookimpl +from nitpick.plugins.base import NitpickPlugin +from nitpick.schemas import help_message +from nitpick.typedefs import JsonDict, YieldFlake8Error + +LOGGER = logging.getLogger(__name__) + +TEXT_FILE_RTFD_PAGE = "config_files.html#text-files" + + +class TextItemSchema(Schema): + """Validation schema for the object inside ``contains``.""" + + error_messages = {"unknown": help_message("Unknown configuration", TEXT_FILE_RTFD_PAGE)} + line = fields.NonEmptyString() + + +class TextSchema(Schema): + """Validation schema for the text file TOML configuration.""" + + error_messages = {"unknown": help_message("Unknown configuration", TEXT_FILE_RTFD_PAGE)} + contains = fields.List(fields.Nested(TextItemSchema)) + + +class TextPlugin(NitpickPlugin): + """Checker for text files. + + To check if ``some.txt`` file contains the lines ``abc`` and ``def`` (in any order): + + .. code-block:: toml + + [["some.txt".contains]] + line = "abc" + + [["some.txt".contains]] + line = "def" + """ + + error_base_number = 350 + identify_tags = {"plain-text"} + validation_schema = TextSchema + + def _expected_lines(self): + return [obj.get("line") for obj in self.file_dict.get("contains", {})] + + def suggest_initial_contents(self) -> str: + """Suggest the initial content for this missing file.""" + return "\n".join(self._expected_lines()) + + def check_rules(self) -> YieldFlake8Error: + """Check missing lines.""" + expected = OrderedSet(self._expected_lines()) + actual = OrderedSet(self.file_path.read_text().split("\n")) + missing = expected - actual + if missing: + yield self.flake8_error(2, " has missing lines:", "\n".join(sorted(missing))) + + +@hookimpl +def plugin_class() -> Type["NitpickPlugin"]: + """You should return your plugin class here.""" + return TextPlugin + + +@hookimpl +def handle_config_file(config: JsonDict, file_name: str, tags: Set[str]) -> Optional["NitpickPlugin"]: + """Handle text files.""" + return TextPlugin(config, file_name) if "plain-text" in tags else None diff --git a/src/nitpick/schemas.py b/src/nitpick/schemas.py index 0bfbe875..74bc8fa4 100644 --- a/src/nitpick/schemas.py +++ b/src/nitpick/schemas.py @@ -8,7 +8,7 @@ from nitpick import fields from nitpick.constants import READ_THE_DOCS_URL from nitpick.generic import flatten -from nitpick.plugins.setup_cfg import SetupCfgFile +from nitpick.plugins.setup_cfg import SetupCfgPlugin def flatten_marshmallow_errors(errors: Dict) -> str: @@ -80,7 +80,7 @@ class NitpickFilesSectionSchema(BaseNitpickSchema): absent = fields.Dict(fields.NonEmptyString, fields.String()) present = fields.Dict(fields.NonEmptyString, fields.String()) # TODO: load this schema dynamically, then add this next field setup_cfg - setup_cfg = fields.Nested(SetupCfgSchema, data_key=SetupCfgFile.file_name) + setup_cfg = fields.Nested(SetupCfgSchema, data_key=SetupCfgPlugin.file_name) class NitpickSectionSchema(BaseNitpickSchema): @@ -89,7 +89,7 @@ class NitpickSectionSchema(BaseNitpickSchema): minimum_version = fields.NonEmptyString() styles = fields.Nested(NitpickStylesSectionSchema) files = fields.Nested(NitpickFilesSectionSchema) - # TODO: load this schema dynamically, then add this next field JSONFile + # TODO: deprecate this field and remove all tests using it; it's not necessary anymore JSONFile = fields.Nested(NitpickJSONFileSectionSchema) diff --git a/src/nitpick/style.py b/src/nitpick/style.py index 7e5ecd48..fb9967ba 100644 --- a/src/nitpick/style.py +++ b/src/nitpick/style.py @@ -20,10 +20,10 @@ RAW_GITHUB_CONTENT_BASE_URL, TOML_EXTENSION, ) -from nitpick.formats import TomlFormat +from nitpick.formats import TOMLFormat from nitpick.generic import MergeDict, climb_directory_tree, is_url, pretty_exception, search_dict -from nitpick.plugins.base import BaseFile -from nitpick.plugins.pyproject_toml import PyProjectTomlFile +from nitpick.plugins.base import NitpickPlugin +from nitpick.plugins.pyproject_toml import PyProjectTomlPlugin from nitpick.schemas import BaseStyleSchema, flatten_marshmallow_errors from nitpick.typedefs import JsonDict, StrOrList @@ -50,7 +50,7 @@ def find_initial_styles(self, configured_styles: StrOrList): """Find the initial style(s) and include them.""" if configured_styles: chosen_styles = configured_styles - log_message = "Styles configured in {}: %s".format(PyProjectTomlFile.file_name) + log_message = "Styles configured in {}: %s".format(PyProjectTomlPlugin.file_name) else: paths = climb_directory_tree(NitpickApp.current().root_dir, [NITPICK_STYLE_TOML]) if paths: @@ -80,7 +80,7 @@ def include_multiple_styles(self, chosen_styles: StrOrList) -> None: if not style_path: continue - toml = TomlFormat(path=style_path) + toml = TOMLFormat(path=style_path) try: toml_dict = toml.as_data except TomlDecodeError as err: @@ -196,7 +196,7 @@ def merge_toml_dict(self) -> JsonDict: return {} merged_dict = self._all_styles.merge() merged_style_path = app.cache_dir / MERGED_STYLE_TOML # type: Path - toml = TomlFormat(data=merged_dict) + toml = TOMLFormat(data=merged_dict) attempt = 1 while attempt < 5: @@ -210,14 +210,14 @@ def merge_toml_dict(self) -> JsonDict: return merged_dict @staticmethod - def file_field_pair(file_name: str, base_file_class: Type[BaseFile]) -> Dict[str, fields.Field]: + def file_field_pair(file_name: str, base_file_class: Type[NitpickPlugin]) -> Dict[str, fields.Field]: """Return a schema field with info from a config file class.""" - valid_toml_key = TomlFormat.group_name_for(file_name) + valid_toml_key = TOMLFormat.group_name_for(file_name) unique_file_name_with_underscore = slugify(file_name, separator="_") kwargs = {"data_key": valid_toml_key} - if base_file_class.nested_field: - field = fields.Nested(base_file_class.nested_field, **kwargs) + if base_file_class.validation_schema: + field = fields.Nested(base_file_class.validation_schema, **kwargs) else: # For default files (pyproject.toml, setup.cfg...), there is no strict schema; # it can be anything they allow. @@ -233,14 +233,14 @@ def rebuild_dynamic_schema(self, data: JsonDict = None) -> None: # Data is empty; so this is the first time the dynamic class is being rebuilt. # Loop on classes with predetermined names, and add fields for them on the dynamic validation schema. # E.g.: setup.cfg, pre-commit, pyproject.toml: files whose names we already know at this point. - for subclass in BaseFile.fixed_name_classes: + for subclass in NitpickPlugin.fixed_name_classes: new_files_found.update(self.file_field_pair(subclass.file_name, subclass)) else: - handled_tags = {} # type: Dict[str, Type[BaseFile]] + handled_tags = {} # type: Dict[str, Type[NitpickPlugin]] # Data was provided; search it to find new dynamic files to add to the validation schema). # E.g.: JSON files that were configured on some TOML style file. - for subclass in BaseFile.dynamic_name_classes: + for subclass in NitpickPlugin.dynamic_name_classes: for tag in subclass.identify_tags: # A tag can only be handled by a single subclass. # If more than one class handle a tag, the latest one will be the handler. @@ -250,13 +250,13 @@ def rebuild_dynamic_schema(self, data: JsonDict = None) -> None: for configured_file_name in search_dict(jmex, data, []): new_files_found.update(self.file_field_pair(configured_file_name, subclass)) - self._find_sublcasses(data, handled_tags, new_files_found) + self._find_subclasses(data, handled_tags, new_files_found) # Only recreate the schema if new fields were found. if new_files_found: self._dynamic_schema_class = type("DynamicStyleSchema", (self._dynamic_schema_class,), new_files_found) - def _find_sublcasses(self, data, handled_tags, new_files_found): + def _find_subclasses(self, data, handled_tags, new_files_found): for possible_file in data.keys(): found_subclasses = [] for file_tag in identify.tags_from_filename(possible_file): diff --git a/styles/isort.toml b/styles/isort.toml index d8c053bc..b7e38ac8 100644 --- a/styles/isort.toml +++ b/styles/isort.toml @@ -18,7 +18,7 @@ yaml = """ hooks: - id: seed-isort-config - repo: https://github.com/pre-commit/mirrors-isort - rev: v4.3.21 + rev: v5.0.9 hooks: - id: isort """ diff --git a/styles/pre-commit/general.toml b/styles/pre-commit/general.toml index ba7e8925..42912af3 100644 --- a/styles/pre-commit/general.toml +++ b/styles/pre-commit/general.toml @@ -7,7 +7,7 @@ yaml = """ - id: end-of-file-fixer - id: trailing-whitespace - repo: https://github.com/asottile/pyupgrade - rev: v2.6.2 + rev: v2.7.0 hooks: - id: pyupgrade """ diff --git a/tests/helpers.py b/tests/helpers.py index 73b84c53..52f5e5b2 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -11,10 +11,10 @@ from nitpick.app import NitpickApp from nitpick.constants import CACHE_DIR_NAME, ERROR_PREFIX, MERGED_STYLE_TOML, NITPICK_STYLE_TOML, PROJECT_NAME from nitpick.flake8 import NitpickExtension -from nitpick.formats import TomlFormat -from nitpick.plugins.pre_commit import PreCommitFile -from nitpick.plugins.pyproject_toml import PyProjectTomlFile -from nitpick.plugins.setup_cfg import SetupCfgFile +from nitpick.formats import TOMLFormat +from nitpick.plugins.pre_commit import PreCommitPlugin +from nitpick.plugins.pyproject_toml import PyProjectTomlPlugin +from nitpick.plugins.setup_cfg import SetupCfgPlugin from nitpick.typedefs import PathOrStr from tests.conftest import TEMP_ROOT_PATH @@ -139,15 +139,15 @@ def ensure_toml_extension(file_name: PathOrStr) -> PathOrStr: def setup_cfg(self, file_contents: str) -> "ProjectMock": """Save setup.cfg.""" - return self.save_file(SetupCfgFile.file_name, file_contents) + return self.save_file(SetupCfgPlugin.file_name, file_contents) def pyproject_toml(self, file_contents: str) -> "ProjectMock": """Save pyproject.toml.""" - return self.save_file(PyProjectTomlFile.file_name, file_contents) + return self.save_file(PyProjectTomlPlugin.file_name, file_contents) def pre_commit(self, file_contents: str) -> "ProjectMock": """Save .pre-commit-config.yaml.""" - return self.save_file(PreCommitFile.file_name, file_contents) + return self.save_file(PreCommitPlugin.file_name, file_contents) def raise_assertion_error(self, expected_error: str, assertion_message: str = None): """Show detailed errors in case of an assertion failure.""" @@ -214,6 +214,6 @@ def assert_no_errors(self) -> "ProjectMock": def assert_merged_style(self, toml_string: str): """Assert the contents of the merged style file.""" - expected = TomlFormat(path=self.cache_dir / MERGED_STYLE_TOML) - actual = TomlFormat(string=dedent(toml_string)) + expected = TOMLFormat(path=self.cache_dir / MERGED_STYLE_TOML) + actual = TOMLFormat(string=dedent(toml_string)) compare(expected.as_data, actual.as_data) diff --git a/tests/test_json.py b/tests/test_json.py index 32d78314..fe7f7d5d 100644 --- a/tests/test_json.py +++ b/tests/test_json.py @@ -129,8 +129,8 @@ def test_invalid_json(request): ) -def test_json_file_with_extra_keys(request): - """Test TOML style with extra keys for a JSON file.""" +def test_json_configuration(request): + """Test configuration for JSON files.""" ProjectMock(request).style( """ [nitpick.JSONFile] diff --git a/tests/test_pre_commit.py b/tests/test_pre_commit.py index 73be835e..c8f2c247 100644 --- a/tests/test_pre_commit.py +++ b/tests/test_pre_commit.py @@ -43,7 +43,7 @@ def test_suggest_initial_contents(request): hooks: - id: seed-isort-config - repo: https://github.com/pre-commit/mirrors-isort - rev: v4.3.21 + rev: v5.0.9 hooks: - id: isort - repo: https://github.com/python/black diff --git a/tests/test_pyproject_toml.py b/tests/test_pyproject_toml.py index a18b0cf3..f02bcf15 100644 --- a/tests/test_pyproject_toml.py +++ b/tests/test_pyproject_toml.py @@ -1,5 +1,5 @@ """pyproject.toml tests.""" -from nitpick.plugins.pyproject_toml import PyProjectTomlFile +from nitpick.plugins.pyproject_toml import PyProjectTomlPlugin from tests.helpers import ProjectMock @@ -15,4 +15,4 @@ def test_suggest_initial_contents(request): [nitpick.files.present] "pyproject.toml" = "Do something" """ - ).flake8().assert_errors_contain("NIP103 File {} should exist: Do something".format(PyProjectTomlFile.file_name)) + ).flake8().assert_errors_contain("NIP103 File {} should exist: Do something".format(PyProjectTomlPlugin.file_name)) diff --git a/tests/test_style.py b/tests/test_style.py index 67de96cc..2877f9a5 100644 --- a/tests/test_style.py +++ b/tests/test_style.py @@ -493,7 +493,7 @@ def test_merge_styles_into_single_file(offline, request): hooks: - id: seed-isort-config - repo: https://github.com/pre-commit/mirrors-isort - rev: v4.3.21 + rev: v5.0.9 hooks: - id: isort """ diff --git a/tests/test_text.py b/tests/test_text.py new file mode 100644 index 00000000..bd3c3890 --- /dev/null +++ b/tests/test_text.py @@ -0,0 +1,69 @@ +"""Text file tests.""" +from tests.helpers import ProjectMock + + +def test_suggest_initial_contents(request): + """Suggest initial contents for a text file.""" + ProjectMock(request).style( + """ + [["requirements.txt".contains]] + # File contains this exact line anywhere + line = "sphinx>=1.3.0" + + [["requirements.txt".contains]] + line = "some-package==1.0.0" + """ + ).flake8().assert_errors_contain( + """ + NIP351 File requirements.txt was not found. Create it with this content:\x1b[32m + sphinx>=1.3.0 + some-package==1.0.0\x1b[0m + """ + ) + + +def test_text_configuration(request): + """Test configuration for text files.""" + # pylint: disable=line-too-long + ProjectMock(request).style( + """ + [["abc.txt".contains]] + invalid = "key" + line = ["it", "should", "be", "a", "string"] + + ["def.txt".contains] + should_be = "inside an array" + + ["ghi.txt".whatever] + wrong = "everything" + """ + ).flake8().assert_errors_contain( + """ + NIP001 File nitpick-style.toml has an incorrect style. Invalid config:\x1b[32m + "abc.txt".contains.0.invalid: Unknown configuration. See https://nitpick.rtfd.io/en/latest/config_files.html#text-files. + "abc.txt".contains.0.line: Not a valid string. + "def.txt".contains: Not a valid list. + "ghi.txt".whatever: Unknown configuration. See https://nitpick.rtfd.io/en/latest/config_files.html#text-files.\x1b[0m + """, + 1, + ) + + +def test_text_file_contains_line(request): + """Test if the text file contains a line.""" + ProjectMock(request).style( + """ + [["my.txt".contains]] + line = "qqq" + [["my.txt".contains]] + line = "abc" + [["my.txt".contains]] + line = "www" + """ + ).save_file("my.txt", "def\nghi\nwww").flake8().assert_errors_contain( + """ + NIP352 File my.txt has missing lines:\x1b[32m + abc + qqq\x1b[0m + """ + )