Source code for nitpick.plugins.base

"""Base class for file checkers."""
from __future__ import annotations

import abc
import fnmatch
from typing import TYPE_CHECKING, Iterator

from autorepr import autotext
from loguru import logger

from nitpick.blender import BaseDoc, flatten_quotes, search_json
from nitpick.config import SpecialConfig
from nitpick.constants import DUNDER_LIST_KEYS
from nitpick.typedefs import JsonDict, mypy_property
from nitpick.violations import Fuss, Reporter, SharedViolations

if TYPE_CHECKING:
    from pathlib import Path

    from marshmallow import Schema

    from nitpick.plugins.info import FileInfo


[docs]class NitpickPlugin(metaclass=abc.ABCMeta): # pylint: disable=too-many-instance-attributes """Base class for Nitpick plugins. :param data: File information (project, path, tags). :param expected_config: Expected configuration for the file :param autofix: Flag to modify files, if the plugin supports it (default: True). """ __str__, __unicode__ = autotext("{self.info.path_from_root} ({self.__class__.__name__})") filename = "" # TODO: refactor: remove filename attribute after fixing dynamic/fixed schema loading violation_base_code: int = 0 #: Can this plugin modify its files directly? Are the files fixable? fixable: bool = False #: 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.JsonPlugin`). validation_schema: Schema | None = None #: Which ``identify`` tags this :py:class:`nitpick.plugins.base.NitpickPlugin` child recognises. identify_tags: set[str] = set() skip_empty_suggestion = False def __init__(self, info: FileInfo, expected_config: JsonDict, autofix=False) -> None: self.info = info self.filename = info.path_from_root self.reporter = Reporter(info, self.violation_base_code) self.file_path: Path = self.info.project.root / self.filename # Configuration for this file as a TOML dict, taken from the style file. self.expected_config: JsonDict = expected_config or {} self.autofix = self.fixable and autofix # Dirty flag to avoid changing files without need self.dirty: bool = False self._merge_special_configs() def _merge_special_configs(self): """Merge the predefined plugin config with the style dict to create the special config.""" spc = self.predefined_special_config() temp_dict = spc.list_keys.from_plugin.copy() # pylint: disable=no-member # The user can override the default list keys (if any) by setting them on the style file. # pylint: disable=assigning-non-slot,no-member spc.list_keys.from_style = self.expected_config.pop(DUNDER_LIST_KEYS, None) or {} temp_dict.update(flatten_quotes(spc.list_keys.from_style)) flat_config = flatten_quotes(self.expected_config) for key_with_pattern, parent_child_keys in temp_dict.items(): for expanded_key in fnmatch.filter(flat_config.keys(), key_with_pattern): spc.list_keys.value[expanded_key] = parent_child_keys self.special_config = spc
[docs] def predefined_special_config(self) -> SpecialConfig: # pylint: disable=no-self-use """Create a predefined special configuration for this plugin. Each plugin can override this method.""" return SpecialConfig()
@mypy_property def nitpick_file_dict(self) -> JsonDict: """Nitpick configuration for this file as a TOML dict, taken from the style file.""" return search_json(self.info.project.nitpick_section, f'files."{self.filename}"', {})
[docs] def entry_point(self) -> Iterator[Fuss]: """Entry point of the Nitpick plugin.""" self.post_init() should_exist: bool = bool(self.info.project.nitpick_files_section.get(self.filename, True)) if self.file_path.exists() and not should_exist: logger.info(f"{self}: File {self.filename} exists when it should not") # Only display this message if the style is valid. yield self.reporter.make_fuss(SharedViolations.DELETE_FILE) return has_config_dict = bool(self.expected_config or self.nitpick_file_dict) if not has_config_dict: return yield from self._enforce_file_configuration()
def _enforce_file_configuration(self): file_exists = self.file_path.exists() if file_exists: logger.info(f"{self}: Enforcing rules") yield from self.enforce_rules() else: yield from self._suggest_when_file_not_found() if self.autofix and self.dirty: fuss = self.write_file(file_exists) # pylint: disable=assignment-from-none if fuss: yield fuss
[docs] def post_init(self): # noqa: B027 """Hook for plugin initialization after the instance was created. The name mimics ``__post_init__()`` on dataclasses, without the magic double underscores: `Post-init processing <https://docs.python.org/3/library/dataclasses.html#post-init-processing>`_ """
def _suggest_when_file_not_found(self): suggestion = self.initial_contents if not suggestion and self.skip_empty_suggestion: return logger.info(f"{self}: Suggest initial contents for {self.filename}") if suggestion: yield self.reporter.make_fuss(SharedViolations.CREATE_FILE_WITH_SUGGESTION, suggestion, fixed=self.autofix) else: yield self.reporter.make_fuss(SharedViolations.CREATE_FILE)
[docs] def write_file( # pylint: disable=no-self-use self, file_exists: bool, # pylint: disable=unused-argument # noqa: ARG002 ) -> Fuss | None: """Hook to write the new file when autofix mode is on. Should be used by inherited classes.""" return None
[docs] @abc.abstractmethod def enforce_rules(self) -> Iterator[Fuss]: """Enforce rules for this file. It must be overridden by inherited classes if needed."""
@property @abc.abstractmethod def initial_contents(self) -> str: """Suggested initial content when the file doesn't exist."""
[docs] def write_initial_contents(self, doc_class: type[BaseDoc], expected_dict: dict | None = None) -> str: """Helper to write initial contents based on a format.""" if not expected_dict: expected_dict = self.expected_config formatted_str = doc_class(obj=expected_dict).reformatted if self.autofix: self.file_path.parent.mkdir(exist_ok=True, parents=True) self.file_path.write_text(formatted_str) return formatted_str