Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions python_naming_linter/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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:
Expand All @@ -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)
Expand Down
41 changes: 41 additions & 0 deletions python_naming_linter/ignore.py
Original file line number Diff line number Diff line change
@@ -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
97 changes: 97 additions & 0 deletions tests/test_ignore.py
Original file line number Diff line number Diff line change
@@ -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"
Loading