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.
- {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.1}/CHANGELOG.md +14 -0
- {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.1}/PKG-INFO +51 -12
- {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.1}/README.md +50 -11
- {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.1}/python_dependency_linter/checker.py +40 -0
- {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.1}/python_dependency_linter/cli.py +7 -3
- python_dependency_linter-0.5.1/python_dependency_linter/matcher.py +127 -0
- {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.1}/tests/test_checker.py +74 -1
- python_dependency_linter-0.5.1/tests/test_matcher.py +275 -0
- python_dependency_linter-0.4.0/python_dependency_linter/matcher.py +0 -73
- python_dependency_linter-0.4.0/tests/test_matcher.py +0 -119
- {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.1}/.claude/skills/commit/SKILL.md +0 -0
- {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.1}/.claude/skills/release/SKILL.md +0 -0
- {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.1}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
- {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.1}/.github/ISSUE_TEMPLATE/config.yml +0 -0
- {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.1}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
- {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.1}/.github/dependabot.yml +0 -0
- {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.1}/.github/pull_request_template.md +0 -0
- {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.1}/.github/workflows/ci.yaml +0 -0
- {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.1}/.github/workflows/publish.yaml +0 -0
- {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.1}/.gitignore +0 -0
- {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.1}/.pre-commit-config.yaml +0 -0
- {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.1}/.pre-commit-hooks.yaml +0 -0
- {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.1}/CLAUDE.md +0 -0
- {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.1}/CONTRIBUTING.md +0 -0
- {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.1}/LICENSE +0 -0
- {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.1}/pyproject.toml +0 -0
- {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.1}/python_dependency_linter/__init__.py +0 -0
- {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.1}/python_dependency_linter/config.py +0 -0
- {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.1}/python_dependency_linter/parser.py +0 -0
- {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.1}/python_dependency_linter/reporter.py +0 -0
- {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.1}/python_dependency_linter/resolver.py +0 -0
- {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.1}/tests/fixtures/sample_config.yaml +0 -0
- {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.1}/tests/fixtures/sample_project/contexts/__init__.py +0 -0
- {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.1}/tests/fixtures/sample_project/contexts/auth/__init__.py +0 -0
- {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.1}/tests/fixtures/sample_project/contexts/auth/application/__init__.py +0 -0
- {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.1}/tests/fixtures/sample_project/contexts/auth/application/service.py +0 -0
- {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.1}/tests/fixtures/sample_project/contexts/auth/domain/__init__.py +0 -0
- {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.1}/tests/fixtures/sample_project/contexts/auth/domain/models.py +0 -0
- {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.1}/tests/fixtures/sample_project/contexts/boards/__init__.py +0 -0
- {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.1}/tests/fixtures/sample_project/contexts/boards/adapters/__init__.py +0 -0
- {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.1}/tests/fixtures/sample_project/contexts/boards/adapters/repository.py +0 -0
- {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.1}/tests/fixtures/sample_project/contexts/boards/application/__init__.py +0 -0
- {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.1}/tests/fixtures/sample_project/contexts/boards/application/service.py +0 -0
- {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.1}/tests/fixtures/sample_project/contexts/boards/domain/__init__.py +0 -0
- {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.1}/tests/fixtures/sample_project/contexts/boards/domain/models.py +0 -0
- {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.1}/tests/fixtures/sample_pyproject.toml +0 -0
- {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.1}/tests/test_cli.py +0 -0
- {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.1}/tests/test_config.py +0 -0
- {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.1}/tests/test_parser.py +0 -0
- {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.1}/tests/test_reporter.py +0 -0
- {python_dependency_linter-0.4.0 → python_dependency_linter-0.5.1}/tests/test_resolver.py +0 -0
- {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.
|
|
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
|
|
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,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
|
|
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
|
-
|
|
256
|
-
|
|
291
|
+
rules:
|
|
292
|
+
- name: domain-layer
|
|
293
|
+
modules: contexts.*.domain
|
|
294
|
+
allow:
|
|
295
|
+
local: [contexts.*.domain]
|
|
257
296
|
```
|
|
258
297
|
|
|
259
|
-
|
|
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.
|
|
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.
|
|
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
|
|
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,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
|
|
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
|
-
|
|
231
|
-
|
|
266
|
+
rules:
|
|
267
|
+
- name: domain-layer
|
|
268
|
+
modules: contexts.*.domain
|
|
269
|
+
allow:
|
|
270
|
+
local: [contexts.*.domain]
|
|
232
271
|
```
|
|
233
272
|
|
|
234
|
-
|
|
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.
|
|
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.
|
|
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)
|
{python_dependency_linter-0.4.0 → python_dependency_linter-0.5.1}/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
|
|
|
@@ -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"]
|
{python_dependency_linter-0.4.0 → python_dependency_linter-0.5.1}/.claude/skills/commit/SKILL.md
RENAMED
|
File without changes
|
{python_dependency_linter-0.4.0 → python_dependency_linter-0.5.1}/.claude/skills/release/SKILL.md
RENAMED
|
File without changes
|
|
File without changes
|
{python_dependency_linter-0.4.0 → python_dependency_linter-0.5.1}/.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.1}/.github/pull_request_template.md
RENAMED
|
File without changes
|
|
File without changes
|
{python_dependency_linter-0.4.0 → python_dependency_linter-0.5.1}/.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.1}/python_dependency_linter/config.py
RENAMED
|
File without changes
|
{python_dependency_linter-0.4.0 → python_dependency_linter-0.5.1}/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.1}/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
|