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.
Files changed (50) hide show
  1. {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.0}/CHANGELOG.md +9 -0
  2. {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.0}/PKG-INFO +40 -6
  3. {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.0}/README.md +39 -5
  4. {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.0}/python_dependency_linter/checker.py +40 -0
  5. {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.0}/python_dependency_linter/cli.py +7 -3
  6. {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.0}/python_dependency_linter/matcher.py +42 -7
  7. {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.0}/tests/test_checker.py +74 -1
  8. {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.0}/tests/test_matcher.py +99 -2
  9. {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.0}/.claude/skills/commit/SKILL.md +0 -0
  10. {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.0}/.claude/skills/release/SKILL.md +0 -0
  11. {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  12. {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  13. {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.0}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  14. {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.0}/.github/dependabot.yml +0 -0
  15. {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.0}/.github/pull_request_template.md +0 -0
  16. {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.0}/.github/workflows/ci.yaml +0 -0
  17. {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.0}/.github/workflows/publish.yaml +0 -0
  18. {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.0}/.gitignore +0 -0
  19. {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.0}/.pre-commit-config.yaml +0 -0
  20. {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.0}/.pre-commit-hooks.yaml +0 -0
  21. {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.0}/CLAUDE.md +0 -0
  22. {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.0}/CONTRIBUTING.md +0 -0
  23. {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.0}/LICENSE +0 -0
  24. {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.0}/pyproject.toml +0 -0
  25. {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.0}/python_dependency_linter/__init__.py +0 -0
  26. {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.0}/python_dependency_linter/config.py +0 -0
  27. {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.0}/python_dependency_linter/parser.py +0 -0
  28. {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.0}/python_dependency_linter/reporter.py +0 -0
  29. {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.0}/python_dependency_linter/resolver.py +0 -0
  30. {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.0}/tests/fixtures/sample_config.yaml +0 -0
  31. {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.0}/tests/fixtures/sample_project/contexts/__init__.py +0 -0
  32. {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.0}/tests/fixtures/sample_project/contexts/auth/__init__.py +0 -0
  33. {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.0}/tests/fixtures/sample_project/contexts/auth/application/__init__.py +0 -0
  34. {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.0}/tests/fixtures/sample_project/contexts/auth/application/service.py +0 -0
  35. {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.0}/tests/fixtures/sample_project/contexts/auth/domain/__init__.py +0 -0
  36. {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.0}/tests/fixtures/sample_project/contexts/auth/domain/models.py +0 -0
  37. {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.0}/tests/fixtures/sample_project/contexts/boards/__init__.py +0 -0
  38. {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.0}/tests/fixtures/sample_project/contexts/boards/adapters/__init__.py +0 -0
  39. {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.0}/tests/fixtures/sample_project/contexts/boards/adapters/repository.py +0 -0
  40. {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.0}/tests/fixtures/sample_project/contexts/boards/application/__init__.py +0 -0
  41. {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.0}/tests/fixtures/sample_project/contexts/boards/application/service.py +0 -0
  42. {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.0}/tests/fixtures/sample_project/contexts/boards/domain/__init__.py +0 -0
  43. {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.0}/tests/fixtures/sample_project/contexts/boards/domain/models.py +0 -0
  44. {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.0}/tests/fixtures/sample_pyproject.toml +0 -0
  45. {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.0}/tests/test_cli.py +0 -0
  46. {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.0}/tests/test_config.py +0 -0
  47. {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.0}/tests/test_parser.py +0 -0
  48. {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.0}/tests/test_reporter.py +0 -0
  49. {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.0}/tests/test_resolver.py +0 -0
  50. {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.4.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.*.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,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.*.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,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)
@@ -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
 
@@ -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
- return _match(pattern_parts, module_parts)
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 _match(pattern_parts: list[str], module_parts: list[str]) -> bool:
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
- if _match(pattern_parts[1:], module_parts[i:]):
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 _match(pattern_parts[1:], module_parts[1:])
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(module: str, rules: list[Rule]) -> list[Rule]:
35
- return [r for r in rules if matches_pattern(r.modules, module)]
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"}