python-dependency-linter 0.4.0__tar.gz → 0.5.0__tar.gz
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.
- {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.0}/CHANGELOG.md +9 -0
- {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.0}/PKG-INFO +40 -6
- {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.0}/README.md +39 -5
- {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.0}/python_dependency_linter/checker.py +40 -0
- {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.0}/python_dependency_linter/cli.py +7 -3
- {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.0}/python_dependency_linter/matcher.py +42 -7
- {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.0}/tests/test_checker.py +74 -1
- {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.0}/tests/test_matcher.py +99 -2
- {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.0}/.claude/skills/commit/SKILL.md +0 -0
- {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.0}/.claude/skills/release/SKILL.md +0 -0
- {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
- {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
- {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.0}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
- {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.0}/.github/dependabot.yml +0 -0
- {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.0}/.github/pull_request_template.md +0 -0
- {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.0}/.github/workflows/ci.yaml +0 -0
- {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.0}/.github/workflows/publish.yaml +0 -0
- {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.0}/.gitignore +0 -0
- {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.0}/.pre-commit-config.yaml +0 -0
- {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.0}/.pre-commit-hooks.yaml +0 -0
- {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.0}/CLAUDE.md +0 -0
- {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.0}/CONTRIBUTING.md +0 -0
- {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.0}/LICENSE +0 -0
- {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.0}/pyproject.toml +0 -0
- {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.0}/python_dependency_linter/__init__.py +0 -0
- {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.0}/python_dependency_linter/config.py +0 -0
- {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.0}/python_dependency_linter/parser.py +0 -0
- {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.0}/python_dependency_linter/reporter.py +0 -0
- {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.0}/python_dependency_linter/resolver.py +0 -0
- {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.0}/tests/fixtures/sample_config.yaml +0 -0
- {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.0}/tests/fixtures/sample_project/contexts/__init__.py +0 -0
- {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.0}/tests/fixtures/sample_project/contexts/auth/__init__.py +0 -0
- {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.0}/tests/fixtures/sample_project/contexts/auth/application/__init__.py +0 -0
- {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.0}/tests/fixtures/sample_project/contexts/auth/application/service.py +0 -0
- {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.0}/tests/fixtures/sample_project/contexts/auth/domain/__init__.py +0 -0
- {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.0}/tests/fixtures/sample_project/contexts/auth/domain/models.py +0 -0
- {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.0}/tests/fixtures/sample_project/contexts/boards/__init__.py +0 -0
- {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.0}/tests/fixtures/sample_project/contexts/boards/adapters/__init__.py +0 -0
- {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.0}/tests/fixtures/sample_project/contexts/boards/adapters/repository.py +0 -0
- {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.0}/tests/fixtures/sample_project/contexts/boards/application/__init__.py +0 -0
- {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.0}/tests/fixtures/sample_project/contexts/boards/application/service.py +0 -0
- {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.0}/tests/fixtures/sample_project/contexts/boards/domain/__init__.py +0 -0
- {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.0}/tests/fixtures/sample_project/contexts/boards/domain/models.py +0 -0
- {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.0}/tests/fixtures/sample_pyproject.toml +0 -0
- {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.0}/tests/test_cli.py +0 -0
- {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.0}/tests/test_config.py +0 -0
- {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.0}/tests/test_parser.py +0 -0
- {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.0}/tests/test_reporter.py +0 -0
- {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.0}/tests/test_resolver.py +0 -0
- {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.0}/uv.lock +0 -0
|
@@ -2,6 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## [0.4.0] - 2026-03-30
|
|
6
|
+
|
|
7
|
+
### Bug Fixes
|
|
8
|
+
|
|
9
|
+
- Use fnmatch for include/exclude pattern matching (#15)
|
|
10
|
+
|
|
11
|
+
### Features
|
|
12
|
+
|
|
13
|
+
- Remove --project-root, auto-detect from config file location (#14)
|
|
5
14
|
## [0.3.0] - 2026-03-30
|
|
6
15
|
|
|
7
16
|
### Bug Fixes
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-dependency-linter
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.0
|
|
4
4
|
Summary: A dependency linter for Python projects
|
|
5
5
|
License-Expression: MIT
|
|
6
6
|
License-File: LICENSE
|
|
@@ -127,25 +127,30 @@ rules:
|
|
|
127
127
|
|
|
128
128
|
Isolate domain from infrastructure. Ports (interfaces) live in domain, adapters depend on domain but not vice versa.
|
|
129
129
|
|
|
130
|
+
Using named captures (`{context}`), you can enforce that each bounded context only depends on its own domain — not other contexts' domains:
|
|
131
|
+
|
|
130
132
|
```yaml
|
|
131
133
|
rules:
|
|
132
134
|
- name: domain-no-infra
|
|
133
|
-
modules: contexts
|
|
135
|
+
modules: contexts.{context}.domain
|
|
134
136
|
allow:
|
|
135
137
|
standard_library: [dataclasses, typing, abc]
|
|
136
138
|
third_party: []
|
|
137
|
-
local: [contexts
|
|
139
|
+
local: [contexts.{context}.domain, shared.domain]
|
|
138
140
|
|
|
139
141
|
- name: adapters-depend-on-domain
|
|
140
|
-
modules: contexts
|
|
142
|
+
modules: contexts.{context}.adapters
|
|
141
143
|
allow:
|
|
142
144
|
standard_library: ["*"]
|
|
143
145
|
third_party: ["*"]
|
|
144
146
|
local:
|
|
145
|
-
- contexts
|
|
146
|
-
- contexts
|
|
147
|
+
- contexts.{context}.adapters
|
|
148
|
+
- contexts.{context}.domain
|
|
149
|
+
- shared
|
|
147
150
|
```
|
|
148
151
|
|
|
152
|
+
With `{context}`, `contexts.boards.domain` can only import from `contexts.boards.domain` and `shared.domain` — not from `contexts.auth.domain`. See [Named Capture](#named-capture) for details.
|
|
153
|
+
|
|
149
154
|
## Configuration
|
|
150
155
|
|
|
151
156
|
### Include / Exclude
|
|
@@ -247,6 +252,35 @@ modules: contexts.*.domain # matches contexts.boards.domain, contexts.auth.doma
|
|
|
247
252
|
modules: contexts.**.domain # matches contexts.boards.domain, contexts.boards.sub.domain, ...
|
|
248
253
|
```
|
|
249
254
|
|
|
255
|
+
### Named Capture
|
|
256
|
+
|
|
257
|
+
`{name}` captures a single level (like `*`) and allows back-referencing the captured value in `allow` and `deny`:
|
|
258
|
+
|
|
259
|
+
```yaml
|
|
260
|
+
rules:
|
|
261
|
+
- name: domain-isolation
|
|
262
|
+
modules: contexts.{context}.domain
|
|
263
|
+
allow:
|
|
264
|
+
local: [contexts.{context}.domain, shared.domain]
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
When this rule matches `contexts.boards.domain`, `{context}` captures `"boards"`. The `allow` pattern `contexts.{context}.domain` resolves to `contexts.boards.domain`, so only the same context's domain is allowed.
|
|
268
|
+
|
|
269
|
+
You can use multiple captures in a single rule:
|
|
270
|
+
|
|
271
|
+
```yaml
|
|
272
|
+
rules:
|
|
273
|
+
- name: bounded-context-layers
|
|
274
|
+
modules: contexts.{context}.{layer}
|
|
275
|
+
allow:
|
|
276
|
+
local:
|
|
277
|
+
- contexts.{context}.{layer}
|
|
278
|
+
- contexts.{context}.domain
|
|
279
|
+
- shared
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
Named captures coexist with `*` and `**` wildcards. `{name}` always matches exactly one level.
|
|
283
|
+
|
|
250
284
|
### Submodule Matching
|
|
251
285
|
|
|
252
286
|
When a pattern is used in `allow` or `deny`, it also matches submodules of the matched module. For example:
|
|
@@ -102,25 +102,30 @@ rules:
|
|
|
102
102
|
|
|
103
103
|
Isolate domain from infrastructure. Ports (interfaces) live in domain, adapters depend on domain but not vice versa.
|
|
104
104
|
|
|
105
|
+
Using named captures (`{context}`), you can enforce that each bounded context only depends on its own domain — not other contexts' domains:
|
|
106
|
+
|
|
105
107
|
```yaml
|
|
106
108
|
rules:
|
|
107
109
|
- name: domain-no-infra
|
|
108
|
-
modules: contexts
|
|
110
|
+
modules: contexts.{context}.domain
|
|
109
111
|
allow:
|
|
110
112
|
standard_library: [dataclasses, typing, abc]
|
|
111
113
|
third_party: []
|
|
112
|
-
local: [contexts
|
|
114
|
+
local: [contexts.{context}.domain, shared.domain]
|
|
113
115
|
|
|
114
116
|
- name: adapters-depend-on-domain
|
|
115
|
-
modules: contexts
|
|
117
|
+
modules: contexts.{context}.adapters
|
|
116
118
|
allow:
|
|
117
119
|
standard_library: ["*"]
|
|
118
120
|
third_party: ["*"]
|
|
119
121
|
local:
|
|
120
|
-
- contexts
|
|
121
|
-
- contexts
|
|
122
|
+
- contexts.{context}.adapters
|
|
123
|
+
- contexts.{context}.domain
|
|
124
|
+
- shared
|
|
122
125
|
```
|
|
123
126
|
|
|
127
|
+
With `{context}`, `contexts.boards.domain` can only import from `contexts.boards.domain` and `shared.domain` — not from `contexts.auth.domain`. See [Named Capture](#named-capture) for details.
|
|
128
|
+
|
|
124
129
|
## Configuration
|
|
125
130
|
|
|
126
131
|
### Include / Exclude
|
|
@@ -222,6 +227,35 @@ modules: contexts.*.domain # matches contexts.boards.domain, contexts.auth.doma
|
|
|
222
227
|
modules: contexts.**.domain # matches contexts.boards.domain, contexts.boards.sub.domain, ...
|
|
223
228
|
```
|
|
224
229
|
|
|
230
|
+
### Named Capture
|
|
231
|
+
|
|
232
|
+
`{name}` captures a single level (like `*`) and allows back-referencing the captured value in `allow` and `deny`:
|
|
233
|
+
|
|
234
|
+
```yaml
|
|
235
|
+
rules:
|
|
236
|
+
- name: domain-isolation
|
|
237
|
+
modules: contexts.{context}.domain
|
|
238
|
+
allow:
|
|
239
|
+
local: [contexts.{context}.domain, shared.domain]
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
When this rule matches `contexts.boards.domain`, `{context}` captures `"boards"`. The `allow` pattern `contexts.{context}.domain` resolves to `contexts.boards.domain`, so only the same context's domain is allowed.
|
|
243
|
+
|
|
244
|
+
You can use multiple captures in a single rule:
|
|
245
|
+
|
|
246
|
+
```yaml
|
|
247
|
+
rules:
|
|
248
|
+
- name: bounded-context-layers
|
|
249
|
+
modules: contexts.{context}.{layer}
|
|
250
|
+
allow:
|
|
251
|
+
local:
|
|
252
|
+
- contexts.{context}.{layer}
|
|
253
|
+
- contexts.{context}.domain
|
|
254
|
+
- shared
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
Named captures coexist with `*` and `**` wildcards. `{name}` always matches exactly one level.
|
|
258
|
+
|
|
225
259
|
### Submodule Matching
|
|
226
260
|
|
|
227
261
|
When a pattern is used in `allow` or `deny`, it also matches submodules of the matched module. For example:
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import re
|
|
3
4
|
from dataclasses import dataclass
|
|
4
5
|
|
|
5
6
|
from python_dependency_linter.config import AllowDeny, Rule
|
|
@@ -7,6 +8,36 @@ from python_dependency_linter.matcher import matches_pattern
|
|
|
7
8
|
from python_dependency_linter.parser import ImportInfo
|
|
8
9
|
from python_dependency_linter.resolver import ImportCategory
|
|
9
10
|
|
|
11
|
+
_CAPTURE_RE = re.compile(r"\{(\w+)\}")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def resolve_captures(pattern: str, captures: dict[str, str]) -> str:
|
|
15
|
+
def _replace(m: re.Match) -> str:
|
|
16
|
+
name = m.group(1)
|
|
17
|
+
return captures.get(name, m.group(0))
|
|
18
|
+
|
|
19
|
+
return _CAPTURE_RE.sub(_replace, pattern)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _resolve_list(
|
|
23
|
+
patterns: list[str] | None, captures: dict[str, str]
|
|
24
|
+
) -> list[str] | None:
|
|
25
|
+
if patterns is None:
|
|
26
|
+
return None
|
|
27
|
+
return [resolve_captures(p, captures) for p in patterns]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _resolve_allow_deny(
|
|
31
|
+
allow_deny: AllowDeny | None, captures: dict[str, str]
|
|
32
|
+
) -> AllowDeny | None:
|
|
33
|
+
if allow_deny is None:
|
|
34
|
+
return None
|
|
35
|
+
return AllowDeny(
|
|
36
|
+
standard_library=_resolve_list(allow_deny.standard_library, captures),
|
|
37
|
+
third_party=_resolve_list(allow_deny.third_party, captures),
|
|
38
|
+
local=_resolve_list(allow_deny.local, captures),
|
|
39
|
+
)
|
|
40
|
+
|
|
10
41
|
|
|
11
42
|
@dataclass
|
|
12
43
|
class Violation:
|
|
@@ -60,10 +91,19 @@ def check_import(
|
|
|
60
91
|
category: ImportCategory,
|
|
61
92
|
merged_rule: Rule | None,
|
|
62
93
|
source_module: str,
|
|
94
|
+
captures: dict[str, str] | None = None,
|
|
63
95
|
) -> Violation | None:
|
|
64
96
|
if merged_rule is None:
|
|
65
97
|
return None
|
|
66
98
|
|
|
99
|
+
if captures:
|
|
100
|
+
merged_rule = Rule(
|
|
101
|
+
name=merged_rule.name,
|
|
102
|
+
modules=merged_rule.modules,
|
|
103
|
+
allow=_resolve_allow_deny(merged_rule.allow, captures),
|
|
104
|
+
deny=_resolve_allow_deny(merged_rule.deny, captures),
|
|
105
|
+
)
|
|
106
|
+
|
|
67
107
|
module = import_info.module
|
|
68
108
|
|
|
69
109
|
# Check deny first (deny takes priority over allow)
|
{python_dependency_linter-0.4.0 → python_dependency_linter-0.5.0}/python_dependency_linter/cli.py
RENAMED
|
@@ -115,17 +115,21 @@ def check(config_path: str | None):
|
|
|
115
115
|
for file_path in python_files:
|
|
116
116
|
module = _file_to_module(file_path, root)
|
|
117
117
|
package = _package_module(file_path, root)
|
|
118
|
-
|
|
119
|
-
if not
|
|
118
|
+
matching = find_matching_rules(package, config.rules)
|
|
119
|
+
if not matching:
|
|
120
120
|
continue
|
|
121
121
|
|
|
122
|
+
matching_rules = [r for r, _ in matching]
|
|
123
|
+
captures: dict[str, str] = {}
|
|
124
|
+
for _, c in matching:
|
|
125
|
+
captures.update(c)
|
|
122
126
|
merged_rule = merge_rules(matching_rules)
|
|
123
127
|
imports = parse_imports(file_path, root)
|
|
124
128
|
|
|
125
129
|
file_violations = []
|
|
126
130
|
for imp in imports:
|
|
127
131
|
category = resolve_import(imp.module, root)
|
|
128
|
-
violation = check_import(imp, category, merged_rule, module)
|
|
132
|
+
violation = check_import(imp, category, merged_rule, module, captures)
|
|
129
133
|
if violation is not None:
|
|
130
134
|
file_violations.append(violation)
|
|
131
135
|
|
|
@@ -1,38 +1,73 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import re
|
|
4
|
+
|
|
3
5
|
from python_dependency_linter.config import AllowDeny, Rule
|
|
4
6
|
|
|
7
|
+
_CAPTURE_RE = re.compile(r"^\{(\w+)\}$")
|
|
8
|
+
|
|
5
9
|
|
|
6
10
|
def matches_pattern(pattern: str, module: str) -> bool:
|
|
11
|
+
return match_pattern_with_captures(pattern, module) is not None
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def match_pattern_with_captures(pattern: str, module: str) -> dict[str, str] | None:
|
|
7
15
|
pattern_parts = pattern.split(".")
|
|
8
16
|
module_parts = module.split(".")
|
|
9
|
-
|
|
17
|
+
captures: dict[str, str] = {}
|
|
18
|
+
if _match_with_captures(pattern_parts, module_parts, captures):
|
|
19
|
+
return captures
|
|
20
|
+
return None
|
|
10
21
|
|
|
11
22
|
|
|
12
|
-
def
|
|
23
|
+
def _match_with_captures(
|
|
24
|
+
pattern_parts: list[str],
|
|
25
|
+
module_parts: list[str],
|
|
26
|
+
captures: dict[str, str],
|
|
27
|
+
) -> bool:
|
|
13
28
|
if not pattern_parts and not module_parts:
|
|
14
29
|
return True
|
|
15
30
|
if not pattern_parts:
|
|
16
31
|
return False
|
|
17
32
|
|
|
18
33
|
if pattern_parts[0] == "**":
|
|
19
|
-
# "**" matches one or more parts
|
|
20
34
|
for i in range(1, len(module_parts) + 1):
|
|
21
|
-
|
|
35
|
+
snapshot = dict(captures)
|
|
36
|
+
if _match_with_captures(pattern_parts[1:], module_parts[i:], captures):
|
|
22
37
|
return True
|
|
38
|
+
captures.clear()
|
|
39
|
+
captures.update(snapshot)
|
|
23
40
|
return False
|
|
24
41
|
|
|
25
42
|
if not module_parts:
|
|
26
43
|
return False
|
|
27
44
|
|
|
45
|
+
m = _CAPTURE_RE.match(pattern_parts[0])
|
|
46
|
+
if m:
|
|
47
|
+
name = m.group(1)
|
|
48
|
+
value = module_parts[0]
|
|
49
|
+
if name in captures:
|
|
50
|
+
if captures[name] != value:
|
|
51
|
+
return False
|
|
52
|
+
else:
|
|
53
|
+
captures[name] = value
|
|
54
|
+
return _match_with_captures(pattern_parts[1:], module_parts[1:], captures)
|
|
55
|
+
|
|
28
56
|
if pattern_parts[0] == "*" or pattern_parts[0] == module_parts[0]:
|
|
29
|
-
return
|
|
57
|
+
return _match_with_captures(pattern_parts[1:], module_parts[1:], captures)
|
|
30
58
|
|
|
31
59
|
return False
|
|
32
60
|
|
|
33
61
|
|
|
34
|
-
def find_matching_rules(
|
|
35
|
-
|
|
62
|
+
def find_matching_rules(
|
|
63
|
+
module: str, rules: list[Rule]
|
|
64
|
+
) -> list[tuple[Rule, dict[str, str]]]:
|
|
65
|
+
result = []
|
|
66
|
+
for r in rules:
|
|
67
|
+
captures = match_pattern_with_captures(r.modules, module)
|
|
68
|
+
if captures is not None:
|
|
69
|
+
result.append((r, captures))
|
|
70
|
+
return result
|
|
36
71
|
|
|
37
72
|
|
|
38
73
|
def _merge_allow_deny(
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
from python_dependency_linter.checker import Violation, check_import
|
|
1
|
+
from python_dependency_linter.checker import Violation, check_import, resolve_captures
|
|
2
2
|
from python_dependency_linter.config import AllowDeny, Rule
|
|
3
3
|
from python_dependency_linter.parser import ImportInfo
|
|
4
4
|
from python_dependency_linter.resolver import ImportCategory
|
|
@@ -150,3 +150,76 @@ def test_no_allow_for_category_means_allow_all():
|
|
|
150
150
|
source_module="contexts.boards.domain",
|
|
151
151
|
)
|
|
152
152
|
assert result is None
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def test_resolve_captures_single():
|
|
156
|
+
result = resolve_captures("src.contexts.{context}.domain", {"context": "analytics"})
|
|
157
|
+
assert result == "src.contexts.analytics.domain"
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def test_resolve_captures_multiple():
|
|
161
|
+
result = resolve_captures(
|
|
162
|
+
"src.{ctx}.adapters.{dir}", {"ctx": "auth", "dir": "inbound"}
|
|
163
|
+
)
|
|
164
|
+
assert result == "src.auth.adapters.inbound"
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def test_resolve_captures_no_placeholders():
|
|
168
|
+
result = resolve_captures("src.shared.domain", {"context": "analytics"})
|
|
169
|
+
assert result == "src.shared.domain"
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def test_resolve_captures_unresolved_placeholder():
|
|
173
|
+
result = resolve_captures("src.{unknown}.domain", {"context": "analytics"})
|
|
174
|
+
assert result == "src.{unknown}.domain"
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def test_cross_context_isolation_allowed():
|
|
178
|
+
"""Same context's domain import should be allowed."""
|
|
179
|
+
rule = Rule(
|
|
180
|
+
name="domain-layer",
|
|
181
|
+
modules="contexts.{context}.domain",
|
|
182
|
+
allow=AllowDeny(local=["contexts.{context}.domain", "shared.domain"]),
|
|
183
|
+
)
|
|
184
|
+
result = check_import(
|
|
185
|
+
import_info=ImportInfo(module="contexts.boards.domain.models", lineno=1),
|
|
186
|
+
category=ImportCategory.LOCAL,
|
|
187
|
+
merged_rule=rule,
|
|
188
|
+
source_module="contexts.boards.domain",
|
|
189
|
+
captures={"context": "boards"},
|
|
190
|
+
)
|
|
191
|
+
assert result is None
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def test_cross_context_isolation_violation():
|
|
195
|
+
"""Different context's domain import should be denied."""
|
|
196
|
+
rule = Rule(
|
|
197
|
+
name="domain-layer",
|
|
198
|
+
modules="contexts.{context}.domain",
|
|
199
|
+
allow=AllowDeny(local=["contexts.{context}.domain", "shared.domain"]),
|
|
200
|
+
)
|
|
201
|
+
result = check_import(
|
|
202
|
+
import_info=ImportInfo(module="contexts.auth.domain.models", lineno=5),
|
|
203
|
+
category=ImportCategory.LOCAL,
|
|
204
|
+
merged_rule=rule,
|
|
205
|
+
source_module="contexts.boards.domain",
|
|
206
|
+
captures={"context": "boards"},
|
|
207
|
+
)
|
|
208
|
+
assert isinstance(result, Violation)
|
|
209
|
+
assert result.imported_module == "contexts.auth.domain.models"
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def test_check_import_no_captures_backward_compat():
|
|
213
|
+
"""Existing behavior works when no captures provided."""
|
|
214
|
+
rule = Rule(
|
|
215
|
+
name="domain-isolation",
|
|
216
|
+
modules="contexts.*.domain",
|
|
217
|
+
allow=AllowDeny(third_party=["pydantic"]),
|
|
218
|
+
)
|
|
219
|
+
result = check_import(
|
|
220
|
+
import_info=ImportInfo(module="pydantic", lineno=1),
|
|
221
|
+
category=ImportCategory.THIRD_PARTY,
|
|
222
|
+
merged_rule=rule,
|
|
223
|
+
source_module="contexts.boards.domain",
|
|
224
|
+
)
|
|
225
|
+
assert result is None
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
from python_dependency_linter.config import AllowDeny, Rule
|
|
2
2
|
from python_dependency_linter.matcher import (
|
|
3
3
|
find_matching_rules,
|
|
4
|
+
match_pattern_with_captures,
|
|
4
5
|
matches_pattern,
|
|
5
6
|
merge_rules,
|
|
6
7
|
)
|
|
@@ -73,8 +74,8 @@ def test_find_matching_rules():
|
|
|
73
74
|
]
|
|
74
75
|
matched = find_matching_rules("contexts.boards.domain", rules)
|
|
75
76
|
assert len(matched) == 2
|
|
76
|
-
assert matched[0].name == "r1"
|
|
77
|
-
assert matched[1].name == "r2"
|
|
77
|
+
assert matched[0][0].name == "r1"
|
|
78
|
+
assert matched[1][0].name == "r2"
|
|
78
79
|
|
|
79
80
|
|
|
80
81
|
def test_merge_rules_merges_allow():
|
|
@@ -117,3 +118,99 @@ def test_merge_rules_merges_deny():
|
|
|
117
118
|
merged = merge_rules([rule1, rule2])
|
|
118
119
|
|
|
119
120
|
assert sorted(merged.deny.third_party) == ["boto3", "requests"]
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def test_capture_single():
|
|
124
|
+
result = match_pattern_with_captures(
|
|
125
|
+
"src.contexts.{context}.domain", "src.contexts.analytics.domain"
|
|
126
|
+
)
|
|
127
|
+
assert result == {"context": "analytics"}
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def test_capture_multiple():
|
|
131
|
+
result = match_pattern_with_captures(
|
|
132
|
+
"src.contexts.{ctx}.adapters.{dir}", "src.contexts.auth.adapters.inbound"
|
|
133
|
+
)
|
|
134
|
+
assert result == {"ctx": "auth", "dir": "inbound"}
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def test_capture_duplicate_name_consistent():
|
|
138
|
+
result = match_pattern_with_captures("src.{a}.middle.{a}", "src.foo.middle.foo")
|
|
139
|
+
assert result == {"a": "foo"}
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def test_capture_duplicate_name_inconsistent():
|
|
143
|
+
result = match_pattern_with_captures("src.{a}.middle.{a}", "src.foo.middle.bar")
|
|
144
|
+
assert result is None
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def test_capture_no_match():
|
|
148
|
+
result = match_pattern_with_captures(
|
|
149
|
+
"src.contexts.{context}.domain", "src.utils.helpers"
|
|
150
|
+
)
|
|
151
|
+
assert result is None
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def test_capture_no_captures_with_star():
|
|
155
|
+
result = match_pattern_with_captures("src.*.domain", "src.analytics.domain")
|
|
156
|
+
assert result == {}
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def test_capture_coexist_with_star():
|
|
160
|
+
result = match_pattern_with_captures(
|
|
161
|
+
"src.{ctx}.*.domain", "src.auth.adapters.domain"
|
|
162
|
+
)
|
|
163
|
+
assert result == {"ctx": "auth"}
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def test_capture_coexist_with_double_star():
|
|
167
|
+
result = match_pattern_with_captures(
|
|
168
|
+
"src.{ctx}.**.domain", "src.auth.deep.nested.domain"
|
|
169
|
+
)
|
|
170
|
+
assert result == {"ctx": "auth"}
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def test_capture_exact_no_wildcards():
|
|
174
|
+
result = match_pattern_with_captures(
|
|
175
|
+
"src.contexts.analytics.domain", "src.contexts.analytics.domain"
|
|
176
|
+
)
|
|
177
|
+
assert result == {}
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def test_capture_exact_no_wildcards_no_match():
|
|
181
|
+
result = match_pattern_with_captures(
|
|
182
|
+
"src.contexts.analytics.domain", "src.contexts.auth.domain"
|
|
183
|
+
)
|
|
184
|
+
assert result is None
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def test_find_matching_rules_with_captures():
|
|
188
|
+
rules = [
|
|
189
|
+
Rule(
|
|
190
|
+
name="domain-layer",
|
|
191
|
+
modules="contexts.{context}.domain",
|
|
192
|
+
allow=AllowDeny(local=["contexts.{context}.domain"]),
|
|
193
|
+
),
|
|
194
|
+
Rule(
|
|
195
|
+
name="adapters",
|
|
196
|
+
modules="contexts.*.adapters",
|
|
197
|
+
deny=AllowDeny(third_party=["boto3"]),
|
|
198
|
+
),
|
|
199
|
+
]
|
|
200
|
+
matched = find_matching_rules("contexts.boards.domain", rules)
|
|
201
|
+
assert len(matched) == 1
|
|
202
|
+
rule, captures = matched[0]
|
|
203
|
+
assert rule.name == "domain-layer"
|
|
204
|
+
assert captures == {"context": "boards"}
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def test_capture_after_double_star():
|
|
208
|
+
result = match_pattern_with_captures(
|
|
209
|
+
"src.**.{layer}.models", "src.deep.nested.domain.models"
|
|
210
|
+
)
|
|
211
|
+
assert result == {"layer": "domain"}
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def test_capture_after_double_star_backtrack():
|
|
215
|
+
result = match_pattern_with_captures("**.{x}.end", "a.b.c.end")
|
|
216
|
+
assert result == {"x": "c"}
|
{python_dependency_linter-0.4.0 → python_dependency_linter-0.5.0}/.claude/skills/commit/SKILL.md
RENAMED
|
File without changes
|
{python_dependency_linter-0.4.0 → python_dependency_linter-0.5.0}/.claude/skills/release/SKILL.md
RENAMED
|
File without changes
|
|
File without changes
|
{python_dependency_linter-0.4.0 → python_dependency_linter-0.5.0}/.github/ISSUE_TEMPLATE/config.yml
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_dependency_linter-0.4.0 → python_dependency_linter-0.5.0}/.github/pull_request_template.md
RENAMED
|
File without changes
|
|
File without changes
|
{python_dependency_linter-0.4.0 → python_dependency_linter-0.5.0}/.github/workflows/publish.yaml
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_dependency_linter-0.4.0 → python_dependency_linter-0.5.0}/python_dependency_linter/config.py
RENAMED
|
File without changes
|
{python_dependency_linter-0.4.0 → python_dependency_linter-0.5.0}/python_dependency_linter/parser.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_dependency_linter-0.4.0 → python_dependency_linter-0.5.0}/tests/fixtures/sample_config.yaml
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|