From 0f81d1df8f52d22c900d4f3c6afd8250c17f1743 Mon Sep 17 00:00:00 2001 From: heumsi Date: Tue, 31 Mar 2026 12:04:06 +0900 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat:=20Add=20inline=20ignore=20com?= =?UTF-8?q?ments=20support=20(#=20pnl:=20ignore)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Support suppressing lint violations on specific lines using inline comments: - `# pnl: ignore` suppresses all rules on the line - `# pnl: ignore=rule-name` suppresses a specific rule - `# pnl: ignore=rule-a,rule-b` suppresses multiple rules Co-Authored-By: Claude Opus 4.6 (1M context) --- python_naming_linter/cli.py | 3 ++ python_naming_linter/ignore.py | 41 ++++++++++++++ tests/test_ignore.py | 97 ++++++++++++++++++++++++++++++++++ 3 files changed, 141 insertions(+) create mode 100644 python_naming_linter/ignore.py create mode 100644 tests/test_ignore.py diff --git a/python_naming_linter/cli.py b/python_naming_linter/cli.py index 31c0ccc..0751581 100644 --- a/python_naming_linter/cli.py +++ b/python_naming_linter/cli.py @@ -12,6 +12,7 @@ from python_naming_linter.checkers.package import check_package from python_naming_linter.checkers.variable import check_variable from python_naming_linter.config import Rule, find_config, load_config +from python_naming_linter.ignore import filter_violations, parse_ignore_comments from python_naming_linter.matcher import matches_pattern_or_submodule from python_naming_linter.reporter import format_violations @@ -158,6 +159,7 @@ def check(config_path: str | None) -> None: continue rel_path = str(file_path.relative_to(root)) + ignores = parse_ignore_comments(source) file_violations = [] for rule in rules: @@ -180,6 +182,7 @@ def check(config_path: str | None) -> None: violations = _run_checker(tree, rule, rel_path, package) file_violations.extend(violations) + file_violations = filter_violations(file_violations, ignores) if file_violations: output = format_violations(rel_path, file_violations) click.echo(output) diff --git a/python_naming_linter/ignore.py b/python_naming_linter/ignore.py new file mode 100644 index 0000000..15f1bdd --- /dev/null +++ b/python_naming_linter/ignore.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +import re + +from python_naming_linter.checkers import Violation + +_IGNORE_RE = re.compile(r"#\s*pnl:\s*ignore(?:=([a-zA-Z0-9_,\s-]+))?$") + + +def parse_ignore_comments(source: str) -> dict[int, set[str] | None]: + """Parse inline ignore comments from source code. + + Returns a mapping of line number to ignored rule names. + ``None`` means all rules are ignored on that line. + """ + ignores: dict[int, set[str] | None] = {} + for lineno, line in enumerate(source.splitlines(), start=1): + match = _IGNORE_RE.search(line) + if match is None: + continue + rules_str = match.group(1) + if rules_str is None: + ignores[lineno] = None + else: + ignores[lineno] = {r.strip() for r in rules_str.split(",")} + return ignores + + +def filter_violations( + violations: list[Violation], + ignores: dict[int, set[str] | None], +) -> list[Violation]: + """Remove violations that are suppressed by ignore comments.""" + result = [] + for v in violations: + ignored = ignores.get(v.lineno) + if ignored is None and v.lineno not in ignores: + result.append(v) + elif ignored is not None and v.rule_name not in ignored: + result.append(v) + return result diff --git a/tests/test_ignore.py b/tests/test_ignore.py new file mode 100644 index 0000000..840454c --- /dev/null +++ b/tests/test_ignore.py @@ -0,0 +1,97 @@ +from python_naming_linter.checkers import Violation +from python_naming_linter.ignore import filter_violations, parse_ignore_comments + + +class TestParseIgnoreComments: + def test_no_comments(self): + source = "x: int = 1\ny: str = 'hello'\n" + assert parse_ignore_comments(source) == {} + + def test_ignore_all(self): + source = "x: int = 1 # pnl: ignore\n" + assert parse_ignore_comments(source) == {1: None} + + def test_ignore_single_rule(self): + source = "x: int = 1 # pnl: ignore=my-rule\n" + assert parse_ignore_comments(source) == {1: {"my-rule"}} + + def test_ignore_multiple_rules(self): + source = "x: int = 1 # pnl: ignore=rule-a,rule-b\n" + assert parse_ignore_comments(source) == {1: {"rule-a", "rule-b"}} + + def test_ignore_multiple_rules_with_spaces(self): + source = "x: int = 1 # pnl: ignore=rule-a, rule-b\n" + assert parse_ignore_comments(source) == {1: {"rule-a", "rule-b"}} + + def test_multiple_lines(self): + source = ( + "x: int = 1 # pnl: ignore\n" + "y: str = 'hello'\n" + "z = 3 # pnl: ignore=my-rule\n" + ) + result = parse_ignore_comments(source) + assert result == {1: None, 3: {"my-rule"}} + + def test_comment_only_line(self): + source = "# pnl: ignore\nx: int = 1\n" + assert parse_ignore_comments(source) == {1: None} + + def test_no_space_after_hash(self): + source = "x: int = 1 #pnl: ignore\n" + assert parse_ignore_comments(source) == {1: None} + + def test_extra_space_after_pnl(self): + source = "x: int = 1 # pnl: ignore\n" + assert parse_ignore_comments(source) == {1: None} + + +class TestFilterViolations: + def _make_violation(self, rule_name: str, lineno: int) -> Violation: + return Violation( + rule_name=rule_name, + file_path="test.py", + lineno=lineno, + name="x", + message="bad name", + ) + + def test_no_ignores(self): + violations = [self._make_violation("rule-a", 1)] + result = filter_violations(violations, {}) + assert len(result) == 1 + + def test_ignore_all_on_line(self): + violations = [ + self._make_violation("rule-a", 1), + self._make_violation("rule-b", 1), + ] + result = filter_violations(violations, {1: None}) + assert result == [] + + def test_ignore_specific_rule(self): + violations = [ + self._make_violation("rule-a", 1), + self._make_violation("rule-b", 1), + ] + result = filter_violations(violations, {1: {"rule-a"}}) + assert len(result) == 1 + assert result[0].rule_name == "rule-b" + + def test_ignore_does_not_affect_other_lines(self): + violations = [ + self._make_violation("rule-a", 1), + self._make_violation("rule-a", 2), + ] + result = filter_violations(violations, {1: None}) + assert len(result) == 1 + assert result[0].lineno == 2 + + def test_ignore_multiple_rules(self): + violations = [ + self._make_violation("rule-a", 1), + self._make_violation("rule-b", 1), + self._make_violation("rule-c", 1), + ] + result = filter_violations(violations, {1: {"rule-a", "rule-b"}}) + assert len(result) == 1 + assert result[0].rule_name == "rule-c"