diff --git a/src/flake8/defaults.py b/src/flake8/defaults.py index 57abda1..fa5a87c 100644 --- a/src/flake8/defaults.py +++ b/src/flake8/defaults.py @@ -40,6 +40,25 @@ NOQA_INLINE_REGEXP = re.compile( re.IGNORECASE, ) +NOQA_BLOCK_REGEXP = re.compile( + # We're looking for items that look like this: + # ``# noqa: off`` + # ``# noqa:off`` + # ``# noqa: Off`` + # ``# noqa:Off`` + # ``# noqa: off E123`` + # ``# noqa: off E123,W451,F921`` + # ``# noqa:off E123,W451,F921`` + # ``# NoQA: off E123,W451,F921`` + # ``# NOQA: off E123,W451,F921`` + # ``# NOQA:off E123,W451,F921`` + # We do not care about the casing of ``noqa`` + # We want the desired block state + # We want a comma-separated list of errors + r"# noqa:[\s]?(?Poff|on)[\s]?(?P([A-Z]+[0-9]+(?:[,\s]+)?)+)?", + re.IGNORECASE, +) + NOQA_FILE = re.compile(r"\s*# flake8[:=]\s*noqa", re.I) VALID_CODE_PREFIX = re.compile("^[A-Z]{1,3}[0-9]{0,3}$", re.ASCII) diff --git a/src/flake8/style_guide.py b/src/flake8/style_guide.py index a409484..5823d95 100644 --- a/src/flake8/style_guide.py +++ b/src/flake8/style_guide.py @@ -423,8 +423,13 @@ class StyleGuide: error_is_selected = ( self.should_report_error(error.code) is Decision.Selected ) - is_not_inline_ignored = error.is_inline_ignored(disable_noqa) is False - if error_is_selected and is_not_inline_ignored: + is_ignored = ( + error.is_block_ignored(disable_noqa) + or error.is_inline_ignored(disable_noqa) + ) + is_not_ignored = is_ignored is False + + if error_is_selected and is_not_ignored: self.formatter.handle(error) self.stats.record(error) return 1 diff --git a/src/flake8/violation.py b/src/flake8/violation.py index 96161d4..3558b10 100644 --- a/src/flake8/violation.py +++ b/src/flake8/violation.py @@ -19,6 +19,45 @@ def _find_noqa(physical_line: str) -> Match[str] | None: return defaults.NOQA_INLINE_REGEXP.search(physical_line) +@functools.lru_cache(maxsize=512) +def _find_block_noqa(physical_line: str) -> Match[str] | None: + return defaults.NOQA_BLOCK_REGEXP.search(physical_line) + + +@functools.lru_cache(maxsize=8) +def _noqa_block_ranges(filename: str) -> Match[str] | None: + noqa_block_ranges = [] + next_expected_state = "on" + current_block_start = None + current_block_codes = None + + enumerated_lines = enumerate(linecache.getlines(filename), start=1) + + for line_no, physical_line in enumerated_lines: + noqa_match = _find_block_noqa(physical_line) + if noqa_match is None: + continue + + state = noqa_match.groupdict().get("state").lower() + codes = noqa_match.groupdict().get("codes") + + if state != next_expected_state: + continue + elif state == "on": + next_expected_state = "off" + current_block_start = line_no + current_block_codes = codes + elif state == "off": + noqa_block_ranges.append(( + range(current_block_start, line_no), current_block_codes, + )) + next_expected_state = "on" + current_block_start = None + current_block_codes = None + + return tuple(noqa_block_ranges) + + class Violation(NamedTuple): """Class representing a violation reported by Flake8.""" @@ -29,6 +68,54 @@ class Violation(NamedTuple): text: str physical_line: str | None + def is_block_ignored(self, disable_noqa: bool) -> bool: + """Determine if line is between comments which define an ignore block. + + :param disable_noqa: + Whether or not users have provided ``--disable-noqa``. + :returns: + True if error is ignored by block, False otherwise. + """ + if disable_noqa: + return False + + filename = self.filename + line_number = self.line_number + + def in_range(block_range): + return line_number in block_range + + blocks_in_range = ( + (block_line_range, codes) + for block_line_range, codes in _noqa_block_ranges(filename) + for is_in_range, codes in [(in_range(block_line_range), codes)] + if is_in_range + ) + + block_line_range, codes_str = next(blocks_in_range, (None, None)) + + if block_line_range is None: + LOG.debug("%r is not block ignored", self) + return False + + if codes_str is None: + LOG.debug("%r is ignored by a blanket ``# noqa: on ... # noqa: off`` block", self) + return True + + codes = set(utils.parse_comma_separated_list(codes_str)) + if self.code in codes or self.code.startswith(tuple(codes)): + LOG.debug( + "%r is ignored specifically within ``# noqa: on %s ... # noqa: off`` block", + self, + codes_str, + ) + return True + + LOG.debug( + "%r is not ignored within ``# noqa: on %s ... # noqa: off`` block", self, codes_str + ) + return False + def is_inline_ignored(self, disable_noqa: bool) -> bool: """Determine if a comment has been added to ignore this line. diff --git a/tests/unit/test_violation.py b/tests/unit/test_violation.py index 1b4852b..a80dee3 100644 --- a/tests/unit/test_violation.py +++ b/tests/unit/test_violation.py @@ -51,3 +51,64 @@ def test_disable_is_inline_ignored(): assert error.is_inline_ignored(True) is False assert getline.called is False + + +def _bi_tc(error_code, physical_line, block_start, filename, expected_result): + if block_start is None: + physical_lines = [physical_line] + line_no = 1 + else: + block_end = "# noqa: off" + physical_lines = [block_start, physical_line, block_end] + line_no = 2 + + return (error_code, filename, physical_lines, line_no, expected_result) + + +@pytest.mark.parametrize( + "error_code,filename,physical_lines,line_no,expected_result", + [ + _bi_tc("E111", "a = 1", None, "filename-1.py", False), + _bi_tc("E121", "a = 1", "# noqa: on E111", "filename-2.py", False), + _bi_tc("E121", "a = 1", "# noqa: on E111,W123,F821", "filename-3.py", False), + _bi_tc("E111", "a = 1", "# noqa: on E111,W123,F821", "filename-4.py", True), + _bi_tc("W123", "a = 1", "# noqa: on E111,W123,F821", "filename-5.py", True), + _bi_tc("W123", "a = 1", "# noqa: on E111, W123,F821", "filename-6.py", True), + _bi_tc("E111", "a = 1", "# noqa: on E11,W123,F821", "filename-7.py", True), + _bi_tc("E121", "a = 1", "# noqa:on E111,W123,F821", "filename-8.py", False), + _bi_tc("E111", "a = 1", "# noqa:on E111,W123,F821", "filename-9.py", True), + _bi_tc("W123", "a = 1", "# noqa:on E111,W123,F821", "filename-10.py", True), + _bi_tc("W123", "a = 1", "# noqa:on E111, W123,F821", "filename-11.py", True), + _bi_tc("E111", "a = 1", "# noqa:on E11,W123,F821", "filename-12.py", True), + _bi_tc("E111", "a = 1", "# noqa: on - We do not care", "filename-13.py", True), + _bi_tc("E111", "a = 1", "# noqa: on We do not care", "filename-14.py", True), + _bi_tc("E111", "a = 1", "# noqa:On We do not care", "filename-15.py", True), + _bi_tc("ABC123", "a = 1", "# noqa: on ABC123", "filename-16.py", True), + _bi_tc("E111", "a = 1", "# noqa: on ABC123", "filename-17.py", False), + _bi_tc("ABC123", "a = 1", "# noqa: on ABC124", "filename-18.py", False), + _bi_tc("ABC123", "a = 1", "# noqa: ABC124", "filename-19.py", False), + _bi_tc("ABC123", "a = 1", "# noqa: off ABC124", "filename-19.py", False), + ], +) +def test_is_block_ignored( + error_code, + filename, + physical_lines, + line_no, + expected_result, +): + """Verify that we detect block usage of ``# noqa: off/on``.""" + error = Violation(error_code, filename, line_no, 1, "error text", None) + + with mock.patch("linecache.getlines", return_value=physical_lines): + assert error.is_block_ignored(False) is expected_result + + +def test_disable_is_block_ignored(): + """Verify that is_block_ignored exits immediately if disabling NoQA.""" + error = Violation("E121", "filename.py", 1, 1, "error text", "line") + + with mock.patch("linecache.getlines") as getlines: + assert error.is_block_ignored(True) is False + + assert getlines.called is False