From 77bab53876d529ab7926822861427760222230e2 Mon Sep 17 00:00:00 2001 From: heumsi Date: Tue, 31 Mar 2026 12:05:36 +0900 Subject: [PATCH 1/2] =?UTF-8?q?=E2=9C=A8=20feat:=20Add=20inline=20ignore?= =?UTF-8?q?=20comment=20support?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `# pdl: ignore` syntax to suppress lint violations on specific import lines. Supports blanket ignore (`# pdl: ignore`) and selective ignore by rule name (`# pdl: ignore[rule1, rule2]`). Co-Authored-By: Claude Opus 4.6 (1M context) --- python_dependency_linter/cli.py | 3 + python_dependency_linter/parser.py | 41 +++++++++-- tests/test_cli.py | 107 +++++++++++++++++++++++++++++ tests/test_parser.py | 57 ++++++++++++++- 4 files changed, 203 insertions(+), 5 deletions(-) diff --git a/python_dependency_linter/cli.py b/python_dependency_linter/cli.py index f0ea728..0d58ed5 100644 --- a/python_dependency_linter/cli.py +++ b/python_dependency_linter/cli.py @@ -131,6 +131,9 @@ def check(config_path: str | None): category = resolve_import(imp.module, root) violation = check_import(imp, category, merged_rule, module, captures) if violation is not None: + if imp.ignore_rules is not None: + if not imp.ignore_rules or violation.rule_name in imp.ignore_rules: + continue file_violations.append(violation) if file_violations: diff --git a/python_dependency_linter/parser.py b/python_dependency_linter/parser.py index 14c9fc9..3cca59f 100644 --- a/python_dependency_linter/parser.py +++ b/python_dependency_linter/parser.py @@ -1,14 +1,32 @@ from __future__ import annotations import ast -from dataclasses import dataclass +import re +from dataclasses import dataclass, field from pathlib import Path +_IGNORE_PATTERN = re.compile(r"#\s*pdl:\s*ignore(?:\[([^\]]*)\])?") + @dataclass(frozen=True) class ImportInfo: module: str lineno: int + ignore_rules: list[str] | None = field(default=None, compare=False) + + +def _parse_ignore_comment(line: str) -> list[str] | None: + """Parse ``# pdl: ignore`` or ``# pdl: ignore[rule1, rule2]`` from a source line. + + Returns ``None`` if no ignore comment is found, an empty list for + blanket ignore, or a list of rule names for selective ignore. + """ + m = _IGNORE_PATTERN.search(line) + if m is None: + return None + if m.group(1) is None: + return [] + return [r.strip() for r in m.group(1).split(",") if r.strip()] def _resolve_relative_import( @@ -41,20 +59,35 @@ def _resolve_relative_import( def parse_imports(file_path: Path, project_root: Path) -> list[ImportInfo]: source = file_path.read_text() tree = ast.parse(source, filename=str(file_path)) + source_lines = source.splitlines() imports = [] for node in ast.walk(tree): if isinstance(node, ast.Import): + ignore = _parse_ignore_comment(source_lines[node.lineno - 1]) for alias in node.names: - imports.append(ImportInfo(module=alias.name, lineno=node.lineno)) + imports.append( + ImportInfo( + module=alias.name, lineno=node.lineno, ignore_rules=ignore + ) + ) elif isinstance(node, ast.ImportFrom): + ignore = _parse_ignore_comment(source_lines[node.lineno - 1]) if node.level and node.level > 0: resolved = _resolve_relative_import( file_path, project_root, node.level, node.module ) if resolved is not None: - imports.append(ImportInfo(module=resolved, lineno=node.lineno)) + imports.append( + ImportInfo( + module=resolved, lineno=node.lineno, ignore_rules=ignore + ) + ) elif node.module is not None: - imports.append(ImportInfo(module=node.module, lineno=node.lineno)) + imports.append( + ImportInfo( + module=node.module, lineno=node.lineno, ignore_rules=ignore + ) + ) return imports diff --git a/tests/test_cli.py b/tests/test_cli.py index 353aeff..0725b19 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -237,3 +237,110 @@ def test_cli_check_with_explicit_config(tmp_path, monkeypatch): result = runner.invoke(main, ["check", "--config", str(config_file)]) assert result.exit_code == 1 assert "src/app.py" in result.output + + +def test_cli_check_ignore_blanket(tmp_path, monkeypatch): + """``# pdl: ignore`` suppresses all violations on that line.""" + config_content = """\ +rules: + - name: domain-isolation + modules: "**" + deny: + third_party: [pydantic] +""" + config_file = tmp_path / ".python-dependency-linter.yaml" + config_file.write_text(config_content) + + src = tmp_path / "src" + src.mkdir() + (src / "__init__.py").write_text("") + (src / "app.py").write_text("import pydantic # pdl: ignore\n") + + monkeypatch.chdir(tmp_path) + runner = CliRunner() + result = runner.invoke(main, ["check"]) + assert result.exit_code == 0 + assert "No violations found." in result.output + + +def test_cli_check_ignore_specific_rule(tmp_path, monkeypatch): + """``# pdl: ignore[rule-name]`` suppresses only the matching rule.""" + config_content = """\ +rules: + - name: deny-pydantic + modules: src.* + deny: + third_party: [pydantic] + - name: deny-requests + modules: lib.* + deny: + third_party: [requests] +""" + config_file = tmp_path / ".python-dependency-linter.yaml" + config_file.write_text(config_content) + + src = tmp_path / "src" + src.mkdir() + (src / "__init__.py").write_text("") + (src / "app.py").write_text("import pydantic # pdl: ignore[deny-pydantic]\n") + + lib = tmp_path / "lib" + lib.mkdir() + (lib / "__init__.py").write_text("") + (lib / "client.py").write_text("import requests # pdl: ignore[deny-requests]\n") + + monkeypatch.chdir(tmp_path) + runner = CliRunner() + result = runner.invoke(main, ["check"]) + assert result.exit_code == 0 + assert "No violations found." in result.output + + +def test_cli_check_ignore_specific_rule_no_match(tmp_path, monkeypatch): + """``# pdl: ignore[wrong-name]`` does not suppress a different rule.""" + config_content = """\ +rules: + - name: deny-pydantic + modules: "**" + deny: + third_party: [pydantic] +""" + config_file = tmp_path / ".python-dependency-linter.yaml" + config_file.write_text(config_content) + + src = tmp_path / "src" + src.mkdir() + (src / "__init__.py").write_text("") + (src / "app.py").write_text("import pydantic # pdl: ignore[wrong-name]\n") + + monkeypatch.chdir(tmp_path) + runner = CliRunner() + result = runner.invoke(main, ["check"]) + assert result.exit_code == 1 + assert "[deny-pydantic]" in result.output + + +def test_cli_check_ignore_multiple_rules(tmp_path, monkeypatch): + """``# pdl: ignore[rule1, rule2]`` suppresses listed rules.""" + config_content = """\ +rules: + - name: deny-pydantic + modules: src.* + deny: + third_party: [pydantic] +""" + config_file = tmp_path / ".python-dependency-linter.yaml" + config_file.write_text(config_content) + + src = tmp_path / "src" + src.mkdir() + (src / "__init__.py").write_text("") + (src / "app.py").write_text( + "import pydantic # pdl: ignore[deny-pydantic, other-rule]\n" + ) + + monkeypatch.chdir(tmp_path) + runner = CliRunner() + result = runner.invoke(main, ["check"]) + assert result.exit_code == 0 + assert "No violations found." in result.output diff --git a/tests/test_parser.py b/tests/test_parser.py index 7bce3e6..f6e8467 100644 --- a/tests/test_parser.py +++ b/tests/test_parser.py @@ -1,6 +1,10 @@ from pathlib import Path -from python_dependency_linter.parser import ImportInfo, parse_imports +from python_dependency_linter.parser import ( + ImportInfo, + _parse_ignore_comment, + parse_imports, +) FIXTURES = Path(__file__).parent / "fixtures" / "sample_project" @@ -72,3 +76,54 @@ def test_parse_relative_import_from_init(): imports = parse_imports(file_path, project_root=FIXTURES) assert ImportInfo(module="contexts.boards.domain", lineno=1) in imports + + +# --- _parse_ignore_comment tests --- + + +def test_parse_ignore_comment_no_comment(): + assert _parse_ignore_comment("import os") is None + + +def test_parse_ignore_comment_blanket(): + assert _parse_ignore_comment("import os # pdl: ignore") == [] + + +def test_parse_ignore_comment_single_rule(): + assert _parse_ignore_comment("import os # pdl: ignore[domain-isolation]") == [ + "domain-isolation" + ] + + +def test_parse_ignore_comment_multiple_rules(): + assert _parse_ignore_comment( + "import os # pdl: ignore[domain-isolation, adapters-deny]" + ) == ["domain-isolation", "adapters-deny"] + + +def test_parse_ignore_comment_whitespace_variants(): + assert _parse_ignore_comment("import os #pdl:ignore") == [] + assert _parse_ignore_comment("import os # pdl: ignore[rule1]") == ["rule1"] + + +# --- parse_imports with ignore comment --- + + +def test_parse_imports_with_ignore_comment(tmp_path): + source = """\ +import os # pdl: ignore +from typing import Optional +import sys # pdl: ignore[domain-isolation, adapters-deny] +""" + file_path = tmp_path / "test.py" + file_path.write_text(source) + imports = parse_imports(file_path, project_root=tmp_path) + + os_imp = next(i for i in imports if i.module == "os") + assert os_imp.ignore_rules == [] + + typing_imp = next(i for i in imports if i.module == "typing") + assert typing_imp.ignore_rules is None + + sys_imp = next(i for i in imports if i.module == "sys") + assert sys_imp.ignore_rules == ["domain-isolation", "adapters-deny"] From 2815317f5000c43b5f8defabefd10dc968a9f994 Mon Sep 17 00:00:00 2001 From: heumsi Date: Tue, 31 Mar 2026 12:09:16 +0900 Subject: [PATCH 2/2] =?UTF-8?q?=F0=9F=93=9D=20docs:=20Add=20inline=20ignor?= =?UTF-8?q?e=20comment=20section=20to=20README?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/README.md b/README.md index 5417582..1bdfccb 100644 --- a/README.md +++ b/README.md @@ -320,6 +320,26 @@ modules = "contexts.*.domain" third_party = ["boto3"] ``` +### Inline Ignore + +Suppress violations on specific import lines using `# pdl: ignore` comments: + +```python +import boto3 # pdl: ignore +``` + +To suppress only specific rules, specify rule names in brackets: + +```python +import boto3 # pdl: ignore[no-boto-in-domain] +``` + +Multiple rules can be listed with commas: + +```python +import boto3 # pdl: ignore[no-boto-in-domain, other-rule] +``` + ## CLI ```bash