1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204
|
"""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
|