diff --git a/Makefile b/Makefile index 8aeeb034..db302f9a 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: @@ -25,13 +29,18 @@ 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 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/entry_points.txt: pyproject.toml + poetry install + poetry .cache/make/long-poetry: pyproject.toml # Update dependencies poetry update poetry install @@ -63,6 +72,10 @@ endif touch .cache/make/test .PHONY: test +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 + doc .cache/make/doc: docs/*/* styles/*/* *.rst *.md # Build documentation only @rm -rf docs/source tox -e docs 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 c974a388..00000000 --- a/docs/source/nitpick.files.rst +++ /dev/null @@ -1,19 +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 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.flake8.rst similarity index 59% rename from docs/source/nitpick.plugin.rst rename to docs/source/nitpick.flake8.rst index 487c9c55..86c2b019 100644 --- a/docs/source/nitpick.plugin.rst +++ b/docs/source/nitpick.flake8.rst @@ -1,7 +1,7 @@ -nitpick.plugin module +nitpick.flake8 module ===================== -.. automodule:: nitpick.plugin +.. automodule:: nitpick.flake8 :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 b1d93576..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 ---------- @@ -25,10 +25,10 @@ Submodules nitpick.constants nitpick.exceptions nitpick.fields + nitpick.flake8 nitpick.formats nitpick.generic nitpick.mixin - nitpick.plugin nitpick.schemas nitpick.style nitpick.typedefs diff --git a/poetry.lock b/poetry.lock index 6dcf3739..6e5d1aa3 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.21" + +[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" @@ -447,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" @@ -474,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 = "*" @@ -676,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" @@ -865,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 = "*" @@ -898,7 +909,7 @@ lint = ["pylint"] test = ["pytest", "testfixtures", "responses"] [metadata] -content-hash = "06d575fd97b340e870b2cad29acb30364c55b35066ea12cf2d8687611344b926" +content-hash = "cd4ab91a9742dc79020ee7adb744525b7dadf169aec4709e317ce5c7be38cf76" 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.21-py2.py3-none-any.whl", hash = "sha256:dac33eff90d57164e289fb20bf4e131baef080947ee9bf45efcd0da8d19064bf"}, + {file = "identify-1.4.21.tar.gz", hash = "sha256:c4d07f2b979e3931894170a9e0d4b8281e6905ea6d018c326f7ffefaf20db680"}, +] 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"}, @@ -1199,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 76b9aaa1..79b5b863 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,7 +25,13 @@ classifiers = [ ] [tool.poetry.plugins."flake8.extension"] -NIP = "nitpick.plugin:NitpickChecker" +NIP = "nitpick.flake8:NitpickExtension" + +[tool.poetry.plugins.nitpick] +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" @@ -45,6 +51,9 @@ 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" = "*" +pluggy = "*" pylint = {version = "*", optional = true} pytest = {version = "*", optional = true} diff --git a/setup.cfg b/setup.cfg index c6b9bcb7..550b62af 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,pluggy,pytest,requests,responses,ruamel,slugify,sortedcontainers,testfixtures,toml [mypy] ignore_missing_imports = True @@ -85,9 +85,12 @@ 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 - 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] diff --git a/src/nitpick/app.py b/src/nitpick/app.py index 7ee74d74..a19b8b4e 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 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 @@ -20,15 +23,16 @@ LOGGER = logging.getLogger(__name__) -class Nitpick: +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 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.""" @@ -42,11 +46,11 @@ 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 - from nitpick.files.base import BaseFile + from nitpick.plugins.base import BaseFile app = cls() cls._current_app = app @@ -58,14 +62,23 @@ 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(plugins) + plugin_manager.load_setuptools_entrypoints(PROJECT_NAME) + return plugin_manager + @classmethod - def current_app(cls): + def current(cls): """Get the current app from the stack.""" return cls._current_app @@ -76,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] @@ -162,7 +175,7 @@ def as_flake8_warning(nitpick_error: NitpickError) -> Flake8Error: else "" ) - from nitpick.plugin import NitpickChecker # pylint: disable=import-outside-toplevel + from nitpick.flake8 import NitpickExtension # pylint: disable=import-outside-toplevel return ( 0, @@ -174,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 b5fef7d4..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, @@ -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 @@ -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.plugin 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/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 deleted file mode 100644 index 84bf3e7c..00000000 --- a/src/nitpick/files/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -"""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 diff --git a/src/nitpick/files/base.py b/src/nitpick/files/base.py deleted file mode 100644 index 6c50d2be..00000000 --- a/src/nitpick/files/base.py +++ /dev/null @@ -1,116 +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.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]] - - def __init__(self) -> None: - if self.has_multiple_files: - key = "{}.file_names".format(self.__class__.__name__) - 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.""" - 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/plugin.py b/src/nitpick/flake8.py similarity index 60% rename from src/nitpick/plugin.py rename to src/nitpick/flake8.py index 2c7e357e..e5aa87e4 100644 --- a/src/nitpick/plugin.py +++ b/src/nitpick/flake8.py @@ -5,12 +5,11 @@ import attr from flake8.options.manager import OptionManager +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.files.base import BaseFile -from nitpick.generic import get_subclasses from nitpick.mixin import NitpickMixin from nitpick.typedefs import YieldFlake8Error @@ -18,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 @@ -35,33 +34,41 @@ class NitpickChecker(NitpickMixin): def run(self) -> YieldFlake8Error: """Run the check plugin.""" has_errors = False - for err in Nitpick.current_app().init_errors: + 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 [] 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) + yield NitpickApp.as_flake8_warning(err) if has_errors: return [] - for checker_class in get_subclasses(BaseFile): - checker = checker_class() - yield from checker.check_exists() + # Get all root keys from the style TOML. + 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 base_file in app.plugin_manager.hook.handle_config_file( # pylint: disable=no-member + config=config_dict, file_name=path, tags=tags + ): + yield from base_file.check_exists() return [] @@ -70,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 @@ -86,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 @@ -101,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/__init__.py b/src/nitpick/plugins/__init__.py new file mode 100644 index 00000000..36b42ea1 --- /dev/null +++ b/src/nitpick/plugins/__init__.py @@ -0,0 +1,27 @@ +"""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 nitpick.constants import PROJECT_NAME +from nitpick.typedefs import JsonDict + +if TYPE_CHECKING: + from nitpick.plugins.base import BaseFile + + +hookspec = pluggy.HookspecMarker(PROJECT_NAME) +hookimpl = pluggy.HookimplMarker(PROJECT_NAME) + + +@hookspec +def handle_config_file( # pylint: disable=unused-argument + config: JsonDict, file_name: str, tags: Set[str] +) -> Optional["BaseFile"]: + """Return a BaseFile if this plugin handles the relative filename or any of its :py:package:`identify` tags.""" diff --git a/src/nitpick/plugins/base.py b/src/nitpick/plugins/base.py new file mode 100644 index 00000000..4ec62ebc --- /dev/null +++ b/src/nitpick/plugins/base.py @@ -0,0 +1,98 @@ +"""Base class for file checkers.""" +import abc +from typing import TYPE_CHECKING, Optional, Set, Type + +import jmespath + +from nitpick.app import NitpickApp +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]] + + # 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] + + 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 = 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), NitpickApp.current().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 = NitpickApp.current().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 = NitpickApp.current().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 NitpickApp.current().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 84% rename from src/nitpick/files/json.py rename to src/nitpick/plugins/json.py index dfb6a503..b7ce33a5 100644 --- a/src/nitpick/files/json.py +++ b/src/nitpick/plugins/json.py @@ -1,13 +1,15 @@ """JSON files.""" import json import logging +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.plugins import hookimpl +from nitpick.plugins.base import BaseFile from nitpick.schemas import BaseNitpickSchema from nitpick.typedefs import JsonDict, YieldFlake8Error @@ -19,8 +21,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): @@ -35,18 +37,17 @@ class JSONFile(BaseFile): Otherwise, a style validation error will be raised. """ - has_multiple_files = True error_base_number = 340 nested_field = JSONFileSchema + identify_tags = {"json"} SOME_VALUE_PLACEHOLDER = "" 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.""" @@ -87,3 +88,9 @@ def _check_contained_json(self) -> YieldFlake8Error: yield from self.warn_missing_different( JsonFormat(data=actual_fmt.as_data).compare_with_dictdiffer(expected, unflatten) ) + + +@hookimpl +def handle_config_file(config: JsonDict, file_name: str, tags: Set[str]) -> Optional["BaseFile"]: + """Handle JSON files.""" + 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 94% rename from src/nitpick/files/pre_commit.py rename to src/nitpick/plugins/pre_commit.py index 23117ba8..fb953668 100644 --- a/src/nitpick/files/pre_commit.py +++ b/src/nitpick/plugins/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.plugins import hookimpl +from nitpick.plugins.base import BaseFile from nitpick.typedefs import JsonDict, YamlData, YieldFlake8Error KEY_REPOS = "repos" @@ -187,3 +188,12 @@ def format_hook(expected_dict) -> str: else: output.append(" {}".format(line)) return "\n".join(output) + + +@hookimpl +def handle_config_file( # pylint: disable=unused-argument + config: JsonDict, file_name: str, tags: Set[str] +) -> Optional["BaseFile"]: + """Handle pre-commit config file.""" + base_file = PreCommitFile(config) + return base_file if file_name == TomlFormat.group_name_for(base_file.file_name) else None diff --git a/src/nitpick/files/pyproject_toml.py b/src/nitpick/plugins/pyproject_toml.py similarity index 55% rename from src/nitpick/files/pyproject_toml.py rename to src/nitpick/plugins/pyproject_toml.py index 90665316..69b250fc 100644 --- a/src/nitpick/files/pyproject_toml.py +++ b/src/nitpick/plugins/pyproject_toml.py @@ -1,7 +1,10 @@ """Checker for `pyproject.toml `_.""" -from nitpick.app import Nitpick -from nitpick.files.base import BaseFile -from nitpick.typedefs import YieldFlake8Error +from typing import Optional, Set + +from nitpick.app import NitpickApp +from nitpick.plugins import hookimpl +from nitpick.plugins.base import BaseFile +from nitpick.typedefs import JsonDict, YieldFlake8Error class PyProjectTomlFile(BaseFile): @@ -18,10 +21,19 @@ 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: """Suggest the initial content for this missing file.""" return "" + + +@hookimpl +def handle_config_file( # pylint: disable=unused-argument + config: JsonDict, file_name: str, tags: Set[str] +) -> Optional["BaseFile"]: + """Handle pyproject.toml file.""" + 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 89% rename from src/nitpick/files/setup_cfg.py rename to src/nitpick/plugins/setup_cfg.py index 51c0b5fa..555fdeeb 100644 --- a/src/nitpick/files/setup_cfg.py +++ b/src/nitpick/plugins/setup_cfg.py @@ -1,12 +1,13 @@ """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.typedefs import YieldFlake8Error +from nitpick.plugins import hookimpl +from nitpick.plugins.base import BaseFile +from nitpick.typedefs import JsonDict, YieldFlake8Error class SetupCfgFile(BaseFile): @@ -22,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: @@ -113,3 +114,11 @@ def get_example_cfg(config_parser: ConfigParser) -> str: config_parser.write(string_stream) output = string_stream.getvalue().strip() return output + + +@hookimpl +def handle_config_file( # pylint: disable=unused-argument + config: JsonDict, file_name: str, tags: Set[str] +) -> Optional["BaseFile"]: + """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 5cea2c5f..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: @@ -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..7e5ecd48 100644 --- a/src/nitpick/style.py +++ b/src/nitpick/style.py @@ -7,11 +7,12 @@ 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.app import NitpickApp from nitpick.constants import ( MERGED_STYLE_TOML, NITPICK_STYLE_TOML, @@ -19,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 @@ -51,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" @@ -67,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) ) @@ -83,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) @@ -111,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 @@ -130,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: @@ -138,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, @@ -152,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) @@ -190,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() @@ -235,13 +236,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)) + 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/helpers.py b/tests/helpers.py index f923ba82..73b84c53 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -8,13 +8,13 @@ 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.files.pre_commit import PreCommitFile -from nitpick.files.pyproject_toml import PyProjectTomlFile -from nitpick.files.setup_cfg import SetupCfgFile +from nitpick.flake8 import NitpickExtension from nitpick.formats import TomlFormat -from nitpick.plugin import NitpickChecker +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 @@ -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_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_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_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_style.py b/tests/test_style.py index aa099a76..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.plugin.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")