python-dependency-linter 0.4.0__tar.gz → 0.5.1__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.
Files changed (52) hide show
  1. {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.1}/CHANGELOG.md +14 -0
  2. {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.1}/PKG-INFO +51 -12
  3. {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.1}/README.md +50 -11
  4. {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.1}/python_dependency_linter/checker.py +40 -0
  5. {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.1}/python_dependency_linter/cli.py +7 -3
  6. python_dependency_linter-0.5.1/python_dependency_linter/matcher.py +127 -0
  7. {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.1}/tests/test_checker.py +74 -1
  8. python_dependency_linter-0.5.1/tests/test_matcher.py +275 -0
  9. python_dependency_linter-0.4.0/python_dependency_linter/matcher.py +0 -73
  10. python_dependency_linter-0.4.0/tests/test_matcher.py +0 -119
  11. {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.1}/.claude/skills/commit/SKILL.md +0 -0
  12. {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.1}/.claude/skills/release/SKILL.md +0 -0
  13. {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.1}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  14. {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.1}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  15. {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.1}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  16. {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.1}/.github/dependabot.yml +0 -0
  17. {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.1}/.github/pull_request_template.md +0 -0
  18. {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.1}/.github/workflows/ci.yaml +0 -0
  19. {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.1}/.github/workflows/publish.yaml +0 -0
  20. {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.1}/.gitignore +0 -0
  21. {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.1}/.pre-commit-config.yaml +0 -0
  22. {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.1}/.pre-commit-hooks.yaml +0 -0
  23. {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.1}/CLAUDE.md +0 -0
  24. {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.1}/CONTRIBUTING.md +0 -0
  25. {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.1}/LICENSE +0 -0
  26. {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.1}/pyproject.toml +0 -0
  27. {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.1}/python_dependency_linter/__init__.py +0 -0
  28. {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.1}/python_dependency_linter/config.py +0 -0
  29. {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.1}/python_dependency_linter/parser.py +0 -0
  30. {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.1}/python_dependency_linter/reporter.py +0 -0
  31. {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.1}/python_dependency_linter/resolver.py +0 -0
  32. {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.1}/tests/fixtures/sample_config.yaml +0 -0
  33. {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.1}/tests/fixtures/sample_project/contexts/__init__.py +0 -0
  34. {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.1}/tests/fixtures/sample_project/contexts/auth/__init__.py +0 -0
  35. {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.1}/tests/fixtures/sample_project/contexts/auth/application/__init__.py +0 -0
  36. {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.1}/tests/fixtures/sample_project/contexts/auth/application/service.py +0 -0
  37. {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.1}/tests/fixtures/sample_project/contexts/auth/domain/__init__.py +0 -0
  38. {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.1}/tests/fixtures/sample_project/contexts/auth/domain/models.py +0 -0
  39. {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.1}/tests/fixtures/sample_project/contexts/boards/__init__.py +0 -0
  40. {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.1}/tests/fixtures/sample_project/contexts/boards/adapters/__init__.py +0 -0
  41. {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.1}/tests/fixtures/sample_project/contexts/boards/adapters/repository.py +0 -0
  42. {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.1}/tests/fixtures/sample_project/contexts/boards/application/__init__.py +0 -0
  43. {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.1}/tests/fixtures/sample_project/contexts/boards/application/service.py +0 -0
  44. {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.1}/tests/fixtures/sample_project/contexts/boards/domain/__init__.py +0 -0
  45. {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.1}/tests/fixtures/sample_project/contexts/boards/domain/models.py +0 -0
  46. {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.1}/tests/fixtures/sample_pyproject.toml +0 -0
  47. {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.1}/tests/test_cli.py +0 -0
  48. {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.1}/tests/test_config.py +0 -0
  49. {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.1}/tests/test_parser.py +0 -0
  50. {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.1}/tests/test_reporter.py +0 -0
  51. {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.1}/tests/test_resolver.py +0 -0
  52. {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.1}/uv.lock +0 -0
@@ -2,6 +2,20 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [0.5.0] - 2026-03-30
6
+
7
+ ### Features
8
+
9
+ - Add named capture in module patterns for back-referencing in allow/deny (#17)
10
+ ## [0.4.0] - 2026-03-30
11
+
12
+ ### Bug Fixes
13
+
14
+ - Use fnmatch for include/exclude pattern matching (#15)
15
+
16
+ ### Features
17
+
18
+ - Remove --project-root, auto-detect from config file location (#14)
5
19
  ## [0.3.0] - 2026-03-30
6
20
 
7
21
  ### Bug Fixes
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-dependency-linter
3
- Version: 0.4.0
3
+ Version: 0.5.1
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.*.domain
135
+ modules: contexts.{context}.domain
134
136
  allow:
135
137
  standard_library: [dataclasses, typing, abc]
136
138
  third_party: []
137
- local: [contexts.*.domain]
139
+ local: [contexts.{context}.domain, shared.domain]
138
140
 
139
141
  - name: adapters-depend-on-domain
140
- modules: contexts.*.adapters
142
+ modules: contexts.{context}.adapters
141
143
  allow:
142
144
  standard_library: ["*"]
143
145
  third_party: ["*"]
144
146
  local:
145
- - contexts.*.adapters
146
- - contexts.*.domain
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,16 +252,50 @@ 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
- When a pattern is used in `allow` or `deny`, it also matches submodules of the matched module. For example:
286
+ When a pattern is used in `modules`, `allow`, or `deny`, it also matches submodules of the matched module.
287
+
288
+ For example, the following rule applies to `contexts.boards.domain` as well as its submodules like `contexts.boards.domain.models` or `contexts.boards.domain.entities.metric`:
253
289
 
254
290
  ```yaml
255
- allow:
256
- local: [contexts.*.domain]
291
+ rules:
292
+ - name: domain-layer
293
+ modules: contexts.*.domain
294
+ allow:
295
+ local: [contexts.*.domain]
257
296
  ```
258
297
 
259
- This allows imports of `contexts.boards.domain` as well as its submodules like `contexts.boards.domain.models` or `contexts.boards.domain.entities.metric`.
298
+ > **Note:** `contexts.*.domain` matches the module itself (`__init__.py`) **and** all submodules beneath it, while `contexts.*.domain.**` matches submodules only.
260
299
 
261
300
  ### Rule Merging
262
301
 
@@ -334,7 +373,7 @@ Add to `.pre-commit-config.yaml`:
334
373
 
335
374
  ```yaml
336
375
  - repo: https://github.com/heumsi/python-dependency-linter
337
- rev: v0.1.0
376
+ rev: '' # Use the tag you want to point at (e.g., v0.5.0)
338
377
  hooks:
339
378
  - id: python-dependency-linter
340
379
  ```
@@ -343,7 +382,7 @@ To pass custom options (e.g., a different config file):
343
382
 
344
383
  ```yaml
345
384
  - repo: https://github.com/heumsi/python-dependency-linter
346
- rev: v0.1.0
385
+ rev: '' # Use the tag you want to point at (e.g., v0.5.0)
347
386
  hooks:
348
387
  - id: python-dependency-linter
349
388
  args: [--config, custom-config.yaml]
@@ -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.*.domain
110
+ modules: contexts.{context}.domain
109
111
  allow:
110
112
  standard_library: [dataclasses, typing, abc]
111
113
  third_party: []
112
- local: [contexts.*.domain]
114
+ local: [contexts.{context}.domain, shared.domain]
113
115
 
114
116
  - name: adapters-depend-on-domain
115
- modules: contexts.*.adapters
117
+ modules: contexts.{context}.adapters
116
118
  allow:
117
119
  standard_library: ["*"]
118
120
  third_party: ["*"]
119
121
  local:
120
- - contexts.*.adapters
121
- - contexts.*.domain
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,16 +227,50 @@ 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
- When a pattern is used in `allow` or `deny`, it also matches submodules of the matched module. For example:
261
+ When a pattern is used in `modules`, `allow`, or `deny`, it also matches submodules of the matched module.
262
+
263
+ For example, the following rule applies to `contexts.boards.domain` as well as its submodules like `contexts.boards.domain.models` or `contexts.boards.domain.entities.metric`:
228
264
 
229
265
  ```yaml
230
- allow:
231
- local: [contexts.*.domain]
266
+ rules:
267
+ - name: domain-layer
268
+ modules: contexts.*.domain
269
+ allow:
270
+ local: [contexts.*.domain]
232
271
  ```
233
272
 
234
- This allows imports of `contexts.boards.domain` as well as its submodules like `contexts.boards.domain.models` or `contexts.boards.domain.entities.metric`.
273
+ > **Note:** `contexts.*.domain` matches the module itself (`__init__.py`) **and** all submodules beneath it, while `contexts.*.domain.**` matches submodules only.
235
274
 
236
275
  ### Rule Merging
237
276
 
@@ -309,7 +348,7 @@ Add to `.pre-commit-config.yaml`:
309
348
 
310
349
  ```yaml
311
350
  - repo: https://github.com/heumsi/python-dependency-linter
312
- rev: v0.1.0
351
+ rev: '' # Use the tag you want to point at (e.g., v0.5.0)
313
352
  hooks:
314
353
  - id: python-dependency-linter
315
354
  ```
@@ -318,7 +357,7 @@ To pass custom options (e.g., a different config file):
318
357
 
319
358
  ```yaml
320
359
  - repo: https://github.com/heumsi/python-dependency-linter
321
- rev: v0.1.0
360
+ rev: '' # Use the tag you want to point at (e.g., v0.5.0)
322
361
  hooks:
323
362
  - id: python-dependency-linter
324
363
  args: [--config, custom-config.yaml]
@@ -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)
@@ -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
- matching_rules = find_matching_rules(package, config.rules)
119
- if not matching_rules:
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
 
@@ -0,0 +1,127 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+
5
+ from python_dependency_linter.config import AllowDeny, Rule
6
+
7
+ _CAPTURE_RE = re.compile(r"^\{(\w+)\}$")
8
+
9
+
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:
15
+ pattern_parts = pattern.split(".")
16
+ module_parts = module.split(".")
17
+ captures: dict[str, str] = {}
18
+ if _match_with_captures(pattern_parts, module_parts, captures):
19
+ return captures
20
+ return None
21
+
22
+
23
+ def _match_with_captures(
24
+ pattern_parts: list[str],
25
+ module_parts: list[str],
26
+ captures: dict[str, str],
27
+ ) -> bool:
28
+ if not pattern_parts and not module_parts:
29
+ return True
30
+ if not pattern_parts:
31
+ return False
32
+
33
+ if pattern_parts[0] == "**":
34
+ for i in range(1, len(module_parts) + 1):
35
+ snapshot = dict(captures)
36
+ if _match_with_captures(pattern_parts[1:], module_parts[i:], captures):
37
+ return True
38
+ captures.clear()
39
+ captures.update(snapshot)
40
+ return False
41
+
42
+ if not module_parts:
43
+ return False
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
+
56
+ if pattern_parts[0] == "*" or pattern_parts[0] == module_parts[0]:
57
+ return _match_with_captures(pattern_parts[1:], module_parts[1:], captures)
58
+
59
+ return False
60
+
61
+
62
+ def match_pattern_with_captures_or_submodule(
63
+ pattern: str, module: str
64
+ ) -> dict[str, str] | None:
65
+ """Match pattern exactly or treat module as a submodule of the pattern."""
66
+ captures = match_pattern_with_captures(pattern, module)
67
+ if captures is not None:
68
+ return captures
69
+ # Check if a prefix of the module matches the pattern.
70
+ # e.g. "contexts.*.domain" should match "contexts.boards.domain.models"
71
+ module_parts = module.split(".")
72
+ pattern_parts = pattern.split(".")
73
+ if len(module_parts) > len(pattern_parts):
74
+ prefix = ".".join(module_parts[: len(pattern_parts)])
75
+ captures = match_pattern_with_captures(pattern, prefix)
76
+ if captures is not None:
77
+ return captures
78
+ return None
79
+
80
+
81
+ def find_matching_rules(
82
+ module: str, rules: list[Rule]
83
+ ) -> list[tuple[Rule, dict[str, str]]]:
84
+ result = []
85
+ for r in rules:
86
+ captures = match_pattern_with_captures_or_submodule(r.modules, module)
87
+ if captures is not None:
88
+ result.append((r, captures))
89
+ return result
90
+
91
+
92
+ def _merge_allow_deny(
93
+ base: AllowDeny | None, override: AllowDeny | None
94
+ ) -> AllowDeny | None:
95
+ if base is None and override is None:
96
+ return None
97
+ if base is None:
98
+ return override
99
+ if override is None:
100
+ return base
101
+
102
+ def _merge_lists(a: list[str] | None, b: list[str] | None) -> list[str] | None:
103
+ if a is None and b is None:
104
+ return None
105
+ if a is None:
106
+ return b
107
+ if b is None:
108
+ return a
109
+ return list(set(a + b))
110
+
111
+ return AllowDeny(
112
+ standard_library=_merge_lists(base.standard_library, override.standard_library),
113
+ third_party=_merge_lists(base.third_party, override.third_party),
114
+ local=_merge_lists(base.local, override.local),
115
+ )
116
+
117
+
118
+ def merge_rules(rules: list[Rule]) -> Rule:
119
+ merged = rules[0]
120
+ for rule in rules[1:]:
121
+ merged = Rule(
122
+ name=merged.name,
123
+ modules=merged.modules,
124
+ allow=_merge_allow_deny(merged.allow, rule.allow),
125
+ deny=_merge_allow_deny(merged.deny, rule.deny),
126
+ )
127
+ return merged
@@ -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
@@ -0,0 +1,275 @@
1
+ from python_dependency_linter.config import AllowDeny, Rule
2
+ from python_dependency_linter.matcher import (
3
+ find_matching_rules,
4
+ match_pattern_with_captures,
5
+ matches_pattern,
6
+ merge_rules,
7
+ )
8
+
9
+
10
+ def test_matches_pattern_exact():
11
+ assert matches_pattern("contexts.boards.domain", "contexts.boards.domain") is True
12
+ assert matches_pattern("contexts.boards.domain", "contexts.auth.domain") is False
13
+
14
+
15
+ def test_matches_pattern_wildcard():
16
+ assert matches_pattern("contexts.*.domain", "contexts.boards.domain") is True
17
+ assert matches_pattern("contexts.*.domain", "contexts.auth.domain") is True
18
+ assert matches_pattern("contexts.*.domain", "contexts.boards.application") is False
19
+
20
+
21
+ def test_matches_pattern_double_star():
22
+ # matches one level
23
+ assert matches_pattern("contexts.**.domain", "contexts.analytics.domain") is True
24
+ # matches multiple levels
25
+ assert (
26
+ matches_pattern("contexts.**.domain", "contexts.analytics.sub.domain") is True
27
+ )
28
+ # does not match zero levels (** requires one or more)
29
+ assert matches_pattern("contexts.**.domain", "contexts.domain") is False
30
+ # does not match wrong suffix
31
+ assert (
32
+ matches_pattern("contexts.**.domain", "contexts.analytics.application") is False
33
+ )
34
+
35
+
36
+ def test_matches_pattern_double_star_at_end():
37
+ assert (
38
+ matches_pattern("contexts.**.domain.**", "contexts.a.domain.entities") is True
39
+ )
40
+ assert (
41
+ matches_pattern("contexts.**.domain.**", "contexts.a.domain.entities.metric")
42
+ is True
43
+ )
44
+ assert matches_pattern("contexts.**.domain.**", "contexts.a.domain") is False
45
+
46
+
47
+ def test_matches_pattern_double_star_alone():
48
+ # ** alone matches any module with one or more parts
49
+ assert matches_pattern("**", "anything") is True
50
+ assert matches_pattern("**", "a.b.c") is True
51
+
52
+
53
+ def test_matches_pattern_wildcard_in_allow():
54
+ assert matches_pattern("contexts.*.domain", "contexts.boards.domain") is True
55
+
56
+
57
+ def test_find_matching_rules():
58
+ rules = [
59
+ Rule(
60
+ name="r1",
61
+ modules="contexts.*.domain",
62
+ allow=AllowDeny(third_party=["pydantic"]),
63
+ ),
64
+ Rule(
65
+ name="r2",
66
+ modules="contexts.boards.domain",
67
+ allow=AllowDeny(third_party=["attrs"]),
68
+ ),
69
+ Rule(
70
+ name="r3",
71
+ modules="contexts.*.adapters",
72
+ deny=AllowDeny(third_party=["boto3"]),
73
+ ),
74
+ ]
75
+ matched = find_matching_rules("contexts.boards.domain", rules)
76
+ assert len(matched) == 2
77
+ assert matched[0][0].name == "r1"
78
+ assert matched[1][0].name == "r2"
79
+
80
+
81
+ def test_merge_rules_merges_allow():
82
+ wildcard_rule = Rule(
83
+ name="r1",
84
+ modules="contexts.*.domain",
85
+ allow=AllowDeny(third_party=["pydantic"], standard_library=["typing"]),
86
+ )
87
+ specific_rule = Rule(
88
+ name="r2",
89
+ modules="contexts.boards.domain",
90
+ allow=AllowDeny(third_party=["attrs"]),
91
+ )
92
+ merged = merge_rules([wildcard_rule, specific_rule])
93
+
94
+ assert sorted(merged.allow.third_party) == ["attrs", "pydantic"]
95
+ assert merged.allow.standard_library == ["typing"]
96
+ assert merged.deny is None
97
+
98
+
99
+ def test_merge_rules_single():
100
+ rule = Rule(
101
+ name="r1",
102
+ modules="contexts.*.domain",
103
+ allow=AllowDeny(third_party=["pydantic"]),
104
+ )
105
+ merged = merge_rules([rule])
106
+ assert merged.allow.third_party == ["pydantic"]
107
+
108
+
109
+ def test_merge_rules_merges_deny():
110
+ rule1 = Rule(
111
+ name="r1", modules="contexts.*.adapters", deny=AllowDeny(third_party=["boto3"])
112
+ )
113
+ rule2 = Rule(
114
+ name="r2",
115
+ modules="contexts.boards.adapters",
116
+ deny=AllowDeny(third_party=["requests"]),
117
+ )
118
+ merged = merge_rules([rule1, rule2])
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"}
217
+
218
+
219
+ def test_find_matching_rules_submodule():
220
+ """modules pattern should match submodules automatically."""
221
+ rules = [
222
+ Rule(
223
+ name="domain-layer",
224
+ modules="contexts.*.domain",
225
+ allow=AllowDeny(local=["contexts.*.domain"]),
226
+ ),
227
+ ]
228
+ # Exact match still works
229
+ matched = find_matching_rules("contexts.boards.domain", rules)
230
+ assert len(matched) == 1
231
+ assert matched[0][0].name == "domain-layer"
232
+
233
+ # Submodule should also match
234
+ matched = find_matching_rules("contexts.boards.domain.models", rules)
235
+ assert len(matched) == 1
236
+ assert matched[0][0].name == "domain-layer"
237
+
238
+ # Deeper submodule should also match
239
+ matched = find_matching_rules("contexts.boards.domain.entities.metric", rules)
240
+ assert len(matched) == 1
241
+ assert matched[0][0].name == "domain-layer"
242
+
243
+ # Non-matching module should not match
244
+ matched = find_matching_rules("contexts.boards.application.service", rules)
245
+ assert len(matched) == 0
246
+
247
+
248
+ def test_find_matching_rules_submodule_with_captures():
249
+ """modules submodule matching should preserve captures."""
250
+ rules = [
251
+ Rule(
252
+ name="domain-layer",
253
+ modules="contexts.{context}.domain",
254
+ allow=AllowDeny(local=["contexts.{context}.domain"]),
255
+ ),
256
+ ]
257
+ matched = find_matching_rules("contexts.boards.domain.models", rules)
258
+ assert len(matched) == 1
259
+ rule, captures = matched[0]
260
+ assert rule.name == "domain-layer"
261
+ assert captures == {"context": "boards"}
262
+
263
+
264
+ def test_find_matching_rules_submodule_exact_pattern():
265
+ """Exact (no wildcard) modules pattern should also match submodules."""
266
+ rules = [
267
+ Rule(
268
+ name="shared",
269
+ modules="src.shared.domain",
270
+ allow=AllowDeny(local=["src.shared.domain"]),
271
+ ),
272
+ ]
273
+ matched = find_matching_rules("src.shared.domain.entity.user", rules)
274
+ assert len(matched) == 1
275
+ assert matched[0][0].name == "shared"
@@ -1,73 +0,0 @@
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
- return _match(pattern_parts, module_parts)
10
-
11
-
12
- def _match(pattern_parts: list[str], module_parts: list[str]) -> bool:
13
- if not pattern_parts and not module_parts:
14
- return True
15
- if not pattern_parts:
16
- return False
17
-
18
- if pattern_parts[0] == "**":
19
- # "**" matches one or more parts
20
- for i in range(1, len(module_parts) + 1):
21
- if _match(pattern_parts[1:], module_parts[i:]):
22
- return True
23
- return False
24
-
25
- if not module_parts:
26
- return False
27
-
28
- if pattern_parts[0] == "*" or pattern_parts[0] == module_parts[0]:
29
- return _match(pattern_parts[1:], module_parts[1:])
30
-
31
- return False
32
-
33
-
34
- def find_matching_rules(module: str, rules: list[Rule]) -> list[Rule]:
35
- return [r for r in rules if matches_pattern(r.modules, module)]
36
-
37
-
38
- def _merge_allow_deny(
39
- base: AllowDeny | None, override: AllowDeny | None
40
- ) -> AllowDeny | None:
41
- if base is None and override is None:
42
- return None
43
- if base is None:
44
- return override
45
- if override is None:
46
- return base
47
-
48
- def _merge_lists(a: list[str] | None, b: list[str] | None) -> list[str] | None:
49
- if a is None and b is None:
50
- return None
51
- if a is None:
52
- return b
53
- if b is None:
54
- return a
55
- return list(set(a + b))
56
-
57
- return AllowDeny(
58
- standard_library=_merge_lists(base.standard_library, override.standard_library),
59
- third_party=_merge_lists(base.third_party, override.third_party),
60
- local=_merge_lists(base.local, override.local),
61
- )
62
-
63
-
64
- def merge_rules(rules: list[Rule]) -> Rule:
65
- merged = rules[0]
66
- for rule in rules[1:]:
67
- merged = Rule(
68
- name=merged.name,
69
- modules=merged.modules,
70
- allow=_merge_allow_deny(merged.allow, rule.allow),
71
- deny=_merge_allow_deny(merged.deny, rule.deny),
72
- )
73
- return merged
@@ -1,119 +0,0 @@
1
- from python_dependency_linter.config import AllowDeny, Rule
2
- from python_dependency_linter.matcher import (
3
- find_matching_rules,
4
- matches_pattern,
5
- merge_rules,
6
- )
7
-
8
-
9
- def test_matches_pattern_exact():
10
- assert matches_pattern("contexts.boards.domain", "contexts.boards.domain") is True
11
- assert matches_pattern("contexts.boards.domain", "contexts.auth.domain") is False
12
-
13
-
14
- def test_matches_pattern_wildcard():
15
- assert matches_pattern("contexts.*.domain", "contexts.boards.domain") is True
16
- assert matches_pattern("contexts.*.domain", "contexts.auth.domain") is True
17
- assert matches_pattern("contexts.*.domain", "contexts.boards.application") is False
18
-
19
-
20
- def test_matches_pattern_double_star():
21
- # matches one level
22
- assert matches_pattern("contexts.**.domain", "contexts.analytics.domain") is True
23
- # matches multiple levels
24
- assert (
25
- matches_pattern("contexts.**.domain", "contexts.analytics.sub.domain") is True
26
- )
27
- # does not match zero levels (** requires one or more)
28
- assert matches_pattern("contexts.**.domain", "contexts.domain") is False
29
- # does not match wrong suffix
30
- assert (
31
- matches_pattern("contexts.**.domain", "contexts.analytics.application") is False
32
- )
33
-
34
-
35
- def test_matches_pattern_double_star_at_end():
36
- assert (
37
- matches_pattern("contexts.**.domain.**", "contexts.a.domain.entities") is True
38
- )
39
- assert (
40
- matches_pattern("contexts.**.domain.**", "contexts.a.domain.entities.metric")
41
- is True
42
- )
43
- assert matches_pattern("contexts.**.domain.**", "contexts.a.domain") is False
44
-
45
-
46
- def test_matches_pattern_double_star_alone():
47
- # ** alone matches any module with one or more parts
48
- assert matches_pattern("**", "anything") is True
49
- assert matches_pattern("**", "a.b.c") is True
50
-
51
-
52
- def test_matches_pattern_wildcard_in_allow():
53
- assert matches_pattern("contexts.*.domain", "contexts.boards.domain") is True
54
-
55
-
56
- def test_find_matching_rules():
57
- rules = [
58
- Rule(
59
- name="r1",
60
- modules="contexts.*.domain",
61
- allow=AllowDeny(third_party=["pydantic"]),
62
- ),
63
- Rule(
64
- name="r2",
65
- modules="contexts.boards.domain",
66
- allow=AllowDeny(third_party=["attrs"]),
67
- ),
68
- Rule(
69
- name="r3",
70
- modules="contexts.*.adapters",
71
- deny=AllowDeny(third_party=["boto3"]),
72
- ),
73
- ]
74
- matched = find_matching_rules("contexts.boards.domain", rules)
75
- assert len(matched) == 2
76
- assert matched[0].name == "r1"
77
- assert matched[1].name == "r2"
78
-
79
-
80
- def test_merge_rules_merges_allow():
81
- wildcard_rule = Rule(
82
- name="r1",
83
- modules="contexts.*.domain",
84
- allow=AllowDeny(third_party=["pydantic"], standard_library=["typing"]),
85
- )
86
- specific_rule = Rule(
87
- name="r2",
88
- modules="contexts.boards.domain",
89
- allow=AllowDeny(third_party=["attrs"]),
90
- )
91
- merged = merge_rules([wildcard_rule, specific_rule])
92
-
93
- assert sorted(merged.allow.third_party) == ["attrs", "pydantic"]
94
- assert merged.allow.standard_library == ["typing"]
95
- assert merged.deny is None
96
-
97
-
98
- def test_merge_rules_single():
99
- rule = Rule(
100
- name="r1",
101
- modules="contexts.*.domain",
102
- allow=AllowDeny(third_party=["pydantic"]),
103
- )
104
- merged = merge_rules([rule])
105
- assert merged.allow.third_party == ["pydantic"]
106
-
107
-
108
- def test_merge_rules_merges_deny():
109
- rule1 = Rule(
110
- name="r1", modules="contexts.*.adapters", deny=AllowDeny(third_party=["boto3"])
111
- )
112
- rule2 = Rule(
113
- name="r2",
114
- modules="contexts.boards.adapters",
115
- deny=AllowDeny(third_party=["requests"]),
116
- )
117
- merged = merge_rules([rule1, rule2])
118
-
119
- assert sorted(merged.deny.third_party) == ["boto3", "requests"]