python-dependency-linter 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,94 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+ from python_dependency_linter.config import AllowDeny, Rule
6
+ from python_dependency_linter.matcher import matches_pattern
7
+ from python_dependency_linter.parser import ImportInfo
8
+ from python_dependency_linter.resolver import ImportCategory
9
+
10
+
11
+ @dataclass
12
+ class Violation:
13
+ rule_name: str
14
+ source_module: str
15
+ imported_module: str
16
+ category: ImportCategory
17
+ lineno: int
18
+
19
+
20
+ def _get_category_list(
21
+ allow_deny: AllowDeny | None, category: ImportCategory
22
+ ) -> list[str] | None:
23
+ if allow_deny is None:
24
+ return None
25
+ match category:
26
+ case ImportCategory.STANDARD_LIBRARY:
27
+ return allow_deny.standard_library
28
+ case ImportCategory.THIRD_PARTY:
29
+ return allow_deny.third_party
30
+ case ImportCategory.LOCAL:
31
+ return allow_deny.local
32
+
33
+
34
+ def _matches_pattern_or_submodule(pattern: str, module: str) -> bool:
35
+ """Return True if module matches pattern exactly or is a submodule of it."""
36
+ if matches_pattern(pattern, module):
37
+ return True
38
+ # Check if module starts with a prefix that matches the pattern.
39
+ # e.g. "contexts.*.domain" should match "contexts.boards.domain.models"
40
+ module_parts = module.split(".")
41
+ pattern_parts = pattern.split(".")
42
+ if len(module_parts) > len(pattern_parts):
43
+ prefix = ".".join(module_parts[: len(pattern_parts)])
44
+ if matches_pattern(pattern, prefix):
45
+ return True
46
+ # Literal prefix match (no wildcards in pattern)
47
+ if "*" not in pattern and module.startswith(pattern + "."):
48
+ return True
49
+ return False
50
+
51
+
52
+ def _is_in_list(module: str, patterns: list[str]) -> bool:
53
+ if "*" in patterns:
54
+ return True
55
+ return any(_matches_pattern_or_submodule(p, module) for p in patterns)
56
+
57
+
58
+ def check_import(
59
+ import_info: ImportInfo,
60
+ category: ImportCategory,
61
+ merged_rule: Rule | None,
62
+ source_module: str,
63
+ ) -> Violation | None:
64
+ if merged_rule is None:
65
+ return None
66
+
67
+ module = import_info.module
68
+
69
+ # Check deny first (deny takes priority over allow)
70
+ deny_list = _get_category_list(merged_rule.deny, category)
71
+ if deny_list is not None and _is_in_list(module, deny_list):
72
+ return Violation(
73
+ rule_name=merged_rule.name,
74
+ source_module=source_module,
75
+ imported_module=module,
76
+ category=category,
77
+ lineno=import_info.lineno,
78
+ )
79
+
80
+ # Check allow
81
+ allow_list = _get_category_list(merged_rule.allow, category)
82
+ if allow_list is None:
83
+ # No allow list for this category = allow all
84
+ return None
85
+ if _is_in_list(module, allow_list):
86
+ return None
87
+
88
+ return Violation(
89
+ rule_name=merged_rule.name,
90
+ source_module=source_module,
91
+ imported_module=module,
92
+ category=category,
93
+ lineno=import_info.lineno,
94
+ )
@@ -0,0 +1,96 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ import click
6
+
7
+ from python_dependency_linter.checker import check_import
8
+ from python_dependency_linter.config import load_config
9
+ from python_dependency_linter.matcher import find_matching_rules, merge_rules
10
+ from python_dependency_linter.parser import parse_imports
11
+ from python_dependency_linter.reporter import format_violations
12
+ from python_dependency_linter.resolver import resolve_import
13
+
14
+
15
+ def _file_to_module(file_path: Path, project_root: Path) -> str:
16
+ relative = file_path.relative_to(project_root)
17
+ parts = relative.with_suffix("").parts
18
+ if parts[-1] == "__init__":
19
+ parts = parts[:-1]
20
+ return ".".join(parts)
21
+
22
+
23
+ def _package_module(file_path: Path, project_root: Path) -> str:
24
+ """Return the package (directory) module path for a file.
25
+
26
+ For ``contexts/boards/domain/models.py`` this returns
27
+ ``contexts.boards.domain``, which is the module name to use when
28
+ matching against rule patterns like ``contexts.*.domain``.
29
+ """
30
+ relative = file_path.relative_to(project_root)
31
+ parts = relative.with_suffix("").parts
32
+ if parts[-1] != "__init__":
33
+ parts = parts[:-1]
34
+ else:
35
+ parts = parts[:-1]
36
+ return ".".join(parts)
37
+
38
+
39
+ def _find_python_files(project_root: Path) -> list[Path]:
40
+ return sorted(project_root.rglob("*.py"))
41
+
42
+
43
+ @click.group()
44
+ def main():
45
+ pass
46
+
47
+
48
+ @main.command()
49
+ @click.option(
50
+ "--config",
51
+ "config_path",
52
+ default=".python-dependency-linter.yaml",
53
+ help="Path to config file.",
54
+ )
55
+ @click.option("--project-root", default=".", help="Project root directory.")
56
+ def check(config_path: str, project_root: str):
57
+ root = Path(project_root).resolve()
58
+ config_file = Path(config_path)
59
+
60
+ try:
61
+ config = load_config(config_file)
62
+ except FileNotFoundError as e:
63
+ click.echo(f"Error: {e}", err=True)
64
+ raise SystemExit(1)
65
+
66
+ all_violations = []
67
+ python_files = _find_python_files(root)
68
+
69
+ for file_path in python_files:
70
+ module = _file_to_module(file_path, root)
71
+ package = _package_module(file_path, root)
72
+ matching_rules = find_matching_rules(package, config.rules)
73
+ if not matching_rules:
74
+ continue
75
+
76
+ merged_rule = merge_rules(matching_rules)
77
+ imports = parse_imports(file_path)
78
+
79
+ file_violations = []
80
+ for imp in imports:
81
+ category = resolve_import(imp.module, root)
82
+ violation = check_import(imp, category, merged_rule, module)
83
+ if violation is not None:
84
+ file_violations.append(violation)
85
+
86
+ if file_violations:
87
+ rel_path = str(file_path.relative_to(root))
88
+ output = format_violations(rel_path, file_violations)
89
+ click.echo(output)
90
+ all_violations.extend(file_violations)
91
+
92
+ if all_violations:
93
+ click.echo(f"Found {len(all_violations)} violation(s).")
94
+ raise SystemExit(1)
95
+ else:
96
+ click.echo("No violations found.")
@@ -0,0 +1,76 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+
6
+ import yaml
7
+
8
+
9
+ @dataclass
10
+ class AllowDeny:
11
+ standard_library: list[str] | None = None
12
+ third_party: list[str] | None = None
13
+ local: list[str] | None = None
14
+
15
+
16
+ @dataclass
17
+ class Rule:
18
+ name: str
19
+ modules: str
20
+ allow: AllowDeny | None = None
21
+ deny: AllowDeny | None = None
22
+
23
+
24
+ @dataclass
25
+ class Config:
26
+ rules: list[Rule]
27
+
28
+
29
+ def _parse_allow_deny(data: dict | None) -> AllowDeny | None:
30
+ if data is None:
31
+ return None
32
+ return AllowDeny(
33
+ standard_library=data.get("standard_library"),
34
+ third_party=data.get("third_party"),
35
+ local=data.get("local"),
36
+ )
37
+
38
+
39
+ def _parse_rules(rules_data: list[dict]) -> list[Rule]:
40
+ rules = []
41
+ for r in rules_data:
42
+ rules.append(
43
+ Rule(
44
+ name=r["name"],
45
+ modules=r["modules"],
46
+ allow=_parse_allow_deny(r.get("allow")),
47
+ deny=_parse_allow_deny(r.get("deny")),
48
+ )
49
+ )
50
+ return rules
51
+
52
+
53
+ def _load_yaml(path: Path) -> Config:
54
+ with open(path) as f:
55
+ data = yaml.safe_load(f)
56
+ return Config(rules=_parse_rules(data["rules"]))
57
+
58
+
59
+ def _load_pyproject_toml(path: Path) -> Config:
60
+ try:
61
+ import tomllib
62
+ except ImportError:
63
+ import tomli as tomllib # type: ignore[no-redef]
64
+
65
+ with open(path, "rb") as f:
66
+ data = tomllib.load(f)
67
+ tool_config = data["tool"]["python-dependency-linter"]
68
+ return Config(rules=_parse_rules(tool_config["rules"]))
69
+
70
+
71
+ def load_config(path: Path) -> Config:
72
+ if not path.exists():
73
+ raise FileNotFoundError(f"Config file not found: {path}")
74
+ if path.suffix == ".toml":
75
+ return _load_pyproject_toml(path)
76
+ return _load_yaml(path)
@@ -0,0 +1,61 @@
1
+ from __future__ import annotations
2
+
3
+ from python_dependency_linter.config import AllowDeny, Rule
4
+
5
+
6
+ def matches_pattern(pattern: str, module: str) -> bool:
7
+ pattern_parts = pattern.split(".")
8
+ module_parts = module.split(".")
9
+
10
+ if len(pattern_parts) != len(module_parts):
11
+ return False
12
+
13
+ for p, m in zip(pattern_parts, module_parts):
14
+ if p == "*":
15
+ continue
16
+ if p != m:
17
+ return False
18
+
19
+ return True
20
+
21
+
22
+ def find_matching_rules(module: str, rules: list[Rule]) -> list[Rule]:
23
+ return [r for r in rules if matches_pattern(r.modules, module)]
24
+
25
+
26
+ def _merge_allow_deny(
27
+ base: AllowDeny | None, override: AllowDeny | None
28
+ ) -> AllowDeny | None:
29
+ if base is None and override is None:
30
+ return None
31
+ if base is None:
32
+ return override
33
+ if override is None:
34
+ return base
35
+
36
+ def _merge_lists(a: list[str] | None, b: list[str] | None) -> list[str] | None:
37
+ if a is None and b is None:
38
+ return None
39
+ if a is None:
40
+ return b
41
+ if b is None:
42
+ return a
43
+ return list(set(a + b))
44
+
45
+ return AllowDeny(
46
+ standard_library=_merge_lists(base.standard_library, override.standard_library),
47
+ third_party=_merge_lists(base.third_party, override.third_party),
48
+ local=_merge_lists(base.local, override.local),
49
+ )
50
+
51
+
52
+ def merge_rules(rules: list[Rule]) -> Rule:
53
+ merged = rules[0]
54
+ for rule in rules[1:]:
55
+ merged = Rule(
56
+ name=merged.name,
57
+ modules=merged.modules,
58
+ allow=_merge_allow_deny(merged.allow, rule.allow),
59
+ deny=_merge_allow_deny(merged.deny, rule.deny),
60
+ )
61
+ return merged
@@ -0,0 +1,27 @@
1
+ from __future__ import annotations
2
+
3
+ import ast
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+
7
+
8
+ @dataclass(frozen=True)
9
+ class ImportInfo:
10
+ module: str
11
+ lineno: int
12
+
13
+
14
+ def parse_imports(file_path: Path) -> list[ImportInfo]:
15
+ source = file_path.read_text()
16
+ tree = ast.parse(source, filename=str(file_path))
17
+
18
+ imports = []
19
+ for node in ast.walk(tree):
20
+ if isinstance(node, ast.Import):
21
+ for alias in node.names:
22
+ imports.append(ImportInfo(module=alias.name, lineno=node.lineno))
23
+ elif isinstance(node, ast.ImportFrom):
24
+ if node.module is not None:
25
+ imports.append(ImportInfo(module=node.module, lineno=node.lineno))
26
+
27
+ return imports
@@ -0,0 +1,17 @@
1
+ from __future__ import annotations
2
+
3
+ from python_dependency_linter.checker import Violation
4
+
5
+
6
+ def format_violations(file_path: str, violations: list[Violation]) -> str:
7
+ if not violations:
8
+ return ""
9
+
10
+ lines = []
11
+ for v in violations:
12
+ lines.append(f"{file_path}:{v.lineno}")
13
+ arrow = f"{v.source_module} \u2192 {v.imported_module}"
14
+ lines.append(f" [{v.rule_name}] {arrow} ({v.category.value})")
15
+ lines.append("")
16
+
17
+ return "\n".join(lines)
@@ -0,0 +1,26 @@
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+ from enum import Enum
5
+ from pathlib import Path
6
+
7
+
8
+ class ImportCategory(Enum):
9
+ STANDARD_LIBRARY = "standard_library"
10
+ THIRD_PARTY = "third_party"
11
+ LOCAL = "local"
12
+
13
+
14
+ def resolve_import(module: str, project_root: Path) -> ImportCategory:
15
+ top_level = module.split(".")[0]
16
+
17
+ if top_level in sys.stdlib_module_names:
18
+ return ImportCategory.STANDARD_LIBRARY
19
+
20
+ # Check if it exists as a local module/package
21
+ if (project_root / top_level).is_dir() or (
22
+ project_root / f"{top_level}.py"
23
+ ).is_file():
24
+ return ImportCategory.LOCAL
25
+
26
+ return ImportCategory.THIRD_PARTY
@@ -0,0 +1,200 @@
1
+ Metadata-Version: 2.4
2
+ Name: python-dependency-linter
3
+ Version: 0.1.0
4
+ Summary: A dependency linter for Python projects
5
+ License-Expression: MIT
6
+ License-File: LICENSE
7
+ Classifier: Development Status :: 3 - Alpha
8
+ Classifier: Intended Audience :: Developers
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.10
12
+ Classifier: Programming Language :: Python :: 3.11
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: Programming Language :: Python :: 3.13
15
+ Classifier: Topic :: Software Development :: Quality Assurance
16
+ Requires-Python: >=3.10
17
+ Requires-Dist: click>=8.0
18
+ Requires-Dist: pyyaml>=6.0
19
+ Requires-Dist: tomli>=2.0; python_version < '3.11'
20
+ Provides-Extra: dev
21
+ Requires-Dist: pre-commit>=3.0; extra == 'dev'
22
+ Requires-Dist: pytest>=7.0; extra == 'dev'
23
+ Requires-Dist: ruff>=0.4; extra == 'dev'
24
+ Description-Content-Type: text/markdown
25
+
26
+ # python-dependency-linter
27
+
28
+ A dependency linter for Python projects. Define rules for which modules can depend on what, and catch violations.
29
+
30
+ ## Installation
31
+
32
+ ```bash
33
+ pip install python-dependency-linter
34
+ ```
35
+
36
+ Or with uv:
37
+
38
+ ```bash
39
+ uv add python-dependency-linter
40
+ ```
41
+
42
+ ## Quick Start
43
+
44
+ Create `.python-dependency-linter.yaml` in your project root:
45
+
46
+ ```yaml
47
+ rules:
48
+ - name: domain-isolation
49
+ modules: contexts.*.domain
50
+ allow:
51
+ standard_library: [dataclasses, typing]
52
+ third_party: [pydantic]
53
+ local: [contexts.*.domain]
54
+
55
+ - name: application-dependency
56
+ modules: contexts.*.application
57
+ allow:
58
+ standard_library: ["*"]
59
+ third_party: [pydantic]
60
+ local:
61
+ - contexts.*.application
62
+ - contexts.*.domain
63
+ ```
64
+
65
+ Run:
66
+
67
+ ```bash
68
+ pdl check
69
+ ```
70
+
71
+ Output:
72
+
73
+ ```
74
+ contexts/boards/domain/models.py:6
75
+ [domain-isolation] contexts.boards.domain.models → contexts.boards.application.service (local)
76
+
77
+ contexts/boards/domain/models.py:9
78
+ [domain-isolation] contexts.boards.domain.models → sqlalchemy (third_party)
79
+
80
+ Found 2 violation(s).
81
+ ```
82
+
83
+ ## Configuration
84
+
85
+ ### Rule Structure
86
+
87
+ Each rule has:
88
+
89
+ - `name` — Rule identifier, shown in violation output
90
+ - `modules` — Module pattern to apply the rule to (supports `*` wildcard)
91
+ - `allow` — Whitelist: only listed dependencies are allowed
92
+ - `deny` — Blacklist: listed dependencies are denied
93
+
94
+ ```yaml
95
+ rules:
96
+ - name: rule-name
97
+ modules: my_package.*.domain
98
+ allow:
99
+ standard_library: [dataclasses]
100
+ third_party: [pydantic]
101
+ local: [my_package.*.domain]
102
+ deny:
103
+ third_party: [boto3]
104
+ ```
105
+
106
+ ### Import Categories
107
+
108
+ Dependencies are classified into three categories (per PEP 8):
109
+
110
+ - `standard_library` — Python built-in modules (`os`, `sys`, `typing`, ...)
111
+ - `third_party` — Installed packages (`pydantic`, `sqlalchemy`, ...)
112
+ - `local` — Modules in your project
113
+
114
+ ### Behavior
115
+
116
+ - **No rule** — Everything is allowed
117
+ - **`allow` only** — Whitelist mode. Only listed dependencies are allowed
118
+ - **`deny` only** — Blacklist mode. Listed dependencies are denied, rest allowed
119
+ - **`allow` + `deny`** — Allow first, then deny removes exceptions
120
+ - If `allow` exists but a category is omitted, that category allows all
121
+
122
+ Use `"*"` to allow all within a category:
123
+
124
+ ```yaml
125
+ allow:
126
+ standard_library: ["*"] # allow all standard library imports
127
+ ```
128
+
129
+ ### Wildcard
130
+
131
+ `*` matches a single level in dotted module paths:
132
+
133
+ ```yaml
134
+ modules: contexts.*.domain # matches contexts.boards.domain, contexts.auth.domain, ...
135
+ ```
136
+
137
+ ### Rule Merging
138
+
139
+ When multiple rules match a module, they are merged. Specific rules override wildcard rules per field:
140
+
141
+ ```yaml
142
+ rules:
143
+ - name: base
144
+ modules: contexts.*.domain
145
+ allow:
146
+ third_party: [pydantic]
147
+
148
+ - name: boards-extra
149
+ modules: contexts.boards.domain
150
+ allow:
151
+ third_party: [attrs] # merged: [pydantic, attrs]
152
+ ```
153
+
154
+ ### pyproject.toml
155
+
156
+ You can also configure in `pyproject.toml`:
157
+
158
+ ```toml
159
+ [[tool.python-dependency-linter.rules]]
160
+ name = "domain-isolation"
161
+ modules = "contexts.*.domain"
162
+
163
+ [tool.python-dependency-linter.rules.allow]
164
+ standard_library = ["dataclasses", "typing"]
165
+ third_party = ["pydantic"]
166
+ local = ["contexts.*.domain"]
167
+ ```
168
+
169
+ ## CLI
170
+
171
+ ```bash
172
+ # Check with default config (.python-dependency-linter.yaml)
173
+ pdl check
174
+
175
+ # Specify config file
176
+ pdl check --config path/to/config.yaml
177
+
178
+ # Specify project root
179
+ pdl check --project-root path/to/project
180
+ ```
181
+
182
+ Exit codes:
183
+
184
+ - `0` — No violations
185
+ - `1` — Violations found
186
+
187
+ ## Pre-commit
188
+
189
+ Add to `.pre-commit-config.yaml`:
190
+
191
+ ```yaml
192
+ - repo: https://github.com/heumsi/python-dependency-linter
193
+ rev: v0.1.0
194
+ hooks:
195
+ - id: python-dependency-linter
196
+ ```
197
+
198
+ ## License
199
+
200
+ MIT
@@ -0,0 +1,13 @@
1
+ python_dependency_linter/__init__.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
2
+ python_dependency_linter/checker.py,sha256=haKQTsuhWJ7UQ72vB48WQD-xgwWwan5JUtoyrd0v2zk,2905
3
+ python_dependency_linter/cli.py,sha256=N36yqK-rIRM2U6Wpe1yJuPvH5vrxZLmdKpeN1vVAU7E,2994
4
+ python_dependency_linter/config.py,sha256=OModCwXwyC_fekgommY0IqjjfdEw7R6y-tRTMl6Bn1g,1794
5
+ python_dependency_linter/matcher.py,sha256=i__SuJ-KXoTb-tEtGW4F2d7Vmz2MSxls4eGBqolqQ0A,1696
6
+ python_dependency_linter/parser.py,sha256=5rHlogeT_BmuMfVjvCSDEl-z4peIYKEbc0ArEA1CIxA,729
7
+ python_dependency_linter/reporter.py,sha256=5sB1GVa601QW6xo5tc8_SdrRFfaAXvZ8ErbX708ej_A,490
8
+ python_dependency_linter/resolver.py,sha256=Upw8jgfhbk0A6PR36HcLfw3Q0Ao_qB2cYr4utwBht1g,654
9
+ python_dependency_linter-0.1.0.dist-info/METADATA,sha256=CPvtMPJlXHXJNKcu-1MvM4OvT0e1SbabO7_ZjVzXgYw,4630
10
+ python_dependency_linter-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
11
+ python_dependency_linter-0.1.0.dist-info/entry_points.txt,sha256=hBvZb-KbMnX7kylKQCUtJSYdunKRht18t5PH1GnRQKU,58
12
+ python_dependency_linter-0.1.0.dist-info/licenses/LICENSE,sha256=6jnaAo6a4d5VHjYOiCeh2k2tlMKfCXI4itz82GNbuMs,1063
13
+ python_dependency_linter-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ pdl = python_dependency_linter.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 heumsi
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.