"""Filter for errors masked by noqa comments.""" from __future__ import annotations import enum from typing import Any, ClassVar, TYPE_CHECKING import flake8.checker import flake8.defaults import flake8.options.manager import flake8.style_guide import flake8.utils import flake8_noqa from typing_extensions import Protocol from .noqa_comment import InlineComment if (TYPE_CHECKING): import ast import tokenize from collections.abc import Iterator, Sequence try: try: from importlib.metadata import version except ModuleNotFoundError: # python < 3.8 use polyfill from importlib_metadata import version # type: ignore package_version = version(__package__) except Exception: package_version = 'unknown' class Report: """Violation report info.""" reports: ClassVar[dict[str, dict[int, list[str]]]] = {} @classmethod def add_report(cls, filename: str, error_code: (str | None), line_number: int, column: int, text: str) -> None: """Add violation report to master list.""" code = error_code if (error_code is not None) else text.split(' ', 1)[0] if (code.startswith(flake8_noqa.plugin_prefix)): return if (filename not in cls.reports): cls.reports[filename] = {} if (line_number not in cls.reports[filename]): cls.reports[filename][line_number] = [] cls.reports[filename][line_number].append(code) @classmethod def reports_from(cls, filename: str, start_line: int, end_line: int) -> Sequence[str]: """Get all volation reports for a range of lines.""" reports: list[str] = [] for line_number in range(start_line, end_line + 1): reports += cls.reports.get(filename, {}).get(line_number, []) return reports class Message(enum.Enum): """Messages.""" NOQA_NO_VIOLATIONS = (1, '"{comment}" has no violations') NOQA_NO_MATCHING_CODES = (2, '"{comment}" has no matching violations') NOQA_UNMATCHED_CODES = (3, '"{comment}" has unmatched {plural}, remove {unmatched}') NOQA_REQUIRE_CODE = (4, '"{comment}" must have codes, e.g. "# {noqa_strip}: {codes}"') @property def code(self) -> str: """Get code for message.""" return (flake8_noqa.noqa_filter_prefix + str(self.value[0]).rjust(6 - len(flake8_noqa.noqa_filter_prefix), '0')) def text(self, **kwargs) -> str: """Get formatted text of message.""" return self.value[1].format(**kwargs) class Options(Protocol): """Protocol for options.""" noqa_require_code: bool noqa_include_name: bool class NoqaFilter: """Check noqa comments for proper formatting.""" name: ClassVar[str] = __package__.replace('_', '-') version: ClassVar[str] = package_version plugin_name: ClassVar[str] require_code: ClassVar[bool] _filters: ClassVar[list[NoqaFilter]] = [] tree: ast.AST filename: str @classmethod def add_options(cls, option_manager: flake8.options.manager.OptionManager) -> None: """Add plugin options to option manager.""" option_manager.add_option('--noqa-require-code', default=False, action='store_true', parse_from_config=True, dest='noqa_require_code', help='Require code(s) to be included in "# noqa:" comments (disabled by default)') option_manager.add_option('--noqa-no-require-code', default=False, action='store_false', parse_from_config=True, dest='noqa_require_code', help='Do not require code(s) in "# noqa" comments') option_manager.add_option('--noqa-include-name', default=False, action='store_true', parse_from_config=True, dest='noqa_include_name', help='Include plugin name in messages (enabled by default)') option_manager.add_option('--noqa-no-include-name', default=None, action='store_false', parse_from_config=False, dest='noqa_include_name', help='Remove plugin name from messages') @classmethod def parse_options(cls, options: Options) -> None: """Parse plugin options.""" cls.plugin_name = (' (' + cls.name + ')') if (options.noqa_include_name) else '' cls.require_code = options.noqa_require_code @classmethod def filters(cls) -> Sequence[NoqaFilter]: """Get all filters.""" return cls._filters @classmethod def clear_filters(cls) -> None: """Clear filters.""" cls._filters = [] def __init__(self, tree: ast.AST, filename: str) -> None: self.tree = tree self.filename = filename self._filters.append(self) def __iter__(self) -> Iterator[tuple[int, int, str, Any]]: """Primary call from flake8, yield violations.""" return iter([]) def _message(self, token: tokenize.TokenInfo, message: Message, **kwargs) -> tuple[int, int, str, Any]: return (token.start[0], token.start[1], f'{message.code}{self.plugin_name} {message.text(**kwargs)}', type(self)) def violations(self) -> Iterator[tuple[int, int, str, Any]]: """Private iterator to return violations.""" for comment in InlineComment.file_comments(self.filename): reports = Report.reports_from(self.filename, comment.start_line, comment.end_line) comment_codes = set(comment.code_list) if (comment_codes): matched_codes: set[str] = set() for code in reports: if (code in comment_codes): matched_codes.add(code) if (matched_codes): if (len(matched_codes) < len(comment_codes)): unmatched_codes = comment_codes - matched_codes yield self._message(comment.token, Message.NOQA_UNMATCHED_CODES, comment=comment.text, unmatched=', '.join(unmatched_codes), plural='codes' if (1 < len(unmatched_codes)) else 'code') else: yield self._message(comment.token, Message.NOQA_NO_MATCHING_CODES, comment=comment.text) pass else: # blanket noqa if (reports): if (self.require_code): yield self._message(comment.token, Message.NOQA_REQUIRE_CODE, comment=comment.text, noqa_strip=comment.noqa.strip(), codes=', '.join(sorted(set(reports)))) else: yield self._message(comment.token, Message.NOQA_NO_VIOLATIONS, comment=comment.text) class Violation(flake8.style_guide.Violation): """Replacement for flake8's Violation class.""" def is_inline_ignored(self, disable_noqa: bool, *args, **kwargs) -> bool: """Prevent violations from this plugin from being ignored.""" if (self.code.startswith(flake8_noqa.plugin_prefix)): return False return super().is_inline_ignored(disable_noqa, *args, **kwargs) class FileChecker(flake8.checker.FileChecker): """Replacement for flake8's FileChecker.""" def run_checks(self, *args, **kwargs) -> Any: """Get voilations from NoqaFilter after all other checks are run.""" result = super().run_checks(*args, **kwargs) for filter in NoqaFilter.filters(): for line_number, column, text, _ in filter.violations(): self.report(error_code=None, line_number=line_number, column=column, text=text) NoqaFilter.clear_filters() return result def report(self, error_code: (str | None), line_number: int, column: int, text: str, *args, **kwargs) -> str: """Capture report information.""" Report.add_report(self.processor.filename, error_code, line_number, column, text) return super().report(error_code, line_number, column, text, *args, **kwargs) # patch flake8 flake8.style_guide.Violation = Violation flake8.checker.FileChecker = FileChecker