From fc5ef783d933c1092fc8a38d76d89adc54e71c64 Mon Sep 17 00:00:00 2001 From: "Augusto W. Andreoli" Date: Tue, 30 Jun 2020 02:34:50 +0200 Subject: [PATCH 01/12] chore: add test, play around with file identification (WIP) Signed-off-by: Augusto W. Andreoli --- Makefile | 8 ++++++-- poetry.lock | 29 +++++++++++++++++++++------- pyproject.toml | 2 ++ setup.cfg | 2 +- src/nitpick/constants.py | 2 ++ src/nitpick/fields.py | 11 +++++++---- src/nitpick/files/__init__.py | 1 + src/nitpick/files/base.py | 6 +++++- src/nitpick/files/json.py | 5 +++-- src/nitpick/files/text.py | 36 +++++++++++++++++++++++++++++++++++ src/nitpick/schemas.py | 6 +++--- src/nitpick/style.py | 22 +++++++++++++++++++++ tests/test_text.py | 18 ++++++++++++++++++ 13 files changed, 128 insertions(+), 20 deletions(-) create mode 100644 src/nitpick/files/text.py create mode 100644 tests/test_text.py diff --git a/Makefile b/Makefile index 8aeeb034..0da3f4aa 100644 --- a/Makefile +++ b/Makefile @@ -13,10 +13,14 @@ help: @echo 'Run 'make -B' or 'make --always-make' to force a rebuild of all targets' .PHONY: help -clean: # Clean all build output (cache, tox, coverage) - rm -rf .cache .mypy_cache .pytest_cache .tox docs/_build src/*.egg-info .coverage htmlcov/ +clean: clean-test # Clean all build output (cache, tox, coverage) + rm -rf .cache .mypy_cache docs/_build src/*.egg-info .PHONY: clean +clean-test: # Clean test output + rm -rf .pytest_cache .tox .coverage htmlcov/ +.PHONY: clean-test + # Remove cache files if they are older than the configured time, so the targets will be rebuilt # "fd" is a faster alternative to "find": https://github.com/sharkdp/fd always-run: diff --git a/poetry.lock b/poetry.lock index d6ab5160..d66bbd2e 100644 --- a/poetry.lock +++ b/poetry.lock @@ -156,7 +156,7 @@ description = "Get the currently executing AST node of a frame, and other inform name = "executing" optional = false python-versions = "*" -version = "0.4.4" +version = "0.5.0" [[package]] category = "main" @@ -189,13 +189,24 @@ colorama = ">=0.3.9" executing = ">=0.3.1" pygments = ">=2.2.0" +[[package]] +category = "main" +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.20" + +[package.extras] +license = ["editdistance"] + [[package]] category = "main" description = "Internationalized Domain Names in Applications (IDNA)" name = "idna" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -version = "2.9" +version = "2.10" [[package]] category = "main" @@ -384,7 +395,7 @@ version = "0.6.1" category = "main" description = "More routines for operating on iterables, beyond itertools" name = "more-itertools" -optional = true +optional = false python-versions = ">=3.5" version = "8.4.0" @@ -898,7 +909,7 @@ lint = ["pylint"] test = ["pytest", "testfixtures", "responses"] [metadata] -content-hash = "06d575fd97b340e870b2cad29acb30364c55b35066ea12cf2d8687611344b926" +content-hash = "5506a97293f840f9b73b31ac77e25dcff0a95c7acd25857ae5eddf337239e3b4" python-versions = "^3.5 || ^3.6 || ^3.7 || ^3.8" [metadata.files] @@ -963,7 +974,7 @@ docutils = [ {file = "docutils-0.16.tar.gz", hash = "sha256:c2de3a60e9e7d07be26b7f2b00ca0309c207e06c100f9cc2a94931fc75a478fc"}, ] executing = [ - {file = "executing-0.4.4-py3-none-any.whl", hash = "sha256:bbc5859ac42014c8d367b71edab6114b37367ae122c92f638124c49bbc93924a"}, + {file = "executing-0.5.0-py3-none-any.whl", hash = "sha256:bfe21d108b0b73103d81aa5bee5f7d5067858d835b51fb35b624a195bbf6b9ec"}, ] flake8 = [ {file = "flake8-3.8.3-py2.py3-none-any.whl", hash = "sha256:15e351d19611c887e482fb960eae4d44845013cc142d42896e9862f775d8cf5c"}, @@ -973,9 +984,13 @@ icecream = [ {file = "icecream-2.0.0-py2.py3-none-any.whl", hash = "sha256:9000acd16dc9d3c78a95ac9abc54c172e4f47fbd54541ab10d280fae5bfb8efa"}, {file = "icecream-2.0.0.tar.gz", hash = "sha256:434e14a50da01f9dc1e5757efec7613db5df048ebdcecd460236db41688f779a"}, ] +identify = [ + {file = "identify-1.4.20-py2.py3-none-any.whl", hash = "sha256:acf0712ab4042642e8f44e9532d95c26fbe60c0ab8b6e5b654dd1bc6512810e0"}, + {file = "identify-1.4.20.tar.gz", hash = "sha256:b2cd24dece806707e0b50517c1b3bcf3044e0b1cb13a72e7d34aa31c91f2a55a"}, +] idna = [ - {file = "idna-2.9-py2.py3-none-any.whl", hash = "sha256:a068a21ceac8a4d63dbfd964670474107f541babbd2250d61922f029858365fa"}, - {file = "idna-2.9.tar.gz", hash = "sha256:7588d1c14ae4c77d74036e8c22ff447b26d0fde8f007354fd48a7814db15b7cb"}, + {file = "idna-2.10-py2.py3-none-any.whl", hash = "sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0"}, + {file = "idna-2.10.tar.gz", hash = "sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6"}, ] imagesize = [ {file = "imagesize-1.2.0-py2.py3-none-any.whl", hash = "sha256:6965f19a6a2039c7d48bca7dba2473069ff854c36ae6f19d2cde309d998228a1"}, diff --git a/pyproject.toml b/pyproject.toml index 76b9aaa1..4ce36da0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,6 +45,8 @@ marshmallow = {version = ">=3.0.0b10"} # Pin to avoid error on "flake8 ." when there is an invalid TOML style: # TypeError: _deserialize() got an unexpected keyword argument 'partial' marshmallow-polyfield = "^5.7" +identify = "*" +"more-itertools" = "*" pylint = {version = "*", optional = true} pytest = {version = "*", optional = true} diff --git a/setup.cfg b/setup.cfg index c6b9bcb7..8ae0bdad 100644 --- a/setup.cfg +++ b/setup.cfg @@ -50,7 +50,7 @@ include_trailing_comma = True force_grid_wrap = 0 combine_as_imports = True known_first_party = tests,nitpick -known_third_party = _pytest,attr,click,dictdiffer,flake8,jmespath,marshmallow,marshmallow_polyfield,pytest,requests,responses,ruamel,slugify,sortedcontainers,testfixtures,toml +known_third_party = _pytest,attr,click,dictdiffer,flake8,identify,jmespath,marshmallow,marshmallow_polyfield,more_itertools,pytest,requests,responses,ruamel,slugify,sortedcontainers,testfixtures,toml [mypy] ignore_missing_imports = True diff --git a/src/nitpick/constants.py b/src/nitpick/constants.py index cebdaba0..27d11b28 100644 --- a/src/nitpick/constants.py +++ b/src/nitpick/constants.py @@ -30,3 +30,5 @@ TOOL_NITPICK_JMEX = jmespath.compile(TOOL_NITPICK) NITPICK_STYLES_INCLUDE_JMEX = jmespath.compile("nitpick.styles.include") NITPICK_MINIMUM_VERSION_JMEX = jmespath.compile("nitpick.minimum_version") + +KEY_FILE_NAMES = "file_names" diff --git a/src/nitpick/fields.py b/src/nitpick/fields.py index 8536875d..63635700 100644 --- a/src/nitpick/fields.py +++ b/src/nitpick/fields.py @@ -4,6 +4,7 @@ from marshmallow import ValidationError, fields from marshmallow.fields import Dict, Field, List, Nested, String from marshmallow.validate import Length +from more_itertools import always_iterable from nitpick.generic import pretty_exception @@ -27,11 +28,13 @@ def __call__(self, value): return super().__call__(value.strip()) -class FilledString(fields.String): +class NonEmptyString(fields.String): """A string field that must not be empty even after trimmed.""" def __init__(self, **kwargs): - super().__init__(validate=TrimmedLength(min=1), **kwargs) + validate = list(always_iterable(kwargs.pop("validate", None))) + validate.append(TrimmedLength(min=1)) + super().__init__(validate=validate, **kwargs) class JSONString(fields.String): @@ -46,8 +49,8 @@ def __init__(self, **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() + return fields.List(NonEmptyString(required=True, allow_none=False)) + return NonEmptyString() def validate_section_dot_field(section_field: str) -> bool: diff --git a/src/nitpick/files/__init__.py b/src/nitpick/files/__init__.py index 84bf3e7c..9add77db 100644 --- a/src/nitpick/files/__init__.py +++ b/src/nitpick/files/__init__.py @@ -3,3 +3,4 @@ # TODO: load all modules under files/*, so get_subclasses() detects them; or use a plugin system? import nitpick.files.pre_commit # noqa: F401 import nitpick.files.json # noqa: F401 +import nitpick.files.text # noqa: F401 diff --git a/src/nitpick/files/base.py b/src/nitpick/files/base.py index 6c50d2be..a5114ffd 100644 --- a/src/nitpick/files/base.py +++ b/src/nitpick/files/base.py @@ -5,6 +5,7 @@ import jmespath from nitpick.app import Nitpick +from nitpick.constants import KEY_FILE_NAMES from nitpick.formats import TomlFormat from nitpick.generic import get_subclasses, search_dict from nitpick.mixin import NitpickMixin @@ -35,9 +36,12 @@ class BaseFile(NitpickMixin, metaclass=abc.ABCMeta): fixed_name_classes = set() # type: Set[Type[BaseFile]] dynamic_name_classes = set() # type: Set[Type[BaseFile]] + #: Which :py:package:`identify` tags this :py:class:`nitpick.files.base.BaseFile` child recognises. + identify_tags = set() # type: Set[str] + def __init__(self) -> None: if self.has_multiple_files: - key = "{}.file_names".format(self.__class__.__name__) + key = "{}.{}".format(self.__class__.__name__, KEY_FILE_NAMES) self._multiple_files = search_dict(key, Nitpick.current_app().config.nitpick_section, []) # type: List[str] else: self._multiple_files = [self.file_name] diff --git a/src/nitpick/files/json.py b/src/nitpick/files/json.py index dfb6a503..7cc9d995 100644 --- a/src/nitpick/files/json.py +++ b/src/nitpick/files/json.py @@ -19,8 +19,8 @@ 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) + contains_keys = fields.List(fields.NonEmptyString) + contains_json = fields.Dict(fields.NonEmptyString, fields.JSONString) class JSONFile(BaseFile): @@ -39,6 +39,7 @@ class JSONFile(BaseFile): error_base_number = 340 nested_field = JSONFileSchema + identify_tags = {"json"} SOME_VALUE_PLACEHOLDER = "" diff --git a/src/nitpick/files/text.py b/src/nitpick/files/text.py new file mode 100644 index 00000000..60c0f96c --- /dev/null +++ b/src/nitpick/files/text.py @@ -0,0 +1,36 @@ +"""Text files.""" +import json +import logging + +from nitpick import fields +from nitpick.files.base import BaseFile +from nitpick.schemas import BaseNitpickSchema +from nitpick.typedefs import YieldFlake8Error + +LOGGER = logging.getLogger(__name__) + + +class LineSchema(BaseNitpickSchema): + line = fields.String() + + +class TextFileSchema(BaseNitpickSchema): + """Validation schema for any JSON file added to the style.""" + + contains = fields.List(fields.Nested(LineSchema)) + + +class TextFile(BaseFile): + """Checker for any text file.""" + + has_multiple_files = True + error_base_number = 350 + + nested_field = TextFileSchema + identify_tags = {"text"} + + def suggest_initial_contents(self) -> str: + x = self.file_path + + def check_rules(self) -> YieldFlake8Error: + x = self.file_path diff --git a/src/nitpick/schemas.py b/src/nitpick/schemas.py index 5cea2c5f..b0453df5 100644 --- a/src/nitpick/schemas.py +++ b/src/nitpick/schemas.py @@ -77,8 +77,8 @@ class NitpickFilesSectionSchema(BaseNitpickSchema): error_messages = {"unknown": help_message("Unknown file", "nitpick_section.html#nitpick-files")} - absent = fields.Dict(fields.FilledString, fields.String()) - present = fields.Dict(fields.FilledString, fields.String()) + 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) @@ -86,7 +86,7 @@ class NitpickFilesSectionSchema(BaseNitpickSchema): class NitpickSectionSchema(BaseNitpickSchema): """Validation schema for the ``[nitpick]`` section on the style file.""" - minimum_version = fields.FilledString() + minimum_version = fields.NonEmptyString() styles = fields.Nested(NitpickStylesSectionSchema) files = fields.Nested(NitpickFilesSectionSchema) # TODO: load this schema dynamically, then add this next field JSONFile diff --git a/src/nitpick/style.py b/src/nitpick/style.py index b4b0204e..bc908758 100644 --- a/src/nitpick/style.py +++ b/src/nitpick/style.py @@ -7,12 +7,14 @@ import click import requests +from identify import identify from slugify import slugify from toml import TomlDecodeError from nitpick import __version__, fields from nitpick.app import Nitpick from nitpick.constants import ( + KEY_FILE_NAMES, MERGED_STYLE_TOML, NITPICK_STYLE_TOML, NITPICK_STYLES_INCLUDE_JMEX, @@ -235,13 +237,33 @@ def rebuild_dynamic_schema(self, data: JsonDict = None) -> None: for subclass in BaseFile.fixed_name_classes: new_files_found.update(self.file_field_pair(subclass.file_name, subclass)) else: + handled_tags = {} # type: Dict[str, Type[BaseFile]] + # 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 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. + handled_tags[tag] = subclass + jmex = subclass.get_compiled_jmespath_file_names() for configured_file_name in search_dict(jmex, data, []): new_files_found.update(self.file_field_pair(configured_file_name, subclass)) + for possible_file in data.keys(): + found_subclasses = [] + for file_tag in identify.tags_from_filename(possible_file): + handler_subclass = handled_tags.get(file_tag) + if handler_subclass: + found_subclasses.append(handler_subclass) + + # TODO: Dirty hack to simulate multiple files + # data.update({"nitpick.{}".format(handler_subclass.__name__): {KEY_FILE_NAMES: [possible_file]}}) + + for found_subclass in found_subclasses: + new_files_found.update(self.file_field_pair(possible_file, found_subclass)) + # 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) diff --git a/tests/test_text.py b/tests/test_text.py new file mode 100644 index 00000000..cebe8b46 --- /dev/null +++ b/tests/test_text.py @@ -0,0 +1,18 @@ +"""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" + """ + ).flake8().assert_errors_contain( + """ + NIP341 File requirements.txt was not found. Create it with this content:\x1b[32m + sphinx>=1.3.0\x1b[0m + """ + ) From 7b39dcfd8e10396f6daf91e4708976d4a9c08105 Mon Sep 17 00:00:00 2001 From: "Augusto W. Andreoli" Date: Tue, 30 Jun 2020 03:02:39 +0200 Subject: [PATCH 02/12] docs: implementation comments Signed-off-by: Augusto W. Andreoli --- src/nitpick/files/base.py | 1 + src/nitpick/plugin.py | 3 +++ 2 files changed, 4 insertions(+) diff --git a/src/nitpick/files/base.py b/src/nitpick/files/base.py index a5114ffd..a1f083e2 100644 --- a/src/nitpick/files/base.py +++ b/src/nitpick/files/base.py @@ -111,6 +111,7 @@ def check_exists(self) -> YieldFlake8Error: elif file_exists and config_data_exists: yield from self.check_rules() + # FIXME: abstract methods become plugin hooks. Hooks should return a list of errors @abc.abstractmethod def check_rules(self) -> YieldFlake8Error: """Check rules for this file. It should be overridden by inherited classes if needed.""" diff --git a/src/nitpick/plugin.py b/src/nitpick/plugin.py index 2c7e357e..d0a7e1f5 100644 --- a/src/nitpick/plugin.py +++ b/src/nitpick/plugin.py @@ -59,6 +59,9 @@ def run(self) -> YieldFlake8Error: if has_errors: return [] + # FIXME: Get all root keys from the style TOML. All except "nitpick" are file names. + # Load all checker classes here (plugins). + # For each file name, find the checker class that can handle the file. for checker_class in get_subclasses(BaseFile): checker = checker_class() yield from checker.check_exists() From 5bfffab7cdda7bf5826a008dbe24f9ad63ea5bc2 Mon Sep 17 00:00:00 2001 From: "Augusto W. Andreoli" Date: Wed, 1 Jul 2020 02:17:32 +0200 Subject: [PATCH 03/12] chore: plugin skeleton (WIP) Signed-off-by: Augusto W. Andreoli --- poetry.lock | 8 ++++---- pyproject.toml | 1 + setup.cfg | 2 +- src/nitpick/plugin.py | 30 ++++++++++++++++++++++++++++++ 4 files changed, 36 insertions(+), 5 deletions(-) diff --git a/poetry.lock b/poetry.lock index d66bbd2e..1c024d65 100644 --- a/poetry.lock +++ b/poetry.lock @@ -458,7 +458,7 @@ version = "0.7.5" category = "main" description = "plugin and hook calling mechanisms for python" name = "pluggy" -optional = true +optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" version = "0.13.1" @@ -582,7 +582,7 @@ description = "A Python Slugify application that handles Unicode" name = "python-slugify" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" -version = "4.0.0" +version = "4.0.1" [package.dependencies] text-unidecode = ">=1.3" @@ -909,7 +909,7 @@ lint = ["pylint"] test = ["pytest", "testfixtures", "responses"] [metadata] -content-hash = "5506a97293f840f9b73b31ac77e25dcff0a95c7acd25857ae5eddf337239e3b4" +content-hash = "cd4ab91a9742dc79020ee7adb744525b7dadf169aec4709e317ce5c7be38cf76" python-versions = "^3.5 || ^3.6 || ^3.7 || ^3.8" [metadata.files] @@ -1162,7 +1162,7 @@ pytest = [ {file = "pytest-5.4.3.tar.gz", hash = "sha256:7979331bfcba207414f5e1263b5a0f8f521d0f457318836a7355531ed1a4c7d8"}, ] python-slugify = [ - {file = "python-slugify-4.0.0.tar.gz", hash = "sha256:a8fc3433821140e8f409a9831d13ae5deccd0b033d4744d94b31fea141bdd84c"}, + {file = "python-slugify-4.0.1.tar.gz", hash = "sha256:69a517766e00c1268e5bbfc0d010a0a8508de0b18d30ad5a1ff357f8ae724270"}, ] pytz = [ {file = "pytz-2020.1-py2.py3-none-any.whl", hash = "sha256:a494d53b6d39c3c6e44c3bec237336e14305e4f29bbf800b599253057fbb79ed"}, diff --git a/pyproject.toml b/pyproject.toml index 4ce36da0..e4dfee3a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,6 +47,7 @@ marshmallow = {version = ">=3.0.0b10"} marshmallow-polyfield = "^5.7" identify = "*" "more-itertools" = "*" +pluggy = "*" pylint = {version = "*", optional = true} pytest = {version = "*", optional = true} diff --git a/setup.cfg b/setup.cfg index 8ae0bdad..b030462b 100644 --- a/setup.cfg +++ b/setup.cfg @@ -50,7 +50,7 @@ include_trailing_comma = True force_grid_wrap = 0 combine_as_imports = True known_first_party = tests,nitpick -known_third_party = _pytest,attr,click,dictdiffer,flake8,identify,jmespath,marshmallow,marshmallow_polyfield,more_itertools,pytest,requests,responses,ruamel,slugify,sortedcontainers,testfixtures,toml +known_third_party = _pytest,attr,click,dictdiffer,flake8,identify,jmespath,marshmallow,marshmallow_polyfield,more_itertools,pluggy,pytest,requests,responses,ruamel,slugify,sortedcontainers,testfixtures,toml [mypy] ignore_missing_imports = True diff --git a/src/nitpick/plugin.py b/src/nitpick/plugin.py index d0a7e1f5..0991aa22 100644 --- a/src/nitpick/plugin.py +++ b/src/nitpick/plugin.py @@ -2,8 +2,10 @@ import itertools import logging from pathlib import Path +from typing import List import attr +import pluggy from flake8.options.manager import OptionManager from nitpick import __version__ @@ -14,9 +16,28 @@ from nitpick.mixin import NitpickMixin from nitpick.typedefs import YieldFlake8Error +hookspec = pluggy.HookspecMarker(PROJECT_NAME) +hookimpl = pluggy.HookimplMarker(PROJECT_NAME) + LOGGER = logging.getLogger(__name__) +class NitpickPlugin: + """Base for a Nitpick plugin.""" + + @hookspec + def relative_paths(self) -> List[str]: + """List of strings representing relative file paths handled by this plugin.""" + + @hookspec + def file_types(self) -> List[str]: + """Which :py:package:`identify` tags this plugin recognises.""" + + +# class TextPlugin: +# pass + + @attr.s(hash=False) class NitpickChecker(NitpickMixin): """Main plugin class.""" @@ -62,6 +83,15 @@ def run(self) -> YieldFlake8Error: # FIXME: Get all root keys from the style TOML. All except "nitpick" are file names. # Load all checker classes here (plugins). # For each file name, find the checker class that can handle the file. + # pm = pluggy.PluginManager(PROJECT_NAME) + # pm.add_hookspecs(NitpickPlugin) + # + # # register plugins + # pm.register(TextPlugin()) + # # call our `myhook` hook + # results = pm.hook.check_config_file(relative_file_path) + # print(results) + for checker_class in get_subclasses(BaseFile): checker = checker_class() yield from checker.check_exists() From 1cee37ff048022ba960761e7b6e0ab59b311bb55 Mon Sep 17 00:00:00 2001 From: "Augusto W. Andreoli" Date: Thu, 2 Jul 2020 00:58:12 +0200 Subject: [PATCH 04/12] chore: hack to keep using BaseFile while migrating to plugins (WIP) Signed-off-by: Augusto W. Andreoli --- poetry.lock | 6 +- src/nitpick/files/base.py | 8 ++- src/nitpick/files/text.py | 22 ++----- src/nitpick/plugin.py | 131 +++++++++++++++++++++++++++++--------- src/nitpick/style.py | 25 ++++---- tests/test_json.py | 2 +- tests/test_text.py | 3 + 7 files changed, 130 insertions(+), 67 deletions(-) diff --git a/poetry.lock b/poetry.lock index 1c024d65..cda33833 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.20" +version = "1.4.21" [package.extras] license = ["editdistance"] @@ -985,8 +985,8 @@ icecream = [ {file = "icecream-2.0.0.tar.gz", hash = "sha256:434e14a50da01f9dc1e5757efec7613db5df048ebdcecd460236db41688f779a"}, ] identify = [ - {file = "identify-1.4.20-py2.py3-none-any.whl", hash = "sha256:acf0712ab4042642e8f44e9532d95c26fbe60c0ab8b6e5b654dd1bc6512810e0"}, - {file = "identify-1.4.20.tar.gz", hash = "sha256:b2cd24dece806707e0b50517c1b3bcf3044e0b1cb13a72e7d34aa31c91f2a55a"}, + {file = "identify-1.4.21-py2.py3-none-any.whl", hash = "sha256:dac33eff90d57164e289fb20bf4e131baef080947ee9bf45efcd0da8d19064bf"}, + {file = "identify-1.4.21.tar.gz", hash = "sha256:c4d07f2b979e3931894170a9e0d4b8281e6905ea6d018c326f7ffefaf20db680"}, ] idna = [ {file = "idna-2.10-py2.py3-none-any.whl", hash = "sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0"}, diff --git a/src/nitpick/files/base.py b/src/nitpick/files/base.py index a1f083e2..8e7f5567 100644 --- a/src/nitpick/files/base.py +++ b/src/nitpick/files/base.py @@ -39,7 +39,12 @@ class BaseFile(NitpickMixin, metaclass=abc.ABCMeta): #: Which :py:package:`identify` tags this :py:class:`nitpick.files.base.BaseFile` child recognises. identify_tags = set() # type: Set[str] - def __init__(self) -> None: + def __init__(self, filename: str = None) -> None: + if filename is not None: + # FIXME: Hack to keep using BaseFile while migrating to the plugin architecture + self.has_multiple_files = False + self.file_name = filename + if self.has_multiple_files: key = "{}.{}".format(self.__class__.__name__, KEY_FILE_NAMES) self._multiple_files = search_dict(key, Nitpick.current_app().config.nitpick_section, []) # type: List[str] @@ -111,7 +116,6 @@ def check_exists(self) -> YieldFlake8Error: elif file_exists and config_data_exists: yield from self.check_rules() - # FIXME: abstract methods become plugin hooks. Hooks should return a list of errors @abc.abstractmethod def check_rules(self) -> YieldFlake8Error: """Check rules for this file. It should be overridden by inherited classes if needed.""" diff --git a/src/nitpick/files/text.py b/src/nitpick/files/text.py index 60c0f96c..799ec15b 100644 --- a/src/nitpick/files/text.py +++ b/src/nitpick/files/text.py @@ -1,36 +1,22 @@ """Text files.""" -import json import logging -from nitpick import fields from nitpick.files.base import BaseFile -from nitpick.schemas import BaseNitpickSchema from nitpick.typedefs import YieldFlake8Error LOGGER = logging.getLogger(__name__) -class LineSchema(BaseNitpickSchema): - line = fields.String() - - -class TextFileSchema(BaseNitpickSchema): - """Validation schema for any JSON file added to the style.""" - - contains = fields.List(fields.Nested(LineSchema)) - - class TextFile(BaseFile): """Checker for any text file.""" has_multiple_files = True error_base_number = 350 - nested_field = TextFileSchema - identify_tags = {"text"} - def suggest_initial_contents(self) -> str: - x = self.file_path + """Suggest the initial content for this missing file.""" + return "" def check_rules(self) -> YieldFlake8Error: - x = self.file_path + """Check rules for this file. It should be overridden by inherited classes if needed.""" + return [] diff --git a/src/nitpick/plugin.py b/src/nitpick/plugin.py index 0991aa22..319a63c5 100644 --- a/src/nitpick/plugin.py +++ b/src/nitpick/plugin.py @@ -1,18 +1,22 @@ """Flake8 plugin to check files.""" +# pylint: disable=too-few-public-methods,import-outside-toplevel +# FIXME: remove this disabled warning import itertools import logging from pathlib import Path -from typing import List +from typing import Optional, Set import attr import pluggy from flake8.options.manager import OptionManager +from identify import identify +from pluggy import PluginManager from nitpick import __version__ from nitpick.app import Nitpick from nitpick.constants import PROJECT_NAME from nitpick.files.base import BaseFile -from nitpick.generic import get_subclasses +from nitpick.formats import TomlFormat from nitpick.mixin import NitpickMixin from nitpick.typedefs import YieldFlake8Error @@ -25,17 +29,71 @@ class NitpickPlugin: """Base for a Nitpick plugin.""" - @hookspec - def relative_paths(self) -> List[str]: - """List of strings representing relative file paths handled by this plugin.""" + base_file = None # type: BaseFile @hookspec - def file_types(self) -> List[str]: - """Which :py:package:`identify` tags this plugin recognises.""" + def handle(self, filename: str, tags: Set[str]) -> Optional["NitpickPlugin"]: + """Return self if this plugin handle the relative filename or any of its :py:package:`identify` tags.""" + + +class TextPlugin(NitpickPlugin): + """Handle text files.""" + + @hookimpl + def handle(self, filename: str, tags: Set[str]) -> Optional[NitpickPlugin]: + """Handle text files.""" + from nitpick.files.text import TextFile + + self.base_file = TextFile(filename) + return self if "plain-text" in tags else None + + +class JSONPlugin(NitpickPlugin): + """Handle JSON files.""" + + @hookimpl + def handle(self, filename: str, tags: Set[str]) -> Optional[NitpickPlugin]: + """Handle JSON files.""" + from nitpick.files.json import JSONFile + + self.base_file = JSONFile() + return self if "json" in tags else None + + +class PreCommitPlugin(NitpickPlugin): + """Handle pre-commit config file.""" + + @hookimpl + def handle(self, filename: str, tags: Set[str]) -> Optional[NitpickPlugin]: + """Handle pre-commit config file.""" + from nitpick.files.pre_commit import PreCommitFile + + self.base_file = PreCommitFile() + return self if filename == TomlFormat.group_name_for(self.base_file.file_name) else None -# class TextPlugin: -# pass +class SetupCfgPlugin(NitpickPlugin): + """Handle setup.cfg files.""" + + @hookimpl + def handle(self, filename: str, tags: Set[str]) -> Optional[NitpickPlugin]: + """Handle setup.cfg files.""" + from nitpick.files.setup_cfg import SetupCfgFile + + self.base_file = SetupCfgFile() + return self if filename == SetupCfgFile.file_name else None + + +class PyProjectTomlPlugin(NitpickPlugin): + """Handle pyproject.toml file.""" + + @hookimpl + def handle(self, filename: str, tags: Set[str]) -> Optional[NitpickPlugin]: + """Handle pyproject.toml file.""" + from nitpick.files.pyproject_toml import PyProjectTomlFile + + self.base_file = PyProjectTomlFile() + return self if filename == self.base_file.file_name else None @attr.s(hash=False) @@ -56,48 +114,61 @@ class NitpickChecker(NitpickMixin): def run(self) -> YieldFlake8Error: """Run the check plugin.""" has_errors = False - for err in Nitpick.current_app().init_errors: + app = Nitpick.current_app() + for err in app.init_errors: has_errors = True yield Nitpick.as_flake8_warning(err) if has_errors: return [] current_python_file = Path(self.filename) - if current_python_file.absolute() != Nitpick.current_app().main_python_file.absolute(): + if current_python_file.absolute() != app.main_python_file.absolute(): # Only report warnings once, for the main Python file of this project. LOGGER.debug("Ignoring file: %s", self.filename) return [] LOGGER.debug("Nitpicking file: %s", self.filename) - yield from itertools.chain( - Nitpick.current_app().config.merge_styles(), self.check_files(True), self.check_files(False) - ) + yield from itertools.chain(app.config.merge_styles(), self.check_files(True), self.check_files(False)) has_errors = False - for err in Nitpick.current_app().style_errors: + for err in app.style_errors: has_errors = True yield Nitpick.as_flake8_warning(err) if has_errors: return [] - # FIXME: Get all root keys from the style TOML. All except "nitpick" are file names. - # Load all checker classes here (plugins). - # For each file name, find the checker class that can handle the file. - # pm = pluggy.PluginManager(PROJECT_NAME) - # pm.add_hookspecs(NitpickPlugin) - # - # # register plugins - # pm.register(TextPlugin()) - # # call our `myhook` hook - # results = pm.hook.check_config_file(relative_file_path) - # print(results) - - for checker_class in get_subclasses(BaseFile): - checker = checker_class() - yield from checker.check_exists() + plugin_manager = self.load_plugins() + + # Get all root keys from the style TOML. + for path in app.config.style_dict: + # All except "nitpick" are file names. + if path == PROJECT_NAME: + continue + + # For each file name, find the plugin that can handle the file. + tags = identify.tags_from_filename(path) + for plugin in plugin_manager.hook.handle( # pylint: disable=no-member + filename=path, tags=tags + ): # type: NitpickPlugin + yield from plugin.base_file.check_exists() return [] + @staticmethod + def load_plugins() -> PluginManager: + """Load all defined plugins.""" + plugin_manager = pluggy.PluginManager(PROJECT_NAME) + plugin_manager.add_hookspecs(NitpickPlugin) + + # FIXME: use entry points instead + plugin_manager.register(JSONPlugin()) + plugin_manager.register(TextPlugin()) + plugin_manager.register(PreCommitPlugin()) + plugin_manager.register(SetupCfgPlugin()) + plugin_manager.register(PyProjectTomlPlugin()) + + return plugin_manager + def check_files(self, present: bool) -> YieldFlake8Error: """Check files that should be present or absent.""" key = "present" if present else "absent" diff --git a/src/nitpick/style.py b/src/nitpick/style.py index bc908758..2481915c 100644 --- a/src/nitpick/style.py +++ b/src/nitpick/style.py @@ -14,7 +14,6 @@ from nitpick import __version__, fields from nitpick.app import Nitpick from nitpick.constants import ( - KEY_FILE_NAMES, MERGED_STYLE_TOML, NITPICK_STYLE_TOML, NITPICK_STYLES_INCLUDE_JMEX, @@ -251,19 +250,19 @@ 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)) - for possible_file in data.keys(): - found_subclasses = [] - for file_tag in identify.tags_from_filename(possible_file): - handler_subclass = handled_tags.get(file_tag) - if handler_subclass: - found_subclasses.append(handler_subclass) - - # TODO: Dirty hack to simulate multiple files - # data.update({"nitpick.{}".format(handler_subclass.__name__): {KEY_FILE_NAMES: [possible_file]}}) - - for found_subclass in found_subclasses: - new_files_found.update(self.file_field_pair(possible_file, found_subclass)) + self._find_sublcasses(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): + for possible_file in data.keys(): + found_subclasses = [] + for file_tag in identify.tags_from_filename(possible_file): + handler_subclass = handled_tags.get(file_tag) + if handler_subclass: + found_subclasses.append(handler_subclass) + + for found_subclass in found_subclasses: + new_files_found.update(self.file_field_pair(possible_file, found_subclass)) diff --git a/tests/test_json.py b/tests/test_json.py index 371ed29f..32d78314 100644 --- a/tests/test_json.py +++ b/tests/test_json.py @@ -145,7 +145,7 @@ def test_json_file_with_extra_keys(request): ).flake8().assert_errors_contain( """ NIP001 File nitpick-style.toml has an incorrect style. Invalid config:\x1b[32m - "their.json": Unknown file. See https://nitpick.rtfd.io/en/latest/config_files.html. + "their.json".x: Unknown configuration. See https://nitpick.rtfd.io/en/latest/nitpick_section.html. "your.json".has: Unknown configuration. See https://nitpick.rtfd.io/en/latest/nitpick_section.html.\x1b[0m """, 1, diff --git a/tests/test_text.py b/tests/test_text.py index cebe8b46..f9452e66 100644 --- a/tests/test_text.py +++ b/tests/test_text.py @@ -1,7 +1,10 @@ """Text file tests.""" +import pytest + from tests.helpers import ProjectMock +@pytest.mark.xfail(reason="WIP") def test_suggest_initial_contents(request): """Suggest initial contents for a text file.""" ProjectMock(request).style( From dba2a733929861912d07057217fc9bebbe102280 Mon Sep 17 00:00:00 2001 From: "Augusto W. Andreoli" Date: Thu, 2 Jul 2020 20:28:38 +0200 Subject: [PATCH 05/12] ci: run pytest as a module Signed-off-by: Augusto W. Andreoli --- setup.cfg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.cfg b/setup.cfg index b030462b..7ae99ee3 100644 --- a/setup.cfg +++ b/setup.cfg @@ -87,7 +87,7 @@ depends = report: py39,py38,py37,py36,py35 commands = # https://pytest-cov.readthedocs.io/en/latest/config.html - pytest --cov-config=setup.cfg --cov --cov-append --cov-report=term-missing --doctest-modules {posargs:-vv} + python -m pytest --cov-config=setup.cfg --cov --cov-append --cov-report=term-missing --doctest-modules {posargs:-vv} # https://pytest-cov.readthedocs.io/en/latest/tox.html [testenv:clean] From 7e32ffee9a8a9a1d26ebe9cf160a410878d08909 Mon Sep 17 00:00:00 2001 From: "Augusto W. Andreoli" Date: Sun, 5 Jul 2020 03:55:07 +0200 Subject: [PATCH 06/12] refactor: separate plugin and flake8 modules Signed-off-by: Augusto W. Andreoli --- Makefile | 7 +- docs/config_files.rst | 7 + docs/generate_rst.py | 3 +- docs/source/nitpick.files.rst | 1 + docs/source/nitpick.rst | 1 + pyproject.toml | 2 +- src/nitpick/app.py | 2 +- src/nitpick/config.py | 2 +- src/nitpick/files/json.py | 12 ++ src/nitpick/files/pre_commit.py | 15 ++- src/nitpick/files/pyproject_toml.py | 13 ++ src/nitpick/files/setup_cfg.py | 13 +- src/nitpick/files/text.py | 12 ++ src/nitpick/flake8.py | 140 ++++++++++++++++++++ src/nitpick/plugin.py | 198 +--------------------------- tests/helpers.py | 2 +- tests/test_style.py | 2 +- 17 files changed, 230 insertions(+), 202 deletions(-) create mode 100644 src/nitpick/flake8.py diff --git a/Makefile b/Makefile index 0da3f4aa..8ef37a0b 100644 --- a/Makefile +++ b/Makefile @@ -29,7 +29,8 @@ 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 - pre-commit autoupdate +# TODO: isort 5.0.0 is apparently broken, so we can't autoupdate for now +# pre-commit autoupdate pre-commit install --install-hooks pre-commit install --hook-type commit-msg pre-commit gc @@ -67,6 +68,10 @@ endif touch .cache/make/test .PHONY: test +pytest: # Run pytest on the poetry venv (to quickly run tests locally without waiting for tox) + poetry run pytest +.PHONY: pytest + doc .cache/make/doc: docs/*/* styles/*/* *.rst *.md # Build documentation only @rm -rf docs/source tox -e docs diff --git a/docs/config_files.rst b/docs/config_files.rst index bc643d3a..ed508ac7 100644 --- a/docs/config_files.rst +++ b/docs/config_files.rst @@ -52,3 +52,10 @@ 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. + +.. _textfile: + +Text files +---------- + +Checker for any text file. diff --git a/docs/generate_rst.py b/docs/generate_rst.py index b0d54737..b873b4e6 100644 --- a/docs/generate_rst.py +++ b/docs/generate_rst.py @@ -21,6 +21,7 @@ from nitpick.files.pre_commit import PreCommitFile from nitpick.files.pyproject_toml import PyProjectTomlFile from nitpick.files.setup_cfg import SetupCfgFile +from nitpick.files.text import TextFile from nitpick.generic import get_subclasses style_mapping = SortedDict( @@ -47,7 +48,7 @@ "python37.toml": "Python 3.7", } ) -file_classes = [PyProjectTomlFile, SetupCfgFile, PreCommitFile, JSONFile] +file_classes = [PyProjectTomlFile, SetupCfgFile, PreCommitFile, JSONFile, TextFile] divider = ".. auto-generated-from-here" docs_dir = Path(__file__).parent.absolute() # type: Path diff --git a/docs/source/nitpick.files.rst b/docs/source/nitpick.files.rst index c974a388..3e235c64 100644 --- a/docs/source/nitpick.files.rst +++ b/docs/source/nitpick.files.rst @@ -17,3 +17,4 @@ Submodules nitpick.files.pre_commit nitpick.files.pyproject_toml nitpick.files.setup_cfg + nitpick.files.text diff --git a/docs/source/nitpick.rst b/docs/source/nitpick.rst index b1d93576..bec4b84e 100644 --- a/docs/source/nitpick.rst +++ b/docs/source/nitpick.rst @@ -25,6 +25,7 @@ Submodules nitpick.constants nitpick.exceptions nitpick.fields + nitpick.flake8 nitpick.formats nitpick.generic nitpick.mixin diff --git a/pyproject.toml b/pyproject.toml index e4dfee3a..f18610a5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,7 +25,7 @@ classifiers = [ ] [tool.poetry.plugins."flake8.extension"] -NIP = "nitpick.plugin:NitpickChecker" +NIP = "nitpick.flake8:NitpickChecker" [tool.poetry.dependencies] python = "^3.5 || ^3.6 || ^3.7 || ^3.8" diff --git a/src/nitpick/app.py b/src/nitpick/app.py index 7ee74d74..a3096eea 100644 --- a/src/nitpick/app.py +++ b/src/nitpick/app.py @@ -162,7 +162,7 @@ def as_flake8_warning(nitpick_error: NitpickError) -> Flake8Error: else "" ) - from nitpick.plugin import NitpickChecker # pylint: disable=import-outside-toplevel + from nitpick.flake8 import NitpickChecker # pylint: disable=import-outside-toplevel return ( 0, diff --git a/src/nitpick/config.py b/src/nitpick/config.py index b5fef7d4..d9962629 100644 --- a/src/nitpick/config.py +++ b/src/nitpick/config.py @@ -69,7 +69,7 @@ def merge_styles(self) -> YieldFlake8Error: # 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) - from nitpick.plugin import NitpickChecker # pylint: disable=import-outside-toplevel + from nitpick.flake8 import NitpickChecker # pylint: disable=import-outside-toplevel minimum_version = search_dict(NITPICK_MINIMUM_VERSION_JMEX, self.style_dict, None) if minimum_version and version_to_tuple(NitpickChecker.version) < version_to_tuple(minimum_version): diff --git a/src/nitpick/files/json.py b/src/nitpick/files/json.py index 7cc9d995..96168023 100644 --- a/src/nitpick/files/json.py +++ b/src/nitpick/files/json.py @@ -1,6 +1,7 @@ """JSON files.""" import json import logging +from typing import Optional, Set from sortedcontainers import SortedDict @@ -8,6 +9,7 @@ from nitpick.files.base import BaseFile from nitpick.formats import JsonFormat from nitpick.generic import flatten, unflatten +from nitpick.plugin import NitpickPlugin, hookimpl from nitpick.schemas import BaseNitpickSchema from nitpick.typedefs import JsonDict, YieldFlake8Error @@ -88,3 +90,13 @@ def _check_contained_json(self) -> YieldFlake8Error: yield from self.warn_missing_different( JsonFormat(data=actual_fmt.as_data).compare_with_dictdiffer(expected, unflatten) ) + + +class JSONPlugin(NitpickPlugin): # pylint: disable=too-few-public-methods + """Handle JSON files.""" + + @hookimpl + def handle(self, filename: str, tags: Set[str]) -> Optional[NitpickPlugin]: + """Handle JSON files.""" + self.base_file = JSONFile() + return self if "json" in tags else None diff --git a/src/nitpick/files/pre_commit.py b/src/nitpick/files/pre_commit.py index 23117ba8..b160e38a 100644 --- a/src/nitpick/files/pre_commit.py +++ b/src/nitpick/files/pre_commit.py @@ -1,12 +1,13 @@ """Checker for the `.pre-commit-config.yaml `_ file.""" from collections import OrderedDict -from typing import Any, Dict, List, Tuple, Union +from typing import Any, Dict, List, Optional, Set, Tuple, Union import attr from nitpick.files.base import BaseFile -from nitpick.formats import YamlFormat +from nitpick.formats import TomlFormat, YamlFormat from nitpick.generic import find_object_by_key, search_dict +from nitpick.plugin import NitpickPlugin, hookimpl from nitpick.typedefs import JsonDict, YamlData, YieldFlake8Error KEY_REPOS = "repos" @@ -187,3 +188,13 @@ def format_hook(expected_dict) -> str: else: output.append(" {}".format(line)) return "\n".join(output) + + +class PreCommitPlugin(NitpickPlugin): # pylint: disable=too-few-public-methods + """Handle pre-commit config file.""" + + @hookimpl + def handle(self, filename: str, tags: Set[str]) -> Optional[NitpickPlugin]: + """Handle pre-commit config file.""" + self.base_file = PreCommitFile() + return self if filename == TomlFormat.group_name_for(self.base_file.file_name) else None diff --git a/src/nitpick/files/pyproject_toml.py b/src/nitpick/files/pyproject_toml.py index 90665316..aceb1433 100644 --- a/src/nitpick/files/pyproject_toml.py +++ b/src/nitpick/files/pyproject_toml.py @@ -1,6 +1,9 @@ """Checker for `pyproject.toml `_.""" +from typing import Optional, Set + from nitpick.app import Nitpick from nitpick.files.base import BaseFile +from nitpick.plugin import NitpickPlugin, hookimpl from nitpick.typedefs import YieldFlake8Error @@ -25,3 +28,13 @@ def check_rules(self) -> YieldFlake8Error: def suggest_initial_contents(self) -> str: """Suggest the initial content for this missing file.""" return "" + + +class PyProjectTomlPlugin(NitpickPlugin): # pylint: disable=too-few-public-methods + """Handle pyproject.toml file.""" + + @hookimpl + def handle(self, filename: str, tags: Set[str]) -> Optional[NitpickPlugin]: + """Handle pyproject.toml file.""" + self.base_file = PyProjectTomlFile() + return self if filename == self.base_file.file_name else None diff --git a/src/nitpick/files/setup_cfg.py b/src/nitpick/files/setup_cfg.py index 51c0b5fa..99e82a72 100644 --- a/src/nitpick/files/setup_cfg.py +++ b/src/nitpick/files/setup_cfg.py @@ -1,11 +1,12 @@ """Checker for the `setup.cfg ` config file.""" from configparser import ConfigParser from io import StringIO -from typing import Any, Dict, List, Set, Tuple +from typing import Any, Dict, List, Optional, Set, Tuple import dictdiffer from nitpick.files.base import BaseFile +from nitpick.plugin import NitpickPlugin, hookimpl from nitpick.typedefs import YieldFlake8Error @@ -113,3 +114,13 @@ def get_example_cfg(config_parser: ConfigParser) -> str: config_parser.write(string_stream) output = string_stream.getvalue().strip() return output + + +class SetupCfgPlugin(NitpickPlugin): # pylint: disable=too-few-public-methods + """Handle setup.cfg files.""" + + @hookimpl + def handle(self, filename: str, tags: Set[str]) -> Optional[NitpickPlugin]: + """Handle setup.cfg files.""" + self.base_file = SetupCfgFile() + return self if filename == SetupCfgFile.file_name else None diff --git a/src/nitpick/files/text.py b/src/nitpick/files/text.py index 799ec15b..805346ce 100644 --- a/src/nitpick/files/text.py +++ b/src/nitpick/files/text.py @@ -1,7 +1,9 @@ """Text files.""" import logging +from typing import Optional, Set from nitpick.files.base import BaseFile +from nitpick.plugin import NitpickPlugin, hookimpl from nitpick.typedefs import YieldFlake8Error LOGGER = logging.getLogger(__name__) @@ -20,3 +22,13 @@ def suggest_initial_contents(self) -> str: def check_rules(self) -> YieldFlake8Error: """Check rules for this file. It should be overridden by inherited classes if needed.""" return [] + + +class TextPlugin(NitpickPlugin): # pylint: disable=too-few-public-methods + """Handle text files.""" + + @hookimpl + def handle(self, filename: str, tags: Set[str]) -> Optional[NitpickPlugin]: + """Handle text files.""" + self.base_file = TextFile(filename) + return self if "plain-text" in tags else None diff --git a/src/nitpick/flake8.py b/src/nitpick/flake8.py new file mode 100644 index 00000000..19ca3104 --- /dev/null +++ b/src/nitpick/flake8.py @@ -0,0 +1,140 @@ +"""Flake8 plugin.""" +import itertools +import logging +from pathlib import Path + +import attr +import pluggy +from flake8.options.manager import OptionManager +from identify import identify +from pluggy import PluginManager + +from nitpick import __version__ +from nitpick.app import Nitpick +from nitpick.constants import PROJECT_NAME +from nitpick.mixin import NitpickMixin +from nitpick.plugin import NitpickPlugin +from nitpick.typedefs import YieldFlake8Error + +LOGGER = logging.getLogger(__name__) + + +@attr.s(hash=False) +class NitpickChecker(NitpickMixin): + """Main plugin class.""" + + # Plugin config + name = PROJECT_NAME + version = __version__ + + # NitpickMixin + error_base_number = 100 + + # Plugin arguments passed by Flake8 + tree = attr.ib(default=None) + filename = attr.ib(default="(none)") + + def run(self) -> YieldFlake8Error: + """Run the check plugin.""" + has_errors = False + app = Nitpick.current_app() + for err in app.init_errors: + has_errors = True + yield Nitpick.as_flake8_warning(err) + if has_errors: + return [] + + current_python_file = Path(self.filename) + if current_python_file.absolute() != app.main_python_file.absolute(): + # Only report warnings once, for the main Python file of this project. + LOGGER.debug("Ignoring file: %s", self.filename) + return [] + LOGGER.debug("Nitpicking file: %s", self.filename) + + yield from itertools.chain(app.config.merge_styles(), self.check_files(True), self.check_files(False)) + + has_errors = False + for err in app.style_errors: + has_errors = True + yield Nitpick.as_flake8_warning(err) + if has_errors: + return [] + + plugin_manager = self.load_plugins() + + # Get all root keys from the style TOML. + for path in app.config.style_dict: + # All except "nitpick" are file names. + if path == PROJECT_NAME: + continue + + # For each file name, find the plugin that can handle the file. + tags = identify.tags_from_filename(path) + for plugin in plugin_manager.hook.handle( # pylint: disable=no-member + filename=path, tags=tags + ): # type: NitpickPlugin + yield from plugin.base_file.check_exists() + + return [] + + @staticmethod + def load_plugins() -> PluginManager: + """Load all defined plugins.""" + plugin_manager = pluggy.PluginManager(PROJECT_NAME) + plugin_manager.add_hookspecs(NitpickPlugin) + + # pylint: disable=import-outside-toplevel + from nitpick.files.pyproject_toml import PyProjectTomlPlugin + from nitpick.files.setup_cfg import SetupCfgPlugin + from nitpick.files.pre_commit import PreCommitPlugin + from nitpick.files.json import JSONPlugin + from nitpick.files.text import TextPlugin + + # FIXME: use entry points instead + # plugin_manager.load_setuptools_entrypoints(PROJECT_NAME) + plugin_manager.register(JSONPlugin()) + plugin_manager.register(TextPlugin()) + plugin_manager.register(PreCommitPlugin()) + plugin_manager.register(SetupCfgPlugin()) + plugin_manager.register(PyProjectTomlPlugin()) + + return plugin_manager + + def check_files(self, present: bool) -> YieldFlake8Error: + """Check files that should be present or absent.""" + key = "present" if present else "absent" + message = "exist" if present else "be deleted" + absent = not present + for file_name, extra_message in Nitpick.current_app().config.nitpick_files_section.get(key, {}).items(): + file_path = Nitpick.current_app().root_dir / file_name # type: Path + exists = file_path.exists() + if (present and exists) or (absent and not exists): + continue + + full_message = "File {} should {}".format(file_name, message) + if extra_message: + full_message += ": {}".format(extra_message) + + yield self.flake8_error(3 if present else 4, full_message) + + @staticmethod + def add_options(option_manager: OptionManager): + """Add the offline option.""" + option_manager.add_option( + Nitpick.format_flag(Nitpick.Flags.OFFLINE), + action="store_true", + # dest="offline", + help=Nitpick.Flags.OFFLINE.value, + ) + + @staticmethod + def parse_options(option_manager: OptionManager, options, args): # pylint: disable=unused-argument + """Create the Nitpick app, set logging from the verbose flags, set offline mode. + + This function is called only once by flake8, so it's a good place to create the app. + """ + log_mapping = {1: logging.INFO, 2: logging.DEBUG} + logging.basicConfig(level=log_mapping.get(options.verbose, logging.WARNING)) + + Nitpick.create_app(offline=bool(options.nitpick_offline or Nitpick.get_env(Nitpick.Flags.OFFLINE))) + LOGGER.info("Offline mode: %s", Nitpick.current_app().offline) diff --git a/src/nitpick/plugin.py b/src/nitpick/plugin.py index 319a63c5..7c9080a7 100644 --- a/src/nitpick/plugin.py +++ b/src/nitpick/plugin.py @@ -1,32 +1,19 @@ """Flake8 plugin to check files.""" -# pylint: disable=too-few-public-methods,import-outside-toplevel -# FIXME: remove this disabled warning -import itertools -import logging -from pathlib import Path -from typing import Optional, Set +from typing import TYPE_CHECKING, Optional, Set -import attr import pluggy -from flake8.options.manager import OptionManager -from identify import identify -from pluggy import PluginManager -from nitpick import __version__ -from nitpick.app import Nitpick from nitpick.constants import PROJECT_NAME -from nitpick.files.base import BaseFile -from nitpick.formats import TomlFormat -from nitpick.mixin import NitpickMixin -from nitpick.typedefs import YieldFlake8Error + +if TYPE_CHECKING: + from nitpick.files.base import BaseFile + hookspec = pluggy.HookspecMarker(PROJECT_NAME) hookimpl = pluggy.HookimplMarker(PROJECT_NAME) -LOGGER = logging.getLogger(__name__) - -class NitpickPlugin: +class NitpickPlugin: # pylint: disable=too-few-public-methods """Base for a Nitpick plugin.""" base_file = None # type: BaseFile @@ -34,176 +21,3 @@ class NitpickPlugin: @hookspec def handle(self, filename: str, tags: Set[str]) -> Optional["NitpickPlugin"]: """Return self if this plugin handle the relative filename or any of its :py:package:`identify` tags.""" - - -class TextPlugin(NitpickPlugin): - """Handle text files.""" - - @hookimpl - def handle(self, filename: str, tags: Set[str]) -> Optional[NitpickPlugin]: - """Handle text files.""" - from nitpick.files.text import TextFile - - self.base_file = TextFile(filename) - return self if "plain-text" in tags else None - - -class JSONPlugin(NitpickPlugin): - """Handle JSON files.""" - - @hookimpl - def handle(self, filename: str, tags: Set[str]) -> Optional[NitpickPlugin]: - """Handle JSON files.""" - from nitpick.files.json import JSONFile - - self.base_file = JSONFile() - return self if "json" in tags else None - - -class PreCommitPlugin(NitpickPlugin): - """Handle pre-commit config file.""" - - @hookimpl - def handle(self, filename: str, tags: Set[str]) -> Optional[NitpickPlugin]: - """Handle pre-commit config file.""" - from nitpick.files.pre_commit import PreCommitFile - - self.base_file = PreCommitFile() - return self if filename == TomlFormat.group_name_for(self.base_file.file_name) else None - - -class SetupCfgPlugin(NitpickPlugin): - """Handle setup.cfg files.""" - - @hookimpl - def handle(self, filename: str, tags: Set[str]) -> Optional[NitpickPlugin]: - """Handle setup.cfg files.""" - from nitpick.files.setup_cfg import SetupCfgFile - - self.base_file = SetupCfgFile() - return self if filename == SetupCfgFile.file_name else None - - -class PyProjectTomlPlugin(NitpickPlugin): - """Handle pyproject.toml file.""" - - @hookimpl - def handle(self, filename: str, tags: Set[str]) -> Optional[NitpickPlugin]: - """Handle pyproject.toml file.""" - from nitpick.files.pyproject_toml import PyProjectTomlFile - - self.base_file = PyProjectTomlFile() - return self if filename == self.base_file.file_name else None - - -@attr.s(hash=False) -class NitpickChecker(NitpickMixin): - """Main plugin class.""" - - # Plugin config - name = PROJECT_NAME - version = __version__ - - # NitpickMixin - error_base_number = 100 - - # Plugin arguments passed by Flake8 - tree = attr.ib(default=None) - filename = attr.ib(default="(none)") - - def run(self) -> YieldFlake8Error: - """Run the check plugin.""" - has_errors = False - app = Nitpick.current_app() - for err in app.init_errors: - has_errors = True - yield Nitpick.as_flake8_warning(err) - if has_errors: - return [] - - current_python_file = Path(self.filename) - if current_python_file.absolute() != app.main_python_file.absolute(): - # Only report warnings once, for the main Python file of this project. - LOGGER.debug("Ignoring file: %s", self.filename) - return [] - LOGGER.debug("Nitpicking file: %s", self.filename) - - yield from itertools.chain(app.config.merge_styles(), self.check_files(True), self.check_files(False)) - - has_errors = False - for err in app.style_errors: - has_errors = True - yield Nitpick.as_flake8_warning(err) - if has_errors: - return [] - - plugin_manager = self.load_plugins() - - # Get all root keys from the style TOML. - for path in app.config.style_dict: - # All except "nitpick" are file names. - if path == PROJECT_NAME: - continue - - # For each file name, find the plugin that can handle the file. - tags = identify.tags_from_filename(path) - for plugin in plugin_manager.hook.handle( # pylint: disable=no-member - filename=path, tags=tags - ): # type: NitpickPlugin - yield from plugin.base_file.check_exists() - - return [] - - @staticmethod - def load_plugins() -> PluginManager: - """Load all defined plugins.""" - plugin_manager = pluggy.PluginManager(PROJECT_NAME) - plugin_manager.add_hookspecs(NitpickPlugin) - - # FIXME: use entry points instead - plugin_manager.register(JSONPlugin()) - plugin_manager.register(TextPlugin()) - plugin_manager.register(PreCommitPlugin()) - plugin_manager.register(SetupCfgPlugin()) - plugin_manager.register(PyProjectTomlPlugin()) - - return plugin_manager - - def check_files(self, present: bool) -> YieldFlake8Error: - """Check files that should be present or absent.""" - key = "present" if present else "absent" - message = "exist" if present else "be deleted" - absent = not present - for file_name, extra_message in Nitpick.current_app().config.nitpick_files_section.get(key, {}).items(): - file_path = Nitpick.current_app().root_dir / file_name # type: Path - exists = file_path.exists() - if (present and exists) or (absent and not exists): - continue - - full_message = "File {} should {}".format(file_name, message) - if extra_message: - full_message += ": {}".format(extra_message) - - yield self.flake8_error(3 if present else 4, full_message) - - @staticmethod - def add_options(option_manager: OptionManager): - """Add the offline option.""" - option_manager.add_option( - Nitpick.format_flag(Nitpick.Flags.OFFLINE), - action="store_true", - # dest="offline", - help=Nitpick.Flags.OFFLINE.value, - ) - - @staticmethod - def parse_options(option_manager: OptionManager, options, args): # pylint: disable=unused-argument - """Create the Nitpick app, set logging from the verbose flags, set offline mode. - - This function is called only once by flake8, so it's a good place to create the app. - """ - log_mapping = {1: logging.INFO, 2: logging.DEBUG} - logging.basicConfig(level=log_mapping.get(options.verbose, logging.WARNING)) - - Nitpick.create_app(offline=bool(options.nitpick_offline or Nitpick.get_env(Nitpick.Flags.OFFLINE))) - LOGGER.info("Offline mode: %s", Nitpick.current_app().offline) diff --git a/tests/helpers.py b/tests/helpers.py index f923ba82..b4b30faa 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -13,8 +13,8 @@ from nitpick.files.pre_commit import PreCommitFile from nitpick.files.pyproject_toml import PyProjectTomlFile from nitpick.files.setup_cfg import SetupCfgFile +from nitpick.flake8 import NitpickChecker from nitpick.formats import TomlFormat -from nitpick.plugin import NitpickChecker from nitpick.typedefs import PathOrStr from tests.conftest import TEMP_ROOT_PATH diff --git a/tests/test_style.py b/tests/test_style.py index aa099a76..b207be38 100644 --- a/tests/test_style.py +++ b/tests/test_style.py @@ -152,7 +152,7 @@ def test_include_styles_overriding_values(offline, request): @pytest.mark.parametrize("offline", [False, True]) -@mock.patch("nitpick.plugin.NitpickChecker.version", new_callable=PropertyMock(return_value="0.5.3")) +@mock.patch("nitpick.flake8.NitpickChecker.version", new_callable=PropertyMock(return_value="0.5.3")) def test_minimum_version(mocked_version, offline, request): """Stamp a style file with a minimum required version, to indicate new features or breaking changes.""" assert_conditions(mocked_version == "0.5.3") From d5f0fd6a2720e83c8c332c0f54fc6da2b6bdfbc3 Mon Sep 17 00:00:00 2001 From: "Augusto W. Andreoli" Date: Sun, 5 Jul 2020 18:53:41 +0200 Subject: [PATCH 07/12] refactor: load setuptools entry points Signed-off-by: Augusto W. Andreoli --- docs/generate_rst.py | 3 +-- docs/source/nitpick.flake8.rst | 7 ++++++ poetry.lock | 6 ++--- pyproject.toml | 6 +++++ src/nitpick/app.py | 15 +++++++++++- src/nitpick/files/__init__.py | 5 ---- src/nitpick/files/base.py | 2 +- src/nitpick/files/json.py | 16 ++++++------ src/nitpick/files/pre_commit.py | 15 ++++++------ src/nitpick/files/pyproject_toml.py | 17 ++++++------- src/nitpick/files/setup_cfg.py | 14 +++++------ src/nitpick/files/text.py | 34 -------------------------- src/nitpick/flake8.py | 38 ++++------------------------- src/nitpick/plugin.py | 15 +++++------- 14 files changed, 71 insertions(+), 122 deletions(-) create mode 100644 docs/source/nitpick.flake8.rst delete mode 100644 src/nitpick/files/text.py diff --git a/docs/generate_rst.py b/docs/generate_rst.py index b873b4e6..b0d54737 100644 --- a/docs/generate_rst.py +++ b/docs/generate_rst.py @@ -21,7 +21,6 @@ from nitpick.files.pre_commit import PreCommitFile from nitpick.files.pyproject_toml import PyProjectTomlFile from nitpick.files.setup_cfg import SetupCfgFile -from nitpick.files.text import TextFile from nitpick.generic import get_subclasses style_mapping = SortedDict( @@ -48,7 +47,7 @@ "python37.toml": "Python 3.7", } ) -file_classes = [PyProjectTomlFile, SetupCfgFile, PreCommitFile, JSONFile, TextFile] +file_classes = [PyProjectTomlFile, SetupCfgFile, PreCommitFile, JSONFile] divider = ".. auto-generated-from-here" docs_dir = Path(__file__).parent.absolute() # type: Path diff --git a/docs/source/nitpick.flake8.rst b/docs/source/nitpick.flake8.rst new file mode 100644 index 00000000..86c2b019 --- /dev/null +++ b/docs/source/nitpick.flake8.rst @@ -0,0 +1,7 @@ +nitpick.flake8 module +===================== + +.. automodule:: nitpick.flake8 + :members: + :undoc-members: + :show-inheritance: diff --git a/poetry.lock b/poetry.lock index cda33833..6e5d1aa3 100644 --- a/poetry.lock +++ b/poetry.lock @@ -687,7 +687,7 @@ description = "Python documentation generator" name = "sphinx" optional = true python-versions = ">=3.5" -version = "3.1.1" +version = "3.1.2" [package.dependencies] Jinja2 = ">=2.3" @@ -1214,8 +1214,8 @@ sortedcontainers = [ {file = "sortedcontainers-2.2.2.tar.gz", hash = "sha256:4e73a757831fc3ca4de2859c422564239a31d8213d09a2a666e375807034d2ba"}, ] sphinx = [ - {file = "Sphinx-3.1.1-py3-none-any.whl", hash = "sha256:97c9e3bcce2f61d9f5edf131299ee9d1219630598d9f9a8791459a4d9e815be5"}, - {file = "Sphinx-3.1.1.tar.gz", hash = "sha256:74fbead182a611ce1444f50218a1c5fc70b6cc547f64948f5182fb30a2a20258"}, + {file = "Sphinx-3.1.2-py3-none-any.whl", hash = "sha256:97dbf2e31fc5684bb805104b8ad34434ed70e6c588f6896991b2fdfd2bef8c00"}, + {file = "Sphinx-3.1.2.tar.gz", hash = "sha256:b9daeb9b39aa1ffefc2809b43604109825300300b987a24f45976c001ba1a8fd"}, ] sphinx-rtd-theme = [ {file = "sphinx_rtd_theme-0.5.0-py2.py3-none-any.whl", hash = "sha256:373413d0f82425aaa28fb288009bf0d0964711d347763af2f1b65cafcb028c82"}, diff --git a/pyproject.toml b/pyproject.toml index f18610a5..30696b89 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,6 +27,12 @@ classifiers = [ [tool.poetry.plugins."flake8.extension"] NIP = "nitpick.flake8:NitpickChecker" +[tool.poetry.plugins.nitpick] +json = "nitpick.files.json" +pre_commit = "nitpick.files.pre_commit" +setup_cfg = "nitpick.files.setup_cfg" +pyproject_toml = "nitpick.files.pyproject_toml" + [tool.poetry.dependencies] python = "^3.5 || ^3.6 || ^3.7 || ^3.8" flake8 = ">=3.0.0" diff --git a/src/nitpick/app.py b/src/nitpick/app.py index a3096eea..aba77396 100644 --- a/src/nitpick/app.py +++ b/src/nitpick/app.py @@ -8,7 +8,10 @@ from typing import TYPE_CHECKING, List, Set import click +import pluggy +from pluggy import PluginManager +from nitpick import plugin 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, StyleError from nitpick.generic import climb_directory_tree @@ -20,7 +23,7 @@ LOGGER = logging.getLogger(__name__) -class Nitpick: +class Nitpick: # pylint: disable=too-many-instance-attributes """The Nitpick application.""" _current_app = None # type: Nitpick @@ -29,6 +32,7 @@ class Nitpick: cache_dir = None # type: Path main_python_file = None # type: Path config = None # type: Config + plugin_manager = None # type: PluginManager class Flags(Enum): """Flags to be used with flake8 CLI.""" @@ -58,12 +62,21 @@ def create_app(cls, offline=False) -> "Nitpick": app.main_python_file = app.find_main_python_file() app.config = Config() + app.plugin_manager = app.load_plugins() BaseFile.load_fixed_dynamic_classes() except (NoRootDir, NoPythonFile) as err: app.init_errors.append(err) return app + @staticmethod + def load_plugins() -> PluginManager: + """Load all defined plugins.""" + plugin_manager = pluggy.PluginManager(PROJECT_NAME) + plugin_manager.add_hookspecs(plugin) + plugin_manager.load_setuptools_entrypoints(PROJECT_NAME) + return plugin_manager + @classmethod def current_app(cls): """Get the current app from the stack.""" diff --git a/src/nitpick/files/__init__.py b/src/nitpick/files/__init__.py index 9add77db..89b307bc 100644 --- a/src/nitpick/files/__init__.py +++ b/src/nitpick/files/__init__.py @@ -1,6 +1 @@ """Files that are checked by Nitpick.""" -# isort:skip_file -# TODO: load all modules under files/*, so get_subclasses() detects them; or use a plugin system? -import nitpick.files.pre_commit # noqa: F401 -import nitpick.files.json # noqa: F401 -import nitpick.files.text # noqa: F401 diff --git a/src/nitpick/files/base.py b/src/nitpick/files/base.py index 8e7f5567..bb149497 100644 --- a/src/nitpick/files/base.py +++ b/src/nitpick/files/base.py @@ -41,7 +41,6 @@ class BaseFile(NitpickMixin, metaclass=abc.ABCMeta): def __init__(self, filename: str = None) -> None: if filename is not None: - # FIXME: Hack to keep using BaseFile while migrating to the plugin architecture self.has_multiple_files = False self.file_name = filename @@ -65,6 +64,7 @@ def load_fixed_dynamic_classes(cls) -> None: def _set_current_data(self, file_name: str) -> None: """Set data for the current file name, either if there are multiple or single files.""" + # FIXME: Remove this hacky function, also remove has_multiple_files and _multiple_files if self.has_multiple_files: self.file_name = file_name diff --git a/src/nitpick/files/json.py b/src/nitpick/files/json.py index 96168023..06da28ce 100644 --- a/src/nitpick/files/json.py +++ b/src/nitpick/files/json.py @@ -1,7 +1,7 @@ """JSON files.""" import json import logging -from typing import Optional, Set +from typing import Any, Dict, Optional, Set from sortedcontainers import SortedDict @@ -9,7 +9,7 @@ from nitpick.files.base import BaseFile from nitpick.formats import JsonFormat from nitpick.generic import flatten, unflatten -from nitpick.plugin import NitpickPlugin, hookimpl +from nitpick.plugin import hookimpl from nitpick.schemas import BaseNitpickSchema from nitpick.typedefs import JsonDict, YieldFlake8Error @@ -92,11 +92,9 @@ def _check_contained_json(self) -> YieldFlake8Error: ) -class JSONPlugin(NitpickPlugin): # pylint: disable=too-few-public-methods +@hookimpl +def handle_config_file( # pylint: disable=unused-argument + filename: str, tags: Set[str], config_dict: Dict[str, Any] +) -> Optional["BaseFile"]: """Handle JSON files.""" - - @hookimpl - def handle(self, filename: str, tags: Set[str]) -> Optional[NitpickPlugin]: - """Handle JSON files.""" - self.base_file = JSONFile() - return self if "json" in tags else None + return JSONFile() if "json" in tags else None diff --git a/src/nitpick/files/pre_commit.py b/src/nitpick/files/pre_commit.py index b160e38a..ae4cd13b 100644 --- a/src/nitpick/files/pre_commit.py +++ b/src/nitpick/files/pre_commit.py @@ -7,7 +7,7 @@ from nitpick.files.base import BaseFile from nitpick.formats import TomlFormat, YamlFormat from nitpick.generic import find_object_by_key, search_dict -from nitpick.plugin import NitpickPlugin, hookimpl +from nitpick.plugin import hookimpl from nitpick.typedefs import JsonDict, YamlData, YieldFlake8Error KEY_REPOS = "repos" @@ -190,11 +190,10 @@ def format_hook(expected_dict) -> str: return "\n".join(output) -class PreCommitPlugin(NitpickPlugin): # pylint: disable=too-few-public-methods +@hookimpl +def handle_config_file( # pylint: disable=unused-argument + filename: str, tags: Set[str], config_dict: Dict[str, Any] +) -> Optional["BaseFile"]: """Handle pre-commit config file.""" - - @hookimpl - def handle(self, filename: str, tags: Set[str]) -> Optional[NitpickPlugin]: - """Handle pre-commit config file.""" - self.base_file = PreCommitFile() - return self if filename == TomlFormat.group_name_for(self.base_file.file_name) else None + base_file = PreCommitFile() + return base_file if filename == TomlFormat.group_name_for(base_file.file_name) else None diff --git a/src/nitpick/files/pyproject_toml.py b/src/nitpick/files/pyproject_toml.py index aceb1433..698ae287 100644 --- a/src/nitpick/files/pyproject_toml.py +++ b/src/nitpick/files/pyproject_toml.py @@ -1,9 +1,9 @@ """Checker for `pyproject.toml `_.""" -from typing import Optional, Set +from typing import Any, Dict, Optional, Set from nitpick.app import Nitpick from nitpick.files.base import BaseFile -from nitpick.plugin import NitpickPlugin, hookimpl +from nitpick.plugin import hookimpl from nitpick.typedefs import YieldFlake8Error @@ -30,11 +30,10 @@ def suggest_initial_contents(self) -> str: return "" -class PyProjectTomlPlugin(NitpickPlugin): # pylint: disable=too-few-public-methods +@hookimpl +def handle_config_file( # pylint: disable=unused-argument + filename: str, tags: Set[str], config_dict: Dict[str, Any] +) -> Optional["BaseFile"]: """Handle pyproject.toml file.""" - - @hookimpl - def handle(self, filename: str, tags: Set[str]) -> Optional[NitpickPlugin]: - """Handle pyproject.toml file.""" - self.base_file = PyProjectTomlFile() - return self if filename == self.base_file.file_name else None + base_file = PyProjectTomlFile() + return base_file if filename == base_file.file_name else None diff --git a/src/nitpick/files/setup_cfg.py b/src/nitpick/files/setup_cfg.py index 99e82a72..75a9f35a 100644 --- a/src/nitpick/files/setup_cfg.py +++ b/src/nitpick/files/setup_cfg.py @@ -6,7 +6,7 @@ import dictdiffer from nitpick.files.base import BaseFile -from nitpick.plugin import NitpickPlugin, hookimpl +from nitpick.plugin import hookimpl from nitpick.typedefs import YieldFlake8Error @@ -116,11 +116,9 @@ def get_example_cfg(config_parser: ConfigParser) -> str: return output -class SetupCfgPlugin(NitpickPlugin): # pylint: disable=too-few-public-methods +@hookimpl +def handle_config_file( # pylint: disable=unused-argument + filename: str, tags: Set[str], config_dict: Dict[str, Any] +) -> Optional["BaseFile"]: """Handle setup.cfg files.""" - - @hookimpl - def handle(self, filename: str, tags: Set[str]) -> Optional[NitpickPlugin]: - """Handle setup.cfg files.""" - self.base_file = SetupCfgFile() - return self if filename == SetupCfgFile.file_name else None + return SetupCfgFile() if filename == SetupCfgFile.file_name else None diff --git a/src/nitpick/files/text.py b/src/nitpick/files/text.py deleted file mode 100644 index 805346ce..00000000 --- a/src/nitpick/files/text.py +++ /dev/null @@ -1,34 +0,0 @@ -"""Text files.""" -import logging -from typing import Optional, Set - -from nitpick.files.base import BaseFile -from nitpick.plugin import NitpickPlugin, hookimpl -from nitpick.typedefs import YieldFlake8Error - -LOGGER = logging.getLogger(__name__) - - -class TextFile(BaseFile): - """Checker for any text file.""" - - has_multiple_files = True - error_base_number = 350 - - def suggest_initial_contents(self) -> str: - """Suggest the initial content for this missing file.""" - return "" - - def check_rules(self) -> YieldFlake8Error: - """Check rules for this file. It should be overridden by inherited classes if needed.""" - return [] - - -class TextPlugin(NitpickPlugin): # pylint: disable=too-few-public-methods - """Handle text files.""" - - @hookimpl - def handle(self, filename: str, tags: Set[str]) -> Optional[NitpickPlugin]: - """Handle text files.""" - self.base_file = TextFile(filename) - return self if "plain-text" in tags else None diff --git a/src/nitpick/flake8.py b/src/nitpick/flake8.py index 19ca3104..64bdf235 100644 --- a/src/nitpick/flake8.py +++ b/src/nitpick/flake8.py @@ -4,16 +4,13 @@ from pathlib import Path import attr -import pluggy from flake8.options.manager import OptionManager from identify import identify -from pluggy import PluginManager from nitpick import __version__ from nitpick.app import Nitpick from nitpick.constants import PROJECT_NAME from nitpick.mixin import NitpickMixin -from nitpick.plugin import NitpickPlugin from nitpick.typedefs import YieldFlake8Error LOGGER = logging.getLogger(__name__) @@ -60,46 +57,21 @@ def run(self) -> YieldFlake8Error: if has_errors: return [] - plugin_manager = self.load_plugins() - # Get all root keys from the style TOML. - for path in app.config.style_dict: + for path, config_dict in app.config.style_dict.items(): # All except "nitpick" are file names. if path == PROJECT_NAME: continue # For each file name, find the plugin that can handle the file. tags = identify.tags_from_filename(path) - for plugin in plugin_manager.hook.handle( # pylint: disable=no-member - filename=path, tags=tags - ): # type: NitpickPlugin - yield from plugin.base_file.check_exists() + for base_file in app.plugin_manager.hook.handle_config_file( # pylint: disable=no-member + filename=path, tags=tags, config_dict=config_dict + ): + yield from base_file.check_exists() return [] - @staticmethod - def load_plugins() -> PluginManager: - """Load all defined plugins.""" - plugin_manager = pluggy.PluginManager(PROJECT_NAME) - plugin_manager.add_hookspecs(NitpickPlugin) - - # pylint: disable=import-outside-toplevel - from nitpick.files.pyproject_toml import PyProjectTomlPlugin - from nitpick.files.setup_cfg import SetupCfgPlugin - from nitpick.files.pre_commit import PreCommitPlugin - from nitpick.files.json import JSONPlugin - from nitpick.files.text import TextPlugin - - # FIXME: use entry points instead - # plugin_manager.load_setuptools_entrypoints(PROJECT_NAME) - plugin_manager.register(JSONPlugin()) - plugin_manager.register(TextPlugin()) - plugin_manager.register(PreCommitPlugin()) - plugin_manager.register(SetupCfgPlugin()) - plugin_manager.register(PyProjectTomlPlugin()) - - return plugin_manager - def check_files(self, present: bool) -> YieldFlake8Error: """Check files that should be present or absent.""" key = "present" if present else "absent" diff --git a/src/nitpick/plugin.py b/src/nitpick/plugin.py index 7c9080a7..592b0267 100644 --- a/src/nitpick/plugin.py +++ b/src/nitpick/plugin.py @@ -1,5 +1,5 @@ """Flake8 plugin to check files.""" -from typing import TYPE_CHECKING, Optional, Set +from typing import TYPE_CHECKING, Any, Dict, Optional, Set import pluggy @@ -13,11 +13,8 @@ hookimpl = pluggy.HookimplMarker(PROJECT_NAME) -class NitpickPlugin: # pylint: disable=too-few-public-methods - """Base for a Nitpick plugin.""" - - base_file = None # type: BaseFile - - @hookspec - def handle(self, filename: str, tags: Set[str]) -> Optional["NitpickPlugin"]: - """Return self if this plugin handle the relative filename or any of its :py:package:`identify` tags.""" +@hookspec +def handle_config_file( # pylint: disable=unused-argument + filename: str, tags: Set[str], config_dict: Dict[str, Any] +) -> Optional["BaseFile"]: + """Return a BaseFile if this plugin handles the relative filename or any of its :py:package:`identify` tags.""" From aa1d1831f3698552ee56d31c0035c12f53a42ddb Mon Sep 17 00:00:00 2001 From: "Augusto W. Andreoli" Date: Sun, 5 Jul 2020 21:45:05 +0200 Subject: [PATCH 08/12] chore: fix import mismatch error locally Signed-off-by: Augusto W. Andreoli --- Makefile | 6 +++++- setup.cfg | 3 +++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 8ef37a0b..60cf4eef 100644 --- a/Makefile +++ b/Makefile @@ -37,6 +37,10 @@ pre-commit .cache/make/long-pre-commit: .pre-commit-config.yaml .pre-commit-hook touch .cache/make/long-pre-commit .PHONY: pre-commit +# Poetry install is needed to create the Nitpick plugin entries on setuptools, used by pluggy +src/nitpick.egg-info: + poetry install + poetry .cache/make/long-poetry: pyproject.toml # Update dependencies poetry update poetry install @@ -68,7 +72,7 @@ endif touch .cache/make/test .PHONY: test -pytest: # Run pytest on the poetry venv (to quickly run tests locally without waiting for tox) +pytest: src/nitpick.egg-info # Run pytest on the poetry venv (to quickly run tests locally without waiting for tox) poetry run pytest .PHONY: pytest diff --git a/setup.cfg b/setup.cfg index 7ae99ee3..550b62af 100644 --- a/setup.cfg +++ b/setup.cfg @@ -85,6 +85,9 @@ extras = test depends = {py39,py38,py37,py36,py35}: clean report: py39,py38,py37,py36,py35 +setenv = + # I had this error locally, but it worked on Travis CI: https://github.com/pytest-dev/pytest/issues/2042 + PY_IGNORE_IMPORTMISMATCH = 1 commands = # https://pytest-cov.readthedocs.io/en/latest/config.html python -m pytest --cov-config=setup.cfg --cov --cov-append --cov-report=term-missing --doctest-modules {posargs:-vv} From 2cccfcba02ee471de76c6bd43ddec77c60886ff1 Mon Sep 17 00:00:00 2001 From: "Augusto W. Andreoli" Date: Mon, 6 Jul 2020 00:16:34 +0200 Subject: [PATCH 09/12] refactor: nitpick.files to nitpick.plugins Signed-off-by: Augusto W. Andreoli --- docs/config_files.rst | 7 - docs/generate_rst.py | 10 +- docs/nitpick_section.rst | 2 +- docs/source/nitpick.files.base.rst | 7 - docs/source/nitpick.files.json.rst | 7 - docs/source/nitpick.files.pre_commit.rst | 7 - docs/source/nitpick.files.pyproject_toml.rst | 7 - docs/source/nitpick.files.rst | 20 --- docs/source/nitpick.files.setup_cfg.rst | 7 - docs/source/nitpick.plugin.rst | 7 - docs/source/nitpick.plugins.base.rst | 7 + docs/source/nitpick.plugins.json.rst | 7 + docs/source/nitpick.plugins.pre_commit.rst | 7 + .../source/nitpick.plugins.pyproject_toml.rst | 7 + docs/source/nitpick.plugins.rst | 19 +++ docs/source/nitpick.plugins.setup_cfg.rst | 7 + docs/source/nitpick.rst | 3 +- pyproject.toml | 8 +- src/nitpick/app.py | 10 +- src/nitpick/config.py | 2 +- src/nitpick/constants.py | 2 - src/nitpick/files/__init__.py | 1 - src/nitpick/files/base.py | 125 ------------------ src/nitpick/flake8.py | 2 +- .../{plugin.py => plugins/__init__.py} | 9 +- src/nitpick/plugins/base.py | 95 +++++++++++++ src/nitpick/{files => plugins}/json.py | 18 +-- src/nitpick/{files => plugins}/pre_commit.py | 10 +- .../{files => plugins}/pyproject_toml.py | 14 +- src/nitpick/{files => plugins}/setup_cfg.py | 16 +-- src/nitpick/schemas.py | 2 +- src/nitpick/style.py | 4 +- tests/helpers.py | 6 +- tests/test_pre_commit.py | 2 +- tests/test_pyproject_toml.py | 2 +- tests/test_text.py | 21 --- 36 files changed, 207 insertions(+), 280 deletions(-) delete mode 100644 docs/source/nitpick.files.base.rst delete mode 100644 docs/source/nitpick.files.json.rst delete mode 100644 docs/source/nitpick.files.pre_commit.rst delete mode 100644 docs/source/nitpick.files.pyproject_toml.rst delete mode 100644 docs/source/nitpick.files.rst delete mode 100644 docs/source/nitpick.files.setup_cfg.rst delete mode 100644 docs/source/nitpick.plugin.rst create mode 100644 docs/source/nitpick.plugins.base.rst create mode 100644 docs/source/nitpick.plugins.json.rst create mode 100644 docs/source/nitpick.plugins.pre_commit.rst create mode 100644 docs/source/nitpick.plugins.pyproject_toml.rst create mode 100644 docs/source/nitpick.plugins.rst create mode 100644 docs/source/nitpick.plugins.setup_cfg.rst delete mode 100644 src/nitpick/files/__init__.py delete mode 100644 src/nitpick/files/base.py rename src/nitpick/{plugin.py => plugins/__init__.py} (63%) create mode 100644 src/nitpick/plugins/base.py rename src/nitpick/{files => plugins}/json.py (87%) rename src/nitpick/{files => plugins}/pre_commit.py (97%) rename src/nitpick/{files => plugins}/pyproject_toml.py (77%) rename src/nitpick/{files => plugins}/setup_cfg.py (92%) delete mode 100644 tests/test_text.py diff --git a/docs/config_files.rst b/docs/config_files.rst index ed508ac7..bc643d3a 100644 --- a/docs/config_files.rst +++ b/docs/config_files.rst @@ -52,10 +52,3 @@ 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. - -.. _textfile: - -Text files ----------- - -Checker for any text file. diff --git a/docs/generate_rst.py b/docs/generate_rst.py index b0d54737..11e036b5 100644 --- a/docs/generate_rst.py +++ b/docs/generate_rst.py @@ -16,12 +16,12 @@ from sortedcontainers import SortedDict from nitpick.constants import RAW_GITHUB_CONTENT_BASE_URL -from nitpick.files.base import BaseFile -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 from nitpick.generic import get_subclasses +from nitpick.plugins.base import BaseFile +from nitpick.plugins.json import JSONFile +from nitpick.plugins.pre_commit import PreCommitFile +from nitpick.plugins.pyproject_toml import PyProjectTomlFile +from nitpick.plugins.setup_cfg import SetupCfgFile style_mapping = SortedDict( { diff --git a/docs/nitpick_section.rst b/docs/nitpick_section.rst index 42855bef..ced06312 100644 --- a/docs/nitpick_section.rst +++ b/docs/nitpick_section.rst @@ -79,5 +79,5 @@ If a key/value pair appears in more than one sub-style, it will be overridden; t [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.plugins.json.JSONFile` class. See :ref:`the default package.json style ` for an example of usage. diff --git a/docs/source/nitpick.files.base.rst b/docs/source/nitpick.files.base.rst deleted file mode 100644 index 34cd5442..00000000 --- a/docs/source/nitpick.files.base.rst +++ /dev/null @@ -1,7 +0,0 @@ -nitpick.files.base module -========================= - -.. automodule:: nitpick.files.base - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/source/nitpick.files.json.rst b/docs/source/nitpick.files.json.rst deleted file mode 100644 index 015e8d6b..00000000 --- a/docs/source/nitpick.files.json.rst +++ /dev/null @@ -1,7 +0,0 @@ -nitpick.files.json module -========================= - -.. automodule:: nitpick.files.json - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/source/nitpick.files.pre_commit.rst b/docs/source/nitpick.files.pre_commit.rst deleted file mode 100644 index 9c16f7ad..00000000 --- a/docs/source/nitpick.files.pre_commit.rst +++ /dev/null @@ -1,7 +0,0 @@ -nitpick.files.pre\_commit module -================================ - -.. automodule:: nitpick.files.pre_commit - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/source/nitpick.files.pyproject_toml.rst b/docs/source/nitpick.files.pyproject_toml.rst deleted file mode 100644 index 98fb2031..00000000 --- a/docs/source/nitpick.files.pyproject_toml.rst +++ /dev/null @@ -1,7 +0,0 @@ -nitpick.files.pyproject\_toml module -==================================== - -.. automodule:: nitpick.files.pyproject_toml - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/source/nitpick.files.rst b/docs/source/nitpick.files.rst deleted file mode 100644 index 3e235c64..00000000 --- a/docs/source/nitpick.files.rst +++ /dev/null @@ -1,20 +0,0 @@ -nitpick.files package -===================== - -.. automodule:: nitpick.files - :members: - :undoc-members: - :show-inheritance: - -Submodules ----------- - -.. toctree:: - :maxdepth: 4 - - nitpick.files.base - nitpick.files.json - nitpick.files.pre_commit - nitpick.files.pyproject_toml - nitpick.files.setup_cfg - nitpick.files.text diff --git a/docs/source/nitpick.files.setup_cfg.rst b/docs/source/nitpick.files.setup_cfg.rst deleted file mode 100644 index cfb23e0e..00000000 --- a/docs/source/nitpick.files.setup_cfg.rst +++ /dev/null @@ -1,7 +0,0 @@ -nitpick.files.setup\_cfg module -=============================== - -.. automodule:: nitpick.files.setup_cfg - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/source/nitpick.plugin.rst b/docs/source/nitpick.plugin.rst deleted file mode 100644 index 487c9c55..00000000 --- a/docs/source/nitpick.plugin.rst +++ /dev/null @@ -1,7 +0,0 @@ -nitpick.plugin module -===================== - -.. automodule:: nitpick.plugin - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/source/nitpick.plugins.base.rst b/docs/source/nitpick.plugins.base.rst new file mode 100644 index 00000000..e9d848f1 --- /dev/null +++ b/docs/source/nitpick.plugins.base.rst @@ -0,0 +1,7 @@ +nitpick.plugins.base module +=========================== + +.. automodule:: nitpick.plugins.base + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/nitpick.plugins.json.rst b/docs/source/nitpick.plugins.json.rst new file mode 100644 index 00000000..4078f53b --- /dev/null +++ b/docs/source/nitpick.plugins.json.rst @@ -0,0 +1,7 @@ +nitpick.plugins.json module +=========================== + +.. automodule:: nitpick.plugins.json + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/nitpick.plugins.pre_commit.rst b/docs/source/nitpick.plugins.pre_commit.rst new file mode 100644 index 00000000..5796c2e2 --- /dev/null +++ b/docs/source/nitpick.plugins.pre_commit.rst @@ -0,0 +1,7 @@ +nitpick.plugins.pre\_commit module +================================== + +.. automodule:: nitpick.plugins.pre_commit + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/nitpick.plugins.pyproject_toml.rst b/docs/source/nitpick.plugins.pyproject_toml.rst new file mode 100644 index 00000000..8d71d710 --- /dev/null +++ b/docs/source/nitpick.plugins.pyproject_toml.rst @@ -0,0 +1,7 @@ +nitpick.plugins.pyproject\_toml module +====================================== + +.. automodule:: nitpick.plugins.pyproject_toml + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/nitpick.plugins.rst b/docs/source/nitpick.plugins.rst new file mode 100644 index 00000000..d5d8a4f5 --- /dev/null +++ b/docs/source/nitpick.plugins.rst @@ -0,0 +1,19 @@ +nitpick.plugins package +======================= + +.. automodule:: nitpick.plugins + :members: + :undoc-members: + :show-inheritance: + +Submodules +---------- + +.. toctree:: + :maxdepth: 4 + + nitpick.plugins.base + nitpick.plugins.json + nitpick.plugins.pre_commit + nitpick.plugins.pyproject_toml + nitpick.plugins.setup_cfg diff --git a/docs/source/nitpick.plugins.setup_cfg.rst b/docs/source/nitpick.plugins.setup_cfg.rst new file mode 100644 index 00000000..08b146aa --- /dev/null +++ b/docs/source/nitpick.plugins.setup_cfg.rst @@ -0,0 +1,7 @@ +nitpick.plugins.setup\_cfg module +================================= + +.. automodule:: nitpick.plugins.setup_cfg + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/nitpick.rst b/docs/source/nitpick.rst index bec4b84e..9c35ea23 100644 --- a/docs/source/nitpick.rst +++ b/docs/source/nitpick.rst @@ -12,7 +12,7 @@ Subpackages .. toctree:: :maxdepth: 4 - nitpick.files + nitpick.plugins Submodules ---------- @@ -29,7 +29,6 @@ Submodules nitpick.formats nitpick.generic nitpick.mixin - nitpick.plugin nitpick.schemas nitpick.style nitpick.typedefs diff --git a/pyproject.toml b/pyproject.toml index 30696b89..f602ab3b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,10 +28,10 @@ classifiers = [ NIP = "nitpick.flake8:NitpickChecker" [tool.poetry.plugins.nitpick] -json = "nitpick.files.json" -pre_commit = "nitpick.files.pre_commit" -setup_cfg = "nitpick.files.setup_cfg" -pyproject_toml = "nitpick.files.pyproject_toml" +json = "nitpick.plugins.json" +pre_commit = "nitpick.plugins.pre_commit" +setup_cfg = "nitpick.plugins.setup_cfg" +pyproject_toml = "nitpick.plugins.pyproject_toml" [tool.poetry.dependencies] python = "^3.5 || ^3.6 || ^3.7 || ^3.8" diff --git a/src/nitpick/app.py b/src/nitpick/app.py index aba77396..651ca107 100644 --- a/src/nitpick/app.py +++ b/src/nitpick/app.py @@ -11,7 +11,7 @@ import pluggy from pluggy import PluginManager -from nitpick import plugin +from nitpick import plugins 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, StyleError from nitpick.generic import climb_directory_tree @@ -50,7 +50,7 @@ def create_app(cls, offline=False) -> "Nitpick": """Create a single application.""" # pylint: disable=import-outside-toplevel from nitpick.config import Config # pylint: disable=redefined-outer-name - from nitpick.files.base import BaseFile + from nitpick.plugins.base import BaseFile app = cls() cls._current_app = app @@ -73,7 +73,7 @@ def create_app(cls, offline=False) -> "Nitpick": def load_plugins() -> PluginManager: """Load all defined plugins.""" plugin_manager = pluggy.PluginManager(PROJECT_NAME) - plugin_manager.add_hookspecs(plugin) + plugin_manager.add_hookspecs(plugins) plugin_manager.load_setuptools_entrypoints(PROJECT_NAME) return plugin_manager @@ -89,8 +89,8 @@ def find_root_dir() -> Path: Start from the current working dir. """ # pylint: disable=import-outside-toplevel - from nitpick.files.pyproject_toml import PyProjectTomlFile - from nitpick.files.setup_cfg import SetupCfgFile + from nitpick.plugins.pyproject_toml import PyProjectTomlFile + from nitpick.plugins.setup_cfg import SetupCfgFile root_dirs = set() # type: Set[Path] seen = set() # type: Set[Path] diff --git a/src/nitpick/config.py b/src/nitpick/config.py index d9962629..3c6c4aef 100644 --- a/src/nitpick/config.py +++ b/src/nitpick/config.py @@ -10,10 +10,10 @@ TOOL_NITPICK, TOOL_NITPICK_JMEX, ) -from nitpick.files.pyproject_toml import PyProjectTomlFile 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.schemas import ToolNitpickSectionSchema, flatten_marshmallow_errors from nitpick.style import Style from nitpick.typedefs import YieldFlake8Error diff --git a/src/nitpick/constants.py b/src/nitpick/constants.py index 27d11b28..cebdaba0 100644 --- a/src/nitpick/constants.py +++ b/src/nitpick/constants.py @@ -30,5 +30,3 @@ TOOL_NITPICK_JMEX = jmespath.compile(TOOL_NITPICK) NITPICK_STYLES_INCLUDE_JMEX = jmespath.compile("nitpick.styles.include") NITPICK_MINIMUM_VERSION_JMEX = jmespath.compile("nitpick.minimum_version") - -KEY_FILE_NAMES = "file_names" diff --git a/src/nitpick/files/__init__.py b/src/nitpick/files/__init__.py deleted file mode 100644 index 89b307bc..00000000 --- a/src/nitpick/files/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Files that are checked by Nitpick.""" diff --git a/src/nitpick/files/base.py b/src/nitpick/files/base.py deleted file mode 100644 index bb149497..00000000 --- a/src/nitpick/files/base.py +++ /dev/null @@ -1,125 +0,0 @@ -"""Base class for file checkers.""" -import abc -from typing import TYPE_CHECKING, Generator, List, Optional, Set, Type - -import jmespath - -from nitpick.app import Nitpick -from nitpick.constants import KEY_FILE_NAMES -from nitpick.formats import TomlFormat -from nitpick.generic import get_subclasses, search_dict -from nitpick.mixin import NitpickMixin -from nitpick.typedefs import YieldFlake8Error - -if TYPE_CHECKING: - from pathlib import Path - from marshmallow import Schema - from nitpick.typedefs import JsonDict # pylint: disable=ungrouped-imports - - -class BaseFile(NitpickMixin, metaclass=abc.ABCMeta): - """Base class for file checkers.""" - - has_multiple_files = False - file_name = "" - error_base_number = 300 - - error_prefix = "" - file_path = None # type: Path - 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] - - fixed_name_classes = set() # type: Set[Type[BaseFile]] - dynamic_name_classes = set() # type: Set[Type[BaseFile]] - - #: Which :py:package:`identify` tags this :py:class:`nitpick.files.base.BaseFile` child recognises. - identify_tags = set() # type: Set[str] - - def __init__(self, filename: str = None) -> None: - if filename is not None: - self.has_multiple_files = False - self.file_name = filename - - if self.has_multiple_files: - key = "{}.{}".format(self.__class__.__name__, KEY_FILE_NAMES) - self._multiple_files = search_dict(key, Nitpick.current_app().config.nitpick_section, []) # type: List[str] - else: - self._multiple_files = [self.file_name] - self._set_current_data(self.file_name) - - @classmethod - 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) - else: - cls.dynamic_name_classes.add(subclass) - - def _set_current_data(self, file_name: str) -> None: - """Set data for the current file name, either if there are multiple or single files.""" - # FIXME: Remove this hacky function, also remove has_multiple_files and _multiple_files - if self.has_multiple_files: - self.file_name = file_name - - self.error_prefix = "File {}".format(self.file_name) - self.file_path = Nitpick.current_app().root_dir / self.file_name - - # Configuration for this file as a TOML dict, taken from the style file. - self.file_dict = Nitpick.current_app().config.style_dict.get(TomlFormat.group_name_for(self.file_name), {}) - - # Nitpick configuration for this file as a TOML dict, taken from the style file. - self.nitpick_file_dict = search_dict( - 'files."{}"'.format(self.file_name), Nitpick.current_app().config.nitpick_section, {} - ) - - @classmethod - def get_compiled_jmespath_file_names(cls): - """Return a compiled JMESPath expression for file names, using the class name as part of the key.""" - return jmespath.compile("nitpick.{}.file_names".format(cls.__name__)) - - @property - def multiple_files(self) -> Generator: - """Yield the next multiple file, one by one.""" - for file_name in self._multiple_files: - self._set_current_data(file_name) - yield file_name - - def check_exists(self) -> YieldFlake8Error: - """Check if the file should exist.""" - for _ in self.multiple_files: - config_data_exists = bool(self.file_dict or self.nitpick_file_dict) - should_exist = Nitpick.current_app().config.nitpick_files_section.get( - TomlFormat.group_name_for(self.file_name), True - ) # type: bool - file_exists = self.file_path.exists() - - if config_data_exists and not file_exists: - suggestion = self.suggest_initial_contents() - phrases = [" was not found"] - message = Nitpick.current_app().config.nitpick_files_section.get(self.file_name) - if message and isinstance(message, str): - 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().style_errors: - yield self.flake8_error(2, " should be deleted") - elif file_exists and config_data_exists: - yield from self.check_rules() - - @abc.abstractmethod - def check_rules(self) -> YieldFlake8Error: - """Check rules for this file. It should be overridden by inherited classes if needed.""" - - @abc.abstractmethod - def suggest_initial_contents(self) -> str: - """Suggest the initial content for this missing file.""" diff --git a/src/nitpick/flake8.py b/src/nitpick/flake8.py index 64bdf235..bd407b68 100644 --- a/src/nitpick/flake8.py +++ b/src/nitpick/flake8.py @@ -66,7 +66,7 @@ def run(self) -> YieldFlake8Error: # For each file name, find the plugin that can handle the file. tags = identify.tags_from_filename(path) for base_file in app.plugin_manager.hook.handle_config_file( # pylint: disable=no-member - filename=path, tags=tags, config_dict=config_dict + config=config_dict, file_name=path, tags=tags ): yield from base_file.check_exists() diff --git a/src/nitpick/plugin.py b/src/nitpick/plugins/__init__.py similarity index 63% rename from src/nitpick/plugin.py rename to src/nitpick/plugins/__init__.py index 592b0267..7997f642 100644 --- a/src/nitpick/plugin.py +++ b/src/nitpick/plugins/__init__.py @@ -1,12 +1,13 @@ -"""Flake8 plugin to check files.""" -from typing import TYPE_CHECKING, Any, Dict, Optional, Set +"""Hooks used by Nitpick pkugins.""" +from typing import TYPE_CHECKING, Optional, Set import pluggy from nitpick.constants import PROJECT_NAME +from nitpick.typedefs import JsonDict if TYPE_CHECKING: - from nitpick.files.base import BaseFile + from nitpick.plugins.base import BaseFile hookspec = pluggy.HookspecMarker(PROJECT_NAME) @@ -15,6 +16,6 @@ @hookspec def handle_config_file( # pylint: disable=unused-argument - filename: str, tags: Set[str], config_dict: Dict[str, Any] + 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.""" diff --git a/src/nitpick/plugins/base.py b/src/nitpick/plugins/base.py new file mode 100644 index 00000000..b4c4686b --- /dev/null +++ b/src/nitpick/plugins/base.py @@ -0,0 +1,95 @@ +"""Base class for file checkers.""" +import abc +from typing import TYPE_CHECKING, Optional, Set, Type + +import jmespath + +from nitpick.app import Nitpick +from nitpick.formats import TomlFormat +from nitpick.generic import get_subclasses, 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): + """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] + + fixed_name_classes = set() # type: Set[Type[BaseFile]] + dynamic_name_classes = set() # type: Set[Type[BaseFile]] + + #: Which :py:package:`identify` tags this :py:class:`nitpick.files.base.BaseFile` child recognises. + identify_tags = set() # type: Set[str] + + def __init__(self, config: JsonDict, file_name: str = None) -> None: + if file_name is not None: + self.file_name = file_name + + self.error_prefix = "File {}".format(self.file_name) + self.file_path = Nitpick.current_app().root_dir / self.file_name # type: Path + + # Configuration for this file as a TOML dict, taken from the style file. + self.file_dict = config or {} # type: JsonDict + + # Nitpick configuration for this file as a TOML dict, taken from the style file. + self.nitpick_file_dict = search_dict( + 'files."{}"'.format(self.file_name), Nitpick.current_app().config.nitpick_section, {} + ) # type: JsonDict + + @classmethod + 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) + else: + cls.dynamic_name_classes.add(subclass) + + @classmethod + def get_compiled_jmespath_file_names(cls): + """Return a compiled JMESPath expression for file names, using the class name as part of the key.""" + return jmespath.compile("nitpick.{}.file_names".format(cls.__name__)) + + 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 = Nitpick.current_app().config.nitpick_files_section.get( + TomlFormat.group_name_for(self.file_name), True + ) # type: bool + file_exists = self.file_path.exists() + + if config_data_exists and not file_exists: + suggestion = self.suggest_initial_contents() + phrases = [" was not found"] + message = Nitpick.current_app().config.nitpick_files_section.get(self.file_name) + if message and isinstance(message, str): + 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().style_errors: + yield self.flake8_error(2, " should be deleted") + elif file_exists and config_data_exists: + yield from self.check_rules() + + @abc.abstractmethod + def check_rules(self) -> YieldFlake8Error: + """Check rules for this file. It should be overridden by inherited classes if needed.""" + + @abc.abstractmethod + def suggest_initial_contents(self) -> str: + """Suggest the initial content for this missing file.""" diff --git a/src/nitpick/files/json.py b/src/nitpick/plugins/json.py similarity index 87% rename from src/nitpick/files/json.py rename to src/nitpick/plugins/json.py index 06da28ce..b7ce33a5 100644 --- a/src/nitpick/files/json.py +++ b/src/nitpick/plugins/json.py @@ -1,15 +1,15 @@ """JSON files.""" import json import logging -from typing import Any, Dict, Optional, Set +from typing import Optional, Set 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.plugin import hookimpl +from nitpick.plugins import hookimpl +from nitpick.plugins.base import BaseFile from nitpick.schemas import BaseNitpickSchema from nitpick.typedefs import JsonDict, YieldFlake8Error @@ -37,7 +37,6 @@ class JSONFile(BaseFile): Otherwise, a style validation error will be raised. """ - has_multiple_files = True error_base_number = 340 nested_field = JSONFileSchema @@ -47,9 +46,8 @@ class JSONFile(BaseFile): def check_rules(self) -> YieldFlake8Error: """Check missing keys and JSON content.""" - for _ in self.multiple_files: - yield from self._check_contained_keys() - yield from self._check_contained_json() + yield from self._check_contained_keys() + yield from self._check_contained_json() def get_suggested_json(self, raw_actual: JsonDict = None) -> JsonDict: """Return the suggested JSON based on actual values.""" @@ -93,8 +91,6 @@ def _check_contained_json(self) -> YieldFlake8Error: @hookimpl -def handle_config_file( # pylint: disable=unused-argument - filename: str, tags: Set[str], config_dict: Dict[str, Any] -) -> Optional["BaseFile"]: +def handle_config_file(config: JsonDict, file_name: str, tags: Set[str]) -> Optional["BaseFile"]: """Handle JSON files.""" - return JSONFile() if "json" in tags else None + return JSONFile(config, file_name) if "json" in tags else None diff --git a/src/nitpick/files/pre_commit.py b/src/nitpick/plugins/pre_commit.py similarity index 97% rename from src/nitpick/files/pre_commit.py rename to src/nitpick/plugins/pre_commit.py index ae4cd13b..fb953668 100644 --- a/src/nitpick/files/pre_commit.py +++ b/src/nitpick/plugins/pre_commit.py @@ -4,10 +4,10 @@ import attr -from nitpick.files.base import BaseFile from nitpick.formats import TomlFormat, YamlFormat from nitpick.generic import find_object_by_key, search_dict -from nitpick.plugin import hookimpl +from nitpick.plugins import hookimpl +from nitpick.plugins.base import BaseFile from nitpick.typedefs import JsonDict, YamlData, YieldFlake8Error KEY_REPOS = "repos" @@ -192,8 +192,8 @@ def format_hook(expected_dict) -> str: @hookimpl def handle_config_file( # pylint: disable=unused-argument - filename: str, tags: Set[str], config_dict: Dict[str, Any] + config: JsonDict, file_name: str, tags: Set[str] ) -> Optional["BaseFile"]: """Handle pre-commit config file.""" - base_file = PreCommitFile() - return base_file if filename == TomlFormat.group_name_for(base_file.file_name) else None + base_file = PreCommitFile(config) + return base_file if file_name == TomlFormat.group_name_for(base_file.file_name) else None diff --git a/src/nitpick/files/pyproject_toml.py b/src/nitpick/plugins/pyproject_toml.py similarity index 77% rename from src/nitpick/files/pyproject_toml.py rename to src/nitpick/plugins/pyproject_toml.py index 698ae287..ab346319 100644 --- a/src/nitpick/files/pyproject_toml.py +++ b/src/nitpick/plugins/pyproject_toml.py @@ -1,10 +1,10 @@ """Checker for `pyproject.toml `_.""" -from typing import Any, Dict, Optional, Set +from typing import Optional, Set from nitpick.app import Nitpick -from nitpick.files.base import BaseFile -from nitpick.plugin import hookimpl -from nitpick.typedefs import YieldFlake8Error +from nitpick.plugins import hookimpl +from nitpick.plugins.base import BaseFile +from nitpick.typedefs import JsonDict, YieldFlake8Error class PyProjectTomlFile(BaseFile): @@ -32,8 +32,8 @@ def suggest_initial_contents(self) -> str: @hookimpl def handle_config_file( # pylint: disable=unused-argument - filename: str, tags: Set[str], config_dict: Dict[str, Any] + config: JsonDict, file_name: str, tags: Set[str] ) -> Optional["BaseFile"]: """Handle pyproject.toml file.""" - base_file = PyProjectTomlFile() - return base_file if filename == base_file.file_name else None + base_file = PyProjectTomlFile(config) + return base_file if file_name == base_file.file_name else None diff --git a/src/nitpick/files/setup_cfg.py b/src/nitpick/plugins/setup_cfg.py similarity index 92% rename from src/nitpick/files/setup_cfg.py rename to src/nitpick/plugins/setup_cfg.py index 75a9f35a..555fdeeb 100644 --- a/src/nitpick/files/setup_cfg.py +++ b/src/nitpick/plugins/setup_cfg.py @@ -5,9 +5,9 @@ import dictdiffer -from nitpick.files.base import BaseFile -from nitpick.plugin import hookimpl -from nitpick.typedefs import YieldFlake8Error +from nitpick.plugins import hookimpl +from nitpick.plugins.base import BaseFile +from nitpick.typedefs import JsonDict, YieldFlake8Error class SetupCfgFile(BaseFile): @@ -23,8 +23,8 @@ class SetupCfgFile(BaseFile): expected_sections = set() # type: Set[str] missing_sections = set() # type: Set[str] - def __init__(self) -> None: - super().__init__() + def __init__(self, config: JsonDict, file_name: str = None) -> None: + super().__init__(config, file_name) self.comma_separated_values = set(self.nitpick_file_dict.get(self.COMMA_SEPARATED_VALUES, [])) # type: Set[str] def suggest_initial_contents(self) -> str: @@ -118,7 +118,7 @@ def get_example_cfg(config_parser: ConfigParser) -> str: @hookimpl def handle_config_file( # pylint: disable=unused-argument - filename: str, tags: Set[str], config_dict: Dict[str, Any] + config: JsonDict, file_name: str, tags: Set[str] ) -> Optional["BaseFile"]: - """Handle setup.cfg files.""" - return SetupCfgFile() if filename == SetupCfgFile.file_name else None + """Handle the setup.cfg file.""" + return SetupCfgFile(config) if file_name == SetupCfgFile.file_name else None diff --git a/src/nitpick/schemas.py b/src/nitpick/schemas.py index b0453df5..0bfbe875 100644 --- a/src/nitpick/schemas.py +++ b/src/nitpick/schemas.py @@ -7,8 +7,8 @@ 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.plugins.setup_cfg import SetupCfgFile def flatten_marshmallow_errors(errors: Dict) -> str: diff --git a/src/nitpick/style.py b/src/nitpick/style.py index 2481915c..856b95f4 100644 --- a/src/nitpick/style.py +++ b/src/nitpick/style.py @@ -20,10 +20,10 @@ RAW_GITHUB_CONTENT_BASE_URL, TOML_EXTENSION, ) -from nitpick.files.base import BaseFile -from nitpick.files.pyproject_toml import PyProjectTomlFile 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.schemas import BaseStyleSchema, flatten_marshmallow_errors from nitpick.typedefs import JsonDict, StrOrList diff --git a/tests/helpers.py b/tests/helpers.py index b4b30faa..6ea6b741 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -10,11 +10,11 @@ from nitpick.app import Nitpick from nitpick.constants import CACHE_DIR_NAME, ERROR_PREFIX, MERGED_STYLE_TOML, NITPICK_STYLE_TOML, PROJECT_NAME -from nitpick.files.pre_commit import PreCommitFile -from nitpick.files.pyproject_toml import PyProjectTomlFile -from nitpick.files.setup_cfg import SetupCfgFile from nitpick.flake8 import NitpickChecker 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.typedefs import PathOrStr from tests.conftest import TEMP_ROOT_PATH diff --git a/tests/test_pre_commit.py b/tests/test_pre_commit.py index 8ae616d6..73be835e 100644 --- a/tests/test_pre_commit.py +++ b/tests/test_pre_commit.py @@ -3,7 +3,7 @@ from testfixtures import compare -from nitpick.files.pre_commit import PreCommitHook +from nitpick.plugins.pre_commit import PreCommitHook from tests.helpers import ProjectMock diff --git a/tests/test_pyproject_toml.py b/tests/test_pyproject_toml.py index 423a9ed9..a18b0cf3 100644 --- a/tests/test_pyproject_toml.py +++ b/tests/test_pyproject_toml.py @@ -1,5 +1,5 @@ """pyproject.toml tests.""" -from nitpick.files.pyproject_toml import PyProjectTomlFile +from nitpick.plugins.pyproject_toml import PyProjectTomlFile from tests.helpers import ProjectMock diff --git a/tests/test_text.py b/tests/test_text.py deleted file mode 100644 index f9452e66..00000000 --- a/tests/test_text.py +++ /dev/null @@ -1,21 +0,0 @@ -"""Text file tests.""" -import pytest - -from tests.helpers import ProjectMock - - -@pytest.mark.xfail(reason="WIP") -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" - """ - ).flake8().assert_errors_contain( - """ - NIP341 File requirements.txt was not found. Create it with this content:\x1b[32m - sphinx>=1.3.0\x1b[0m - """ - ) From cb08b2a8aca30248cb5c5ed9b250534d236ccfeb Mon Sep 17 00:00:00 2001 From: "Augusto W. Andreoli" Date: Mon, 6 Jul 2020 00:22:53 +0200 Subject: [PATCH 10/12] docs: add plugin disclaimer Signed-off-by: Augusto W. Andreoli --- src/nitpick/plugins/__init__.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/nitpick/plugins/__init__.py b/src/nitpick/plugins/__init__.py index 7997f642..36b42ea1 100644 --- a/src/nitpick/plugins/__init__.py +++ b/src/nitpick/plugins/__init__.py @@ -1,4 +1,10 @@ -"""Hooks used by Nitpick pkugins.""" +"""Hook specifications used by Nitpick plugins. + +.. note:: + + 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 import pluggy From 32b27bd4bc602eb4885a1f6ec6c1eb23b0e2cba7 Mon Sep 17 00:00:00 2001 From: "Augusto W. Andreoli" Date: Mon, 6 Jul 2020 00:33:26 +0200 Subject: [PATCH 11/12] chore: run pytest as a module Signed-off-by: Augusto W. Andreoli --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 60cf4eef..c4cc23e8 100644 --- a/Makefile +++ b/Makefile @@ -73,7 +73,7 @@ endif .PHONY: test pytest: src/nitpick.egg-info # Run pytest on the poetry venv (to quickly run tests locally without waiting for tox) - poetry run pytest + poetry run python -m pytest .PHONY: pytest doc .cache/make/doc: docs/*/* styles/*/* *.rst *.md # Build documentation only From 1ab76ab7aa4600d7d4383c38c2934ca67c3b0649 Mon Sep 17 00:00:00 2001 From: "Augusto W. Andreoli" Date: Mon, 6 Jul 2020 00:58:25 +0200 Subject: [PATCH 12/12] refactor: rename to NitpickApp and NitpickExtension Signed-off-by: Augusto W. Andreoli --- Makefile | 4 ++-- poetry.lock | 4 ++-- pyproject.toml | 2 +- src/nitpick/app.py | 12 ++++++------ src/nitpick/config.py | 14 +++++++------- src/nitpick/flake8.py | 26 +++++++++++++------------- src/nitpick/mixin.py | 4 ++-- src/nitpick/plugins/base.py | 15 +++++++++------ src/nitpick/plugins/pyproject_toml.py | 6 +++--- src/nitpick/style.py | 22 +++++++++++----------- tests/helpers.py | 10 +++++----- tests/test_plugin.py | 16 ++++++++-------- tests/test_style.py | 2 +- 13 files changed, 70 insertions(+), 67 deletions(-) diff --git a/Makefile b/Makefile index c4cc23e8..db302f9a 100644 --- a/Makefile +++ b/Makefile @@ -38,7 +38,7 @@ pre-commit .cache/make/long-pre-commit: .pre-commit-config.yaml .pre-commit-hook .PHONY: pre-commit # Poetry install is needed to create the Nitpick plugin entries on setuptools, used by pluggy -src/nitpick.egg-info: +src/nitpick.egg-info/entry_points.txt: pyproject.toml poetry install poetry .cache/make/long-poetry: pyproject.toml # Update dependencies @@ -72,7 +72,7 @@ endif touch .cache/make/test .PHONY: test -pytest: src/nitpick.egg-info # Run pytest on the poetry venv (to quickly run tests locally without waiting for tox) +pytest: src/nitpick.egg-info/entry_points.txt # Run pytest on the poetry venv (to quickly run tests locally without waiting for tox) poetry run python -m pytest .PHONY: pytest diff --git a/poetry.lock b/poetry.lock index 3b910465..6e5d1aa3 100644 --- a/poetry.lock +++ b/poetry.lock @@ -485,7 +485,7 @@ wcwidth = "*" [[package]] category = "dev" description = "Run a subprocess in a pseudo terminal" -marker = "python_version >= \"3.4\" and sys_platform != \"win32\" or sys_platform != \"win32\" or python_version >= \"3.4\" and sys_platform != \"win32\" and (python_version >= \"3.4\" and sys_platform != \"win32\" or sys_platform != \"win32\")" +marker = "python_version >= \"3.4\" and sys_platform != \"win32\" or sys_platform != \"win32\"" name = "ptyprocess" optional = false python-versions = "*" @@ -876,7 +876,7 @@ version = "0.2.5" [[package]] category = "dev" description = "Enable Unicode input and display when running Python from Windows console." -marker = "sys_platform == \"win32\" and python_version < \"3.6\" and python_version >= \"3.4\" or sys_platform == \"win32\" and python_version < \"3.6\"" +marker = "sys_platform == \"win32\" and python_version < \"3.6\" or sys_platform == \"win32\" and python_version < \"3.6\" and python_version >= \"3.4\"" name = "win-unicode-console" optional = false python-versions = "*" diff --git a/pyproject.toml b/pyproject.toml index f602ab3b..79b5b863 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,7 +25,7 @@ classifiers = [ ] [tool.poetry.plugins."flake8.extension"] -NIP = "nitpick.flake8:NitpickChecker" +NIP = "nitpick.flake8:NitpickExtension" [tool.poetry.plugins.nitpick] json = "nitpick.plugins.json" diff --git a/src/nitpick/app.py b/src/nitpick/app.py index 651ca107..a19b8b4e 100644 --- a/src/nitpick/app.py +++ b/src/nitpick/app.py @@ -23,10 +23,10 @@ LOGGER = logging.getLogger(__name__) -class Nitpick: # pylint: disable=too-many-instance-attributes +class NitpickApp: # pylint: disable=too-many-instance-attributes """The Nitpick application.""" - _current_app = None # type: Nitpick + _current_app = None # type: NitpickApp root_dir = None # type: Path cache_dir = None # type: Path @@ -46,7 +46,7 @@ def __init__(self) -> None: self.offline = False @classmethod - def create_app(cls, offline=False) -> "Nitpick": + 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 @@ -78,7 +78,7 @@ def load_plugins() -> PluginManager: return plugin_manager @classmethod - def current_app(cls): + def current(cls): """Get the current app from the stack.""" return cls._current_app @@ -175,7 +175,7 @@ def as_flake8_warning(nitpick_error: NitpickError) -> Flake8Error: else "" ) - from nitpick.flake8 import NitpickChecker # pylint: disable=import-outside-toplevel + from nitpick.flake8 import NitpickExtension # pylint: disable=import-outside-toplevel return ( 0, @@ -187,7 +187,7 @@ def as_flake8_warning(nitpick_error: NitpickError) -> Flake8Error: nitpick_error.message.rstrip(), suggestion_with_newline, ), - NitpickChecker, + NitpickExtension, ) def add_style_error(self, file_name: str, message: str, invalid_data: str = None) -> None: diff --git a/src/nitpick/config.py b/src/nitpick/config.py index 3c6c4aef..5b5ba8b1 100644 --- a/src/nitpick/config.py +++ b/src/nitpick/config.py @@ -2,7 +2,7 @@ import logging from typing import TYPE_CHECKING, Optional -from nitpick.app import Nitpick +from nitpick.app import NitpickApp from nitpick.constants import ( MERGED_STYLE_TOML, NITPICK_MINIMUM_VERSION_JMEX, @@ -40,13 +40,13 @@ 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 = Nitpick.current_app().root_dir / PyProjectTomlFile.file_name # type: Path + pyproject_path = NitpickApp.current().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 = ToolNitpickSectionSchema().validate(self.tool_nitpick_dict) if pyproject_errors: - Nitpick.current_app().add_style_error( + NitpickApp.current().add_style_error( PyProjectTomlFile.file_name, "Invalid data in [{}]:".format(TOOL_NITPICK), flatten_marshmallow_errors(pyproject_errors), @@ -65,18 +65,18 @@ def merge_styles(self) -> YieldFlake8Error: style.find_initial_styles(configured_styles) self.style_dict = style.merge_toml_dict() - if not Nitpick.current_app().style_errors: + if not NitpickApp.current().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) - from nitpick.flake8 import NitpickChecker # pylint: disable=import-outside-toplevel + from nitpick.flake8 import NitpickExtension # pylint: disable=import-outside-toplevel minimum_version = search_dict(NITPICK_MINIMUM_VERSION_JMEX, self.style_dict, None) - if minimum_version and version_to_tuple(NitpickChecker.version) < version_to_tuple(minimum_version): + if minimum_version and version_to_tuple(NitpickExtension.version) < version_to_tuple(minimum_version): yield self.flake8_error( 3, "The style file you're using requires {}>={}".format(PROJECT_NAME, minimum_version) - + " (you have {}). Please upgrade".format(NitpickChecker.version), + + " (you have {}). Please upgrade".format(NitpickExtension.version), ) self.nitpick_section = self.style_dict.get("nitpick", {}) diff --git a/src/nitpick/flake8.py b/src/nitpick/flake8.py index bd407b68..e5aa87e4 100644 --- a/src/nitpick/flake8.py +++ b/src/nitpick/flake8.py @@ -1,4 +1,4 @@ -"""Flake8 plugin.""" +"""Flake8 plugin to check files.""" import itertools import logging from pathlib import Path @@ -8,7 +8,7 @@ from identify import identify from nitpick import __version__ -from nitpick.app import Nitpick +from nitpick.app import NitpickApp from nitpick.constants import PROJECT_NAME from nitpick.mixin import NitpickMixin from nitpick.typedefs import YieldFlake8Error @@ -17,8 +17,8 @@ @attr.s(hash=False) -class NitpickChecker(NitpickMixin): - """Main plugin class.""" +class NitpickExtension(NitpickMixin): + """Main class for the flake8 extension.""" # Plugin config name = PROJECT_NAME @@ -34,10 +34,10 @@ class NitpickChecker(NitpickMixin): def run(self) -> YieldFlake8Error: """Run the check plugin.""" has_errors = False - app = Nitpick.current_app() + app = NitpickApp.current() for err in app.init_errors: has_errors = True - yield Nitpick.as_flake8_warning(err) + yield NitpickApp.as_flake8_warning(err) if has_errors: return [] @@ -53,7 +53,7 @@ def run(self) -> YieldFlake8Error: has_errors = False for err in app.style_errors: has_errors = True - yield Nitpick.as_flake8_warning(err) + yield NitpickApp.as_flake8_warning(err) if has_errors: return [] @@ -77,8 +77,8 @@ def check_files(self, present: bool) -> YieldFlake8Error: key = "present" if present else "absent" message = "exist" if present else "be deleted" absent = not present - for file_name, extra_message in Nitpick.current_app().config.nitpick_files_section.get(key, {}).items(): - file_path = Nitpick.current_app().root_dir / file_name # type: Path + for file_name, extra_message in NitpickApp.current().config.nitpick_files_section.get(key, {}).items(): + file_path = NitpickApp.current().root_dir / file_name # type: Path exists = file_path.exists() if (present and exists) or (absent and not exists): continue @@ -93,10 +93,10 @@ def check_files(self, present: bool) -> YieldFlake8Error: def add_options(option_manager: OptionManager): """Add the offline option.""" option_manager.add_option( - Nitpick.format_flag(Nitpick.Flags.OFFLINE), + NitpickApp.format_flag(NitpickApp.Flags.OFFLINE), action="store_true", # dest="offline", - help=Nitpick.Flags.OFFLINE.value, + help=NitpickApp.Flags.OFFLINE.value, ) @staticmethod @@ -108,5 +108,5 @@ def parse_options(option_manager: OptionManager, options, args): # pylint: disa log_mapping = {1: logging.INFO, 2: logging.DEBUG} logging.basicConfig(level=log_mapping.get(options.verbose, logging.WARNING)) - Nitpick.create_app(offline=bool(options.nitpick_offline or Nitpick.get_env(Nitpick.Flags.OFFLINE))) - LOGGER.info("Offline mode: %s", Nitpick.current_app().offline) + NitpickApp.create_app(offline=bool(options.nitpick_offline or NitpickApp.get_env(NitpickApp.Flags.OFFLINE))) + LOGGER.info("Offline mode: %s", NitpickApp.current().offline) diff --git a/src/nitpick/mixin.py b/src/nitpick/mixin.py index ec959be3..3b232570 100644 --- a/src/nitpick/mixin.py +++ b/src/nitpick/mixin.py @@ -13,7 +13,7 @@ class NitpickMixin: def flake8_error(self, number: int, message: str, suggestion: str = None, add_to_base_number=True) -> Flake8Error: """Return a flake8 error as a tuple.""" # pylint: disable=import-outside-toplevel - from nitpick.app import Nitpick + from nitpick.app import NitpickApp from nitpick.exceptions import NitpickError error = NitpickError() @@ -24,7 +24,7 @@ def flake8_error(self, number: int, message: str, suggestion: str = None, add_to if suggestion: error.suggestion = suggestion error.add_to_base_number = add_to_base_number - return Nitpick.as_flake8_warning(error) + return NitpickApp.as_flake8_warning(error) def warn_missing_different(self, comparison: Comparison, prefix_message: str = ""): """Warn about missing and different keys.""" diff --git a/src/nitpick/plugins/base.py b/src/nitpick/plugins/base.py index b4c4686b..4ec62ebc 100644 --- a/src/nitpick/plugins/base.py +++ b/src/nitpick/plugins/base.py @@ -4,7 +4,7 @@ import jmespath -from nitpick.app import Nitpick +from nitpick.app import NitpickApp from nitpick.formats import TomlFormat from nitpick.generic import get_subclasses, search_dict from nitpick.mixin import NitpickMixin @@ -28,6 +28,9 @@ class BaseFile(NitpickMixin, metaclass=abc.ABCMeta): fixed_name_classes = set() # type: Set[Type[BaseFile]] dynamic_name_classes = set() # type: Set[Type[BaseFile]] + # 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. identify_tags = set() # type: Set[str] @@ -36,14 +39,14 @@ def __init__(self, config: JsonDict, file_name: str = None) -> None: self.file_name = file_name self.error_prefix = "File {}".format(self.file_name) - self.file_path = Nitpick.current_app().root_dir / self.file_name # type: Path + self.file_path = NitpickApp.current().root_dir / self.file_name # type: Path # Configuration for this file as a TOML dict, taken from the style file. self.file_dict = config or {} # type: JsonDict # Nitpick configuration for this file as a TOML dict, taken from the style file. self.nitpick_file_dict = search_dict( - 'files."{}"'.format(self.file_name), Nitpick.current_app().config.nitpick_section, {} + 'files."{}"'.format(self.file_name), NitpickApp.current().config.nitpick_section, {} ) # type: JsonDict @classmethod @@ -65,7 +68,7 @@ def get_compiled_jmespath_file_names(cls): 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 = Nitpick.current_app().config.nitpick_files_section.get( + should_exist = NitpickApp.current().config.nitpick_files_section.get( TomlFormat.group_name_for(self.file_name), True ) # type: bool file_exists = self.file_path.exists() @@ -73,7 +76,7 @@ def check_exists(self) -> YieldFlake8Error: if config_data_exists and not file_exists: suggestion = self.suggest_initial_contents() phrases = [" was not found"] - message = Nitpick.current_app().config.nitpick_files_section.get(self.file_name) + message = NitpickApp.current().config.nitpick_files_section.get(self.file_name) if message and isinstance(message, str): phrases.append(message) if suggestion: @@ -81,7 +84,7 @@ def check_exists(self) -> YieldFlake8Error: 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().style_errors: + if not NitpickApp.current().style_errors: yield self.flake8_error(2, " should be deleted") elif file_exists and config_data_exists: yield from self.check_rules() diff --git a/src/nitpick/plugins/pyproject_toml.py b/src/nitpick/plugins/pyproject_toml.py index ab346319..69b250fc 100644 --- a/src/nitpick/plugins/pyproject_toml.py +++ b/src/nitpick/plugins/pyproject_toml.py @@ -1,7 +1,7 @@ """Checker for `pyproject.toml `_.""" from typing import Optional, Set -from nitpick.app import Nitpick +from nitpick.app import NitpickApp from nitpick.plugins import hookimpl from nitpick.plugins.base import BaseFile from nitpick.typedefs import JsonDict, YieldFlake8Error @@ -21,8 +21,8 @@ class PyProjectTomlFile(BaseFile): def check_rules(self) -> YieldFlake8Error: """Check missing key/value pairs in pyproject.toml.""" - if Nitpick.current_app().config.pyproject_toml: - comparison = Nitpick.current_app().config.pyproject_toml.compare_with_flatten(self.file_dict) + if NitpickApp.current().config.pyproject_toml: + comparison = NitpickApp.current().config.pyproject_toml.compare_with_flatten(self.file_dict) yield from self.warn_missing_different(comparison) def suggest_initial_contents(self) -> str: diff --git a/src/nitpick/style.py b/src/nitpick/style.py index 856b95f4..7e5ecd48 100644 --- a/src/nitpick/style.py +++ b/src/nitpick/style.py @@ -12,7 +12,7 @@ from toml import TomlDecodeError from nitpick import __version__, fields -from nitpick.app import Nitpick +from nitpick.app import NitpickApp from nitpick.constants import ( MERGED_STYLE_TOML, NITPICK_STYLE_TOML, @@ -52,7 +52,7 @@ def find_initial_styles(self, configured_styles: StrOrList): chosen_styles = configured_styles log_message = "Styles configured in {}: %s".format(PyProjectTomlFile.file_name) else: - paths = climb_directory_tree(Nitpick.current_app().root_dir, [NITPICK_STYLE_TOML]) + paths = climb_directory_tree(NitpickApp.current().root_dir, [NITPICK_STYLE_TOML]) if paths: chosen_styles = str(sorted(paths)[0]) log_message = "Found style climbing the directory tree: %s" @@ -68,7 +68,7 @@ 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: - Nitpick.current_app().add_style_error( + NitpickApp.current().add_style_error( style_file_name, "Invalid config:", flatten_marshmallow_errors(style_errors) ) @@ -84,12 +84,12 @@ def include_multiple_styles(self, chosen_styles: StrOrList) -> None: try: toml_dict = toml.as_data except TomlDecodeError as err: - Nitpick.current_app().add_style_error(style_path.name, pretty_exception(err, "Invalid TOML")) + NitpickApp.current().add_style_error(style_path.name, pretty_exception(err, "Invalid TOML")) # If the TOML itself could not be parsed, we can't go on return try: - display_name = str(style_path.relative_to(Nitpick.current_app().root_dir)) + display_name = str(style_path.relative_to(NitpickApp.current().root_dir)) except ValueError: display_name = style_uri self.validate_style(display_name, toml_dict) @@ -112,7 +112,7 @@ def get_style_path(self, style_uri: str) -> Optional[Path]: def fetch_style_from_url(self, url: str) -> Optional[Path]: """Fetch a style file from a URL, saving the contents in the cache dir.""" - if Nitpick.current_app().offline: + if NitpickApp.current().offline: # No style will be fetched in offline mode return None @@ -131,7 +131,7 @@ def fetch_style_from_url(self, url: str) -> Optional[Path]: if new_url in self._already_included: return None - if not Nitpick.current_app().cache_dir: + if not NitpickApp.current().cache_dir: raise FileNotFoundError("Cache dir does not exist") try: @@ -139,7 +139,7 @@ def fetch_style_from_url(self, url: str) -> Optional[Path]: except requests.ConnectionError: click.secho( "Your network is unreachable. Fix your connection or use {} / {}=1".format( - Nitpick.format_flag(Nitpick.Flags.OFFLINE), Nitpick.format_env(Nitpick.Flags.OFFLINE) + NitpickApp.format_flag(NitpickApp.Flags.OFFLINE), NitpickApp.format_env(NitpickApp.Flags.OFFLINE) ), fg="red", err=True, @@ -153,8 +153,8 @@ def fetch_style_from_url(self, url: str) -> Optional[Path]: self._first_full_path = new_url.rsplit("/", 1)[0] contents = response.text - style_path = Nitpick.current_app().cache_dir / "{}.toml".format(slugify(new_url)) - Nitpick.current_app().cache_dir.mkdir(parents=True, exist_ok=True) + style_path = NitpickApp.current().cache_dir / "{}.toml".format(slugify(new_url)) + NitpickApp.current().cache_dir.mkdir(parents=True, exist_ok=True) style_path.write_text(contents) LOGGER.info("Loading style from URL %s into %s", new_url, style_path) @@ -191,7 +191,7 @@ def fetch_style_from_local_path(self, partial_file_name: str) -> Optional[Path]: def merge_toml_dict(self) -> JsonDict: """Merge all included styles into a TOML (actually JSON) dictionary.""" - app = Nitpick.current_app() + app = NitpickApp.current() if not app.cache_dir: return {} merged_dict = self._all_styles.merge() diff --git a/tests/helpers.py b/tests/helpers.py index 6ea6b741..73b84c53 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -8,9 +8,9 @@ from _pytest.fixtures import FixtureRequest from testfixtures import compare -from nitpick.app import Nitpick +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 NitpickChecker +from nitpick.flake8 import NitpickExtension from nitpick.formats import TomlFormat from nitpick.plugins.pre_commit import PreCommitFile from nitpick.plugins.pyproject_toml import PyProjectTomlFile @@ -76,15 +76,15 @@ def flake8(self, offline=False, file_index: int = 0) -> "ProjectMock": - Lint one of the project files. If no index is provided, use the default file that's always created. """ os.chdir(str(self.root_dir)) - Nitpick.create_app(offline) + NitpickApp.create_app(offline) - npc = NitpickChecker(filename=str(self.files_to_lint[file_index])) + npc = NitpickExtension(filename=str(self.files_to_lint[file_index])) self._original_errors = list(npc.run()) self._errors = set() for flake8_error in self._original_errors: line, col, message, class_ = flake8_error - if not (line == 0 and col == 0 and message.startswith(ERROR_PREFIX) and class_ is NitpickChecker): + if not (line == 0 and col == 0 and message.startswith(ERROR_PREFIX) and class_ is NitpickExtension): raise AssertionError() self._errors.add(message) return self diff --git a/tests/test_plugin.py b/tests/test_plugin.py index 9ac1a8ca..ce294583 100644 --- a/tests/test_plugin.py +++ b/tests/test_plugin.py @@ -7,7 +7,7 @@ import requests from flake8.main import cli -from nitpick.app import Nitpick +from nitpick.app import NitpickApp from nitpick.constants import READ_THE_DOCS_URL from tests.helpers import ProjectMock @@ -99,25 +99,25 @@ class OtherFlags(Enum): MULTI_WORD = 1 SOME_OPTION = 2 - assert Nitpick.format_flag(OtherFlags.MULTI_WORD) == "--nitpick-multi-word" + assert NitpickApp.format_flag(OtherFlags.MULTI_WORD) == "--nitpick-multi-word" os.environ["NITPICK_SOME_OPTION"] = "something" - assert Nitpick.format_env(OtherFlags.SOME_OPTION) == "NITPICK_SOME_OPTION" - assert Nitpick.get_env(OtherFlags.SOME_OPTION) == "something" - assert Nitpick.get_env(OtherFlags.MULTI_WORD) == "" + assert NitpickApp.format_env(OtherFlags.SOME_OPTION) == "NITPICK_SOME_OPTION" + assert NitpickApp.get_env(OtherFlags.SOME_OPTION) == "something" + assert NitpickApp.get_env(OtherFlags.MULTI_WORD) == "" def test_offline_flag_env_variable(tmpdir): """Test if the offline flag or environment variable was set.""" with tmpdir.as_cwd(): _call_main([]) - assert Nitpick.current_app().offline is False + assert NitpickApp.current().offline is False _call_main(["--nitpick-offline"]) - assert Nitpick.current_app().offline is True + assert NitpickApp.current().offline is True os.environ["NITPICK_OFFLINE"] = "1" _call_main([]) - assert Nitpick.current_app().offline is True + assert NitpickApp.current().offline is True @mock.patch("requests.get") diff --git a/tests/test_style.py b/tests/test_style.py index b207be38..67de96cc 100644 --- a/tests/test_style.py +++ b/tests/test_style.py @@ -152,7 +152,7 @@ def test_include_styles_overriding_values(offline, request): @pytest.mark.parametrize("offline", [False, True]) -@mock.patch("nitpick.flake8.NitpickChecker.version", new_callable=PropertyMock(return_value="0.5.3")) +@mock.patch("nitpick.flake8.NitpickExtension.version", new_callable=PropertyMock(return_value="0.5.3")) def test_minimum_version(mocked_version, offline, request): """Stamp a style file with a minimum required version, to indicate new features or breaking changes.""" assert_conditions(mocked_version == "0.5.3")