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 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"]