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
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions python_dependency_linter/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
41 changes: 37 additions & 4 deletions python_dependency_linter/parser.py
Original file line number Diff line number Diff line change
@@ -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(
Expand Down Expand Up @@ -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
107 changes: 107 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
57 changes: 56 additions & 1 deletion tests/test_parser.py
Original file line number Diff line number Diff line change
@@ -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"

Expand Down Expand Up @@ -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"]
Loading