python-dependency-linter 0.3.0__tar.gz → 0.5.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {python_dependency_linter-0.3.0 → python_dependency_linter-0.5.0}/CHANGELOG.md +30 -0
- {python_dependency_linter-0.3.0 → python_dependency_linter-0.5.0}/PKG-INFO +46 -15
- {python_dependency_linter-0.3.0 → python_dependency_linter-0.5.0}/README.md +45 -14
- {python_dependency_linter-0.3.0 → python_dependency_linter-0.5.0}/python_dependency_linter/checker.py +40 -0
- {python_dependency_linter-0.3.0 → python_dependency_linter-0.5.0}/python_dependency_linter/cli.py +31 -16
- {python_dependency_linter-0.3.0 → python_dependency_linter-0.5.0}/python_dependency_linter/config.py +31 -2
- {python_dependency_linter-0.3.0 → python_dependency_linter-0.5.0}/python_dependency_linter/matcher.py +42 -7
- {python_dependency_linter-0.3.0 → python_dependency_linter-0.5.0}/tests/test_checker.py +74 -1
- {python_dependency_linter-0.3.0 → python_dependency_linter-0.5.0}/tests/test_cli.py +92 -24
- {python_dependency_linter-0.3.0 → python_dependency_linter-0.5.0}/tests/test_config.py +44 -1
- {python_dependency_linter-0.3.0 → python_dependency_linter-0.5.0}/tests/test_matcher.py +99 -2
- {python_dependency_linter-0.3.0 → python_dependency_linter-0.5.0}/.claude/skills/commit/SKILL.md +0 -0
- {python_dependency_linter-0.3.0 → python_dependency_linter-0.5.0}/.claude/skills/release/SKILL.md +0 -0
- {python_dependency_linter-0.3.0 → python_dependency_linter-0.5.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
- {python_dependency_linter-0.3.0 → python_dependency_linter-0.5.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
- {python_dependency_linter-0.3.0 → python_dependency_linter-0.5.0}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
- {python_dependency_linter-0.3.0 → python_dependency_linter-0.5.0}/.github/dependabot.yml +0 -0
- {python_dependency_linter-0.3.0 → python_dependency_linter-0.5.0}/.github/pull_request_template.md +0 -0
- {python_dependency_linter-0.3.0 → python_dependency_linter-0.5.0}/.github/workflows/ci.yaml +0 -0
- {python_dependency_linter-0.3.0 → python_dependency_linter-0.5.0}/.github/workflows/publish.yaml +0 -0
- {python_dependency_linter-0.3.0 → python_dependency_linter-0.5.0}/.gitignore +0 -0
- {python_dependency_linter-0.3.0 → python_dependency_linter-0.5.0}/.pre-commit-config.yaml +0 -0
- {python_dependency_linter-0.3.0 → python_dependency_linter-0.5.0}/.pre-commit-hooks.yaml +0 -0
- {python_dependency_linter-0.3.0 → python_dependency_linter-0.5.0}/CLAUDE.md +0 -0
- {python_dependency_linter-0.3.0 → python_dependency_linter-0.5.0}/CONTRIBUTING.md +0 -0
- {python_dependency_linter-0.3.0 → python_dependency_linter-0.5.0}/LICENSE +0 -0
- {python_dependency_linter-0.3.0 → python_dependency_linter-0.5.0}/pyproject.toml +0 -0
- {python_dependency_linter-0.3.0 → python_dependency_linter-0.5.0}/python_dependency_linter/__init__.py +0 -0
- {python_dependency_linter-0.3.0 → python_dependency_linter-0.5.0}/python_dependency_linter/parser.py +0 -0
- {python_dependency_linter-0.3.0 → python_dependency_linter-0.5.0}/python_dependency_linter/reporter.py +0 -0
- {python_dependency_linter-0.3.0 → python_dependency_linter-0.5.0}/python_dependency_linter/resolver.py +0 -0
- {python_dependency_linter-0.3.0 → python_dependency_linter-0.5.0}/tests/fixtures/sample_config.yaml +0 -0
- {python_dependency_linter-0.3.0 → python_dependency_linter-0.5.0}/tests/fixtures/sample_project/contexts/__init__.py +0 -0
- {python_dependency_linter-0.3.0 → python_dependency_linter-0.5.0}/tests/fixtures/sample_project/contexts/auth/__init__.py +0 -0
- {python_dependency_linter-0.3.0 → python_dependency_linter-0.5.0}/tests/fixtures/sample_project/contexts/auth/application/__init__.py +0 -0
- {python_dependency_linter-0.3.0 → python_dependency_linter-0.5.0}/tests/fixtures/sample_project/contexts/auth/application/service.py +0 -0
- {python_dependency_linter-0.3.0 → python_dependency_linter-0.5.0}/tests/fixtures/sample_project/contexts/auth/domain/__init__.py +0 -0
- {python_dependency_linter-0.3.0 → python_dependency_linter-0.5.0}/tests/fixtures/sample_project/contexts/auth/domain/models.py +0 -0
- {python_dependency_linter-0.3.0 → python_dependency_linter-0.5.0}/tests/fixtures/sample_project/contexts/boards/__init__.py +0 -0
- {python_dependency_linter-0.3.0 → python_dependency_linter-0.5.0}/tests/fixtures/sample_project/contexts/boards/adapters/__init__.py +0 -0
- {python_dependency_linter-0.3.0 → python_dependency_linter-0.5.0}/tests/fixtures/sample_project/contexts/boards/adapters/repository.py +0 -0
- {python_dependency_linter-0.3.0 → python_dependency_linter-0.5.0}/tests/fixtures/sample_project/contexts/boards/application/__init__.py +0 -0
- {python_dependency_linter-0.3.0 → python_dependency_linter-0.5.0}/tests/fixtures/sample_project/contexts/boards/application/service.py +0 -0
- {python_dependency_linter-0.3.0 → python_dependency_linter-0.5.0}/tests/fixtures/sample_project/contexts/boards/domain/__init__.py +0 -0
- {python_dependency_linter-0.3.0 → python_dependency_linter-0.5.0}/tests/fixtures/sample_project/contexts/boards/domain/models.py +0 -0
- {python_dependency_linter-0.3.0 → python_dependency_linter-0.5.0}/tests/fixtures/sample_pyproject.toml +0 -0
- {python_dependency_linter-0.3.0 → python_dependency_linter-0.5.0}/tests/test_parser.py +0 -0
- {python_dependency_linter-0.3.0 → python_dependency_linter-0.5.0}/tests/test_reporter.py +0 -0
- {python_dependency_linter-0.3.0 → python_dependency_linter-0.5.0}/tests/test_resolver.py +0 -0
- {python_dependency_linter-0.3.0 → python_dependency_linter-0.5.0}/uv.lock +0 -0
|
@@ -2,6 +2,36 @@
|
|
|
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)
|
|
14
|
+
## [0.3.0] - 2026-03-30
|
|
15
|
+
|
|
16
|
+
### Bug Fixes
|
|
17
|
+
|
|
18
|
+
- Use exit code 2 for config file not found (#11)
|
|
19
|
+
|
|
20
|
+
### Documentation
|
|
21
|
+
|
|
22
|
+
- Add CONTRIBUTING.md and CLAUDE.md
|
|
23
|
+
- Add PR title convention to template and CONTRIBUTING.md
|
|
24
|
+
- Add release process to CONTRIBUTING.md and /release skill
|
|
25
|
+
|
|
26
|
+
### Features
|
|
27
|
+
|
|
28
|
+
- Resolve relative imports to absolute module names (#10)
|
|
29
|
+
- Add include/exclude file filtering options (#12)
|
|
30
|
+
|
|
31
|
+
### Miscellaneous
|
|
32
|
+
|
|
33
|
+
- Add /commit skill for Claude Code
|
|
34
|
+
- Add uv.lock for reproducible builds
|
|
5
35
|
## [0.2.0] - 2026-03-30
|
|
6
36
|
|
|
7
37
|
### Documentation
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-dependency-linter
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.0
|
|
4
4
|
Summary: A dependency linter for Python projects
|
|
5
5
|
License-Expression: MIT
|
|
6
6
|
License-File: LICENSE
|
|
@@ -127,25 +127,30 @@ rules:
|
|
|
127
127
|
|
|
128
128
|
Isolate domain from infrastructure. Ports (interfaces) live in domain, adapters depend on domain but not vice versa.
|
|
129
129
|
|
|
130
|
+
Using named captures (`{context}`), you can enforce that each bounded context only depends on its own domain — not other contexts' domains:
|
|
131
|
+
|
|
130
132
|
```yaml
|
|
131
133
|
rules:
|
|
132
134
|
- name: domain-no-infra
|
|
133
|
-
modules: contexts
|
|
135
|
+
modules: contexts.{context}.domain
|
|
134
136
|
allow:
|
|
135
137
|
standard_library: [dataclasses, typing, abc]
|
|
136
138
|
third_party: []
|
|
137
|
-
local: [contexts
|
|
139
|
+
local: [contexts.{context}.domain, shared.domain]
|
|
138
140
|
|
|
139
141
|
- name: adapters-depend-on-domain
|
|
140
|
-
modules: contexts
|
|
142
|
+
modules: contexts.{context}.adapters
|
|
141
143
|
allow:
|
|
142
144
|
standard_library: ["*"]
|
|
143
145
|
third_party: ["*"]
|
|
144
146
|
local:
|
|
145
|
-
- contexts
|
|
146
|
-
- contexts
|
|
147
|
+
- contexts.{context}.adapters
|
|
148
|
+
- contexts.{context}.domain
|
|
149
|
+
- shared
|
|
147
150
|
```
|
|
148
151
|
|
|
152
|
+
With `{context}`, `contexts.boards.domain` can only import from `contexts.boards.domain` and `shared.domain` — not from `contexts.auth.domain`. See [Named Capture](#named-capture) for details.
|
|
153
|
+
|
|
149
154
|
## Configuration
|
|
150
155
|
|
|
151
156
|
### Include / Exclude
|
|
@@ -247,6 +252,35 @@ modules: contexts.*.domain # matches contexts.boards.domain, contexts.auth.doma
|
|
|
247
252
|
modules: contexts.**.domain # matches contexts.boards.domain, contexts.boards.sub.domain, ...
|
|
248
253
|
```
|
|
249
254
|
|
|
255
|
+
### Named Capture
|
|
256
|
+
|
|
257
|
+
`{name}` captures a single level (like `*`) and allows back-referencing the captured value in `allow` and `deny`:
|
|
258
|
+
|
|
259
|
+
```yaml
|
|
260
|
+
rules:
|
|
261
|
+
- name: domain-isolation
|
|
262
|
+
modules: contexts.{context}.domain
|
|
263
|
+
allow:
|
|
264
|
+
local: [contexts.{context}.domain, shared.domain]
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
When this rule matches `contexts.boards.domain`, `{context}` captures `"boards"`. The `allow` pattern `contexts.{context}.domain` resolves to `contexts.boards.domain`, so only the same context's domain is allowed.
|
|
268
|
+
|
|
269
|
+
You can use multiple captures in a single rule:
|
|
270
|
+
|
|
271
|
+
```yaml
|
|
272
|
+
rules:
|
|
273
|
+
- name: bounded-context-layers
|
|
274
|
+
modules: contexts.{context}.{layer}
|
|
275
|
+
allow:
|
|
276
|
+
local:
|
|
277
|
+
- contexts.{context}.{layer}
|
|
278
|
+
- contexts.{context}.domain
|
|
279
|
+
- shared
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
Named captures coexist with `*` and `**` wildcards. `{name}` always matches exactly one level.
|
|
283
|
+
|
|
250
284
|
### Submodule Matching
|
|
251
285
|
|
|
252
286
|
When a pattern is used in `allow` or `deny`, it also matches submodules of the matched module. For example:
|
|
@@ -309,14 +343,11 @@ third_party = ["boto3"]
|
|
|
309
343
|
## CLI
|
|
310
344
|
|
|
311
345
|
```bash
|
|
312
|
-
# Check with
|
|
346
|
+
# Check with auto-discovered config (searches upward from cwd)
|
|
313
347
|
pdl check
|
|
314
348
|
|
|
315
|
-
# Specify config file
|
|
349
|
+
# Specify config file (project root = config file's parent directory)
|
|
316
350
|
pdl check --config path/to/config.yaml
|
|
317
|
-
|
|
318
|
-
# Specify project root
|
|
319
|
-
pdl check --project-root path/to/project
|
|
320
351
|
```
|
|
321
352
|
|
|
322
353
|
Exit codes:
|
|
@@ -325,10 +356,10 @@ Exit codes:
|
|
|
325
356
|
- `1` — Violations found
|
|
326
357
|
- `2` — Config file not found
|
|
327
358
|
|
|
328
|
-
If no `--config` is given, the tool
|
|
359
|
+
If no `--config` is given, the tool searches upward from the current directory for `.python-dependency-linter.yaml` or `pyproject.toml` (with `[tool.python-dependency-linter]`). The config file's parent directory is used as the project root. If no config file is found, the tool prints an error and exits with code `2`:
|
|
329
360
|
|
|
330
361
|
```
|
|
331
|
-
Error: Config file not found
|
|
362
|
+
Error: Config file not found. Create .python-dependency-linter.yaml or configure [tool.python-dependency-linter] in pyproject.toml.
|
|
332
363
|
```
|
|
333
364
|
|
|
334
365
|
## Pre-commit
|
|
@@ -342,14 +373,14 @@ Add to `.pre-commit-config.yaml`:
|
|
|
342
373
|
- id: python-dependency-linter
|
|
343
374
|
```
|
|
344
375
|
|
|
345
|
-
To pass custom options (e.g., a different config file
|
|
376
|
+
To pass custom options (e.g., a different config file):
|
|
346
377
|
|
|
347
378
|
```yaml
|
|
348
379
|
- repo: https://github.com/heumsi/python-dependency-linter
|
|
349
380
|
rev: v0.1.0
|
|
350
381
|
hooks:
|
|
351
382
|
- id: python-dependency-linter
|
|
352
|
-
args: [--config, custom-config.yaml
|
|
383
|
+
args: [--config, custom-config.yaml]
|
|
353
384
|
```
|
|
354
385
|
|
|
355
386
|
## License
|
|
@@ -102,25 +102,30 @@ rules:
|
|
|
102
102
|
|
|
103
103
|
Isolate domain from infrastructure. Ports (interfaces) live in domain, adapters depend on domain but not vice versa.
|
|
104
104
|
|
|
105
|
+
Using named captures (`{context}`), you can enforce that each bounded context only depends on its own domain — not other contexts' domains:
|
|
106
|
+
|
|
105
107
|
```yaml
|
|
106
108
|
rules:
|
|
107
109
|
- name: domain-no-infra
|
|
108
|
-
modules: contexts
|
|
110
|
+
modules: contexts.{context}.domain
|
|
109
111
|
allow:
|
|
110
112
|
standard_library: [dataclasses, typing, abc]
|
|
111
113
|
third_party: []
|
|
112
|
-
local: [contexts
|
|
114
|
+
local: [contexts.{context}.domain, shared.domain]
|
|
113
115
|
|
|
114
116
|
- name: adapters-depend-on-domain
|
|
115
|
-
modules: contexts
|
|
117
|
+
modules: contexts.{context}.adapters
|
|
116
118
|
allow:
|
|
117
119
|
standard_library: ["*"]
|
|
118
120
|
third_party: ["*"]
|
|
119
121
|
local:
|
|
120
|
-
- contexts
|
|
121
|
-
- contexts
|
|
122
|
+
- contexts.{context}.adapters
|
|
123
|
+
- contexts.{context}.domain
|
|
124
|
+
- shared
|
|
122
125
|
```
|
|
123
126
|
|
|
127
|
+
With `{context}`, `contexts.boards.domain` can only import from `contexts.boards.domain` and `shared.domain` — not from `contexts.auth.domain`. See [Named Capture](#named-capture) for details.
|
|
128
|
+
|
|
124
129
|
## Configuration
|
|
125
130
|
|
|
126
131
|
### Include / Exclude
|
|
@@ -222,6 +227,35 @@ modules: contexts.*.domain # matches contexts.boards.domain, contexts.auth.doma
|
|
|
222
227
|
modules: contexts.**.domain # matches contexts.boards.domain, contexts.boards.sub.domain, ...
|
|
223
228
|
```
|
|
224
229
|
|
|
230
|
+
### Named Capture
|
|
231
|
+
|
|
232
|
+
`{name}` captures a single level (like `*`) and allows back-referencing the captured value in `allow` and `deny`:
|
|
233
|
+
|
|
234
|
+
```yaml
|
|
235
|
+
rules:
|
|
236
|
+
- name: domain-isolation
|
|
237
|
+
modules: contexts.{context}.domain
|
|
238
|
+
allow:
|
|
239
|
+
local: [contexts.{context}.domain, shared.domain]
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
When this rule matches `contexts.boards.domain`, `{context}` captures `"boards"`. The `allow` pattern `contexts.{context}.domain` resolves to `contexts.boards.domain`, so only the same context's domain is allowed.
|
|
243
|
+
|
|
244
|
+
You can use multiple captures in a single rule:
|
|
245
|
+
|
|
246
|
+
```yaml
|
|
247
|
+
rules:
|
|
248
|
+
- name: bounded-context-layers
|
|
249
|
+
modules: contexts.{context}.{layer}
|
|
250
|
+
allow:
|
|
251
|
+
local:
|
|
252
|
+
- contexts.{context}.{layer}
|
|
253
|
+
- contexts.{context}.domain
|
|
254
|
+
- shared
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
Named captures coexist with `*` and `**` wildcards. `{name}` always matches exactly one level.
|
|
258
|
+
|
|
225
259
|
### Submodule Matching
|
|
226
260
|
|
|
227
261
|
When a pattern is used in `allow` or `deny`, it also matches submodules of the matched module. For example:
|
|
@@ -284,14 +318,11 @@ third_party = ["boto3"]
|
|
|
284
318
|
## CLI
|
|
285
319
|
|
|
286
320
|
```bash
|
|
287
|
-
# Check with
|
|
321
|
+
# Check with auto-discovered config (searches upward from cwd)
|
|
288
322
|
pdl check
|
|
289
323
|
|
|
290
|
-
# Specify config file
|
|
324
|
+
# Specify config file (project root = config file's parent directory)
|
|
291
325
|
pdl check --config path/to/config.yaml
|
|
292
|
-
|
|
293
|
-
# Specify project root
|
|
294
|
-
pdl check --project-root path/to/project
|
|
295
326
|
```
|
|
296
327
|
|
|
297
328
|
Exit codes:
|
|
@@ -300,10 +331,10 @@ Exit codes:
|
|
|
300
331
|
- `1` — Violations found
|
|
301
332
|
- `2` — Config file not found
|
|
302
333
|
|
|
303
|
-
If no `--config` is given, the tool
|
|
334
|
+
If no `--config` is given, the tool searches upward from the current directory for `.python-dependency-linter.yaml` or `pyproject.toml` (with `[tool.python-dependency-linter]`). The config file's parent directory is used as the project root. If no config file is found, the tool prints an error and exits with code `2`:
|
|
304
335
|
|
|
305
336
|
```
|
|
306
|
-
Error: Config file not found
|
|
337
|
+
Error: Config file not found. Create .python-dependency-linter.yaml or configure [tool.python-dependency-linter] in pyproject.toml.
|
|
307
338
|
```
|
|
308
339
|
|
|
309
340
|
## Pre-commit
|
|
@@ -317,14 +348,14 @@ Add to `.pre-commit-config.yaml`:
|
|
|
317
348
|
- id: python-dependency-linter
|
|
318
349
|
```
|
|
319
350
|
|
|
320
|
-
To pass custom options (e.g., a different config file
|
|
351
|
+
To pass custom options (e.g., a different config file):
|
|
321
352
|
|
|
322
353
|
```yaml
|
|
323
354
|
- repo: https://github.com/heumsi/python-dependency-linter
|
|
324
355
|
rev: v0.1.0
|
|
325
356
|
hooks:
|
|
326
357
|
- id: python-dependency-linter
|
|
327
|
-
args: [--config, custom-config.yaml
|
|
358
|
+
args: [--config, custom-config.yaml]
|
|
328
359
|
```
|
|
329
360
|
|
|
330
361
|
## License
|
|
@@ -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.3.0 → python_dependency_linter-0.5.0}/python_dependency_linter/cli.py
RENAMED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import fnmatch
|
|
3
4
|
from pathlib import Path
|
|
4
5
|
|
|
5
6
|
import click
|
|
6
7
|
|
|
7
8
|
from python_dependency_linter.checker import check_import
|
|
8
|
-
from python_dependency_linter.config import load_config
|
|
9
|
+
from python_dependency_linter.config import find_config, load_config
|
|
9
10
|
from python_dependency_linter.matcher import find_matching_rules, merge_rules
|
|
10
11
|
from python_dependency_linter.parser import parse_imports
|
|
11
12
|
from python_dependency_linter.reporter import format_violations
|
|
@@ -46,7 +47,7 @@ def _normalize_pattern(pattern: str, project_root: Path) -> str:
|
|
|
46
47
|
|
|
47
48
|
|
|
48
49
|
def _matches_any(path: Path, patterns: list[str]) -> bool:
|
|
49
|
-
return any(
|
|
50
|
+
return any(fnmatch.fnmatch(str(path), p) for p in patterns)
|
|
50
51
|
|
|
51
52
|
|
|
52
53
|
def _find_python_files(
|
|
@@ -84,19 +85,29 @@ def main():
|
|
|
84
85
|
@click.option(
|
|
85
86
|
"--config",
|
|
86
87
|
"config_path",
|
|
87
|
-
default=
|
|
88
|
+
default=None,
|
|
88
89
|
help="Path to config file.",
|
|
89
90
|
)
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
91
|
+
def check(config_path: str | None):
|
|
92
|
+
if config_path is not None:
|
|
93
|
+
config_file = Path(config_path)
|
|
94
|
+
if not config_file.exists():
|
|
95
|
+
click.echo(f"Error: Config file not found: {config_file}", err=True)
|
|
96
|
+
raise SystemExit(2)
|
|
97
|
+
root = config_file.resolve().parent
|
|
98
|
+
else:
|
|
99
|
+
config_file = find_config()
|
|
100
|
+
if config_file is None:
|
|
101
|
+
click.echo(
|
|
102
|
+
"Error: Config file not found. "
|
|
103
|
+
"Create .python-dependency-linter.yaml or configure "
|
|
104
|
+
"[tool.python-dependency-linter] in pyproject.toml.",
|
|
105
|
+
err=True,
|
|
106
|
+
)
|
|
107
|
+
raise SystemExit(2)
|
|
108
|
+
root = config_file.resolve().parent
|
|
109
|
+
|
|
110
|
+
config = load_config(config_file)
|
|
100
111
|
|
|
101
112
|
all_violations = []
|
|
102
113
|
python_files = _find_python_files(root, config.include, config.exclude)
|
|
@@ -104,17 +115,21 @@ def check(config_path: str, project_root: str):
|
|
|
104
115
|
for file_path in python_files:
|
|
105
116
|
module = _file_to_module(file_path, root)
|
|
106
117
|
package = _package_module(file_path, root)
|
|
107
|
-
|
|
108
|
-
if not
|
|
118
|
+
matching = find_matching_rules(package, config.rules)
|
|
119
|
+
if not matching:
|
|
109
120
|
continue
|
|
110
121
|
|
|
122
|
+
matching_rules = [r for r, _ in matching]
|
|
123
|
+
captures: dict[str, str] = {}
|
|
124
|
+
for _, c in matching:
|
|
125
|
+
captures.update(c)
|
|
111
126
|
merged_rule = merge_rules(matching_rules)
|
|
112
127
|
imports = parse_imports(file_path, root)
|
|
113
128
|
|
|
114
129
|
file_violations = []
|
|
115
130
|
for imp in imports:
|
|
116
131
|
category = resolve_import(imp.module, root)
|
|
117
|
-
violation = check_import(imp, category, merged_rule, module)
|
|
132
|
+
violation = check_import(imp, category, merged_rule, module, captures)
|
|
118
133
|
if violation is not None:
|
|
119
134
|
file_violations.append(violation)
|
|
120
135
|
|
{python_dependency_linter-0.3.0 → python_dependency_linter-0.5.0}/python_dependency_linter/config.py
RENAMED
|
@@ -62,14 +62,18 @@ def _load_yaml(path: Path) -> Config:
|
|
|
62
62
|
)
|
|
63
63
|
|
|
64
64
|
|
|
65
|
-
def
|
|
65
|
+
def _load_toml(path: Path) -> dict:
|
|
66
66
|
try:
|
|
67
67
|
import tomllib
|
|
68
68
|
except ImportError:
|
|
69
69
|
import tomli as tomllib # type: ignore[no-redef]
|
|
70
70
|
|
|
71
71
|
with open(path, "rb") as f:
|
|
72
|
-
|
|
72
|
+
return tomllib.load(f)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _load_pyproject_toml(path: Path) -> Config:
|
|
76
|
+
data = _load_toml(path)
|
|
73
77
|
tool_config = data["tool"]["python-dependency-linter"]
|
|
74
78
|
return Config(
|
|
75
79
|
rules=_parse_rules(tool_config["rules"]),
|
|
@@ -78,6 +82,31 @@ def _load_pyproject_toml(path: Path) -> Config:
|
|
|
78
82
|
)
|
|
79
83
|
|
|
80
84
|
|
|
85
|
+
def _has_pdl_section(path: Path) -> bool:
|
|
86
|
+
"""Check if a pyproject.toml contains [tool.python-dependency-linter]."""
|
|
87
|
+
data = _load_toml(path)
|
|
88
|
+
return "python-dependency-linter" in data.get("tool", {})
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
_CONFIG_FILENAMES = [".python-dependency-linter.yaml", "pyproject.toml"]
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def find_config() -> Path | None:
|
|
95
|
+
"""Search upward from cwd for a config file. Returns None if not found."""
|
|
96
|
+
current = Path.cwd().resolve()
|
|
97
|
+
while True:
|
|
98
|
+
for name in _CONFIG_FILENAMES:
|
|
99
|
+
candidate = current / name
|
|
100
|
+
if candidate.is_file():
|
|
101
|
+
if name == "pyproject.toml" and not _has_pdl_section(candidate):
|
|
102
|
+
continue
|
|
103
|
+
return candidate
|
|
104
|
+
parent = current.parent
|
|
105
|
+
if parent == current:
|
|
106
|
+
return None
|
|
107
|
+
current = parent
|
|
108
|
+
|
|
109
|
+
|
|
81
110
|
def load_config(path: Path) -> Config:
|
|
82
111
|
if not path.exists():
|
|
83
112
|
raise FileNotFoundError(f"Config file not found: {path}")
|
|
@@ -1,38 +1,73 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import re
|
|
4
|
+
|
|
3
5
|
from python_dependency_linter.config import AllowDeny, Rule
|
|
4
6
|
|
|
7
|
+
_CAPTURE_RE = re.compile(r"^\{(\w+)\}$")
|
|
8
|
+
|
|
5
9
|
|
|
6
10
|
def matches_pattern(pattern: str, module: str) -> bool:
|
|
11
|
+
return match_pattern_with_captures(pattern, module) is not None
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def match_pattern_with_captures(pattern: str, module: str) -> dict[str, str] | None:
|
|
7
15
|
pattern_parts = pattern.split(".")
|
|
8
16
|
module_parts = module.split(".")
|
|
9
|
-
|
|
17
|
+
captures: dict[str, str] = {}
|
|
18
|
+
if _match_with_captures(pattern_parts, module_parts, captures):
|
|
19
|
+
return captures
|
|
20
|
+
return None
|
|
10
21
|
|
|
11
22
|
|
|
12
|
-
def
|
|
23
|
+
def _match_with_captures(
|
|
24
|
+
pattern_parts: list[str],
|
|
25
|
+
module_parts: list[str],
|
|
26
|
+
captures: dict[str, str],
|
|
27
|
+
) -> bool:
|
|
13
28
|
if not pattern_parts and not module_parts:
|
|
14
29
|
return True
|
|
15
30
|
if not pattern_parts:
|
|
16
31
|
return False
|
|
17
32
|
|
|
18
33
|
if pattern_parts[0] == "**":
|
|
19
|
-
# "**" matches one or more parts
|
|
20
34
|
for i in range(1, len(module_parts) + 1):
|
|
21
|
-
|
|
35
|
+
snapshot = dict(captures)
|
|
36
|
+
if _match_with_captures(pattern_parts[1:], module_parts[i:], captures):
|
|
22
37
|
return True
|
|
38
|
+
captures.clear()
|
|
39
|
+
captures.update(snapshot)
|
|
23
40
|
return False
|
|
24
41
|
|
|
25
42
|
if not module_parts:
|
|
26
43
|
return False
|
|
27
44
|
|
|
45
|
+
m = _CAPTURE_RE.match(pattern_parts[0])
|
|
46
|
+
if m:
|
|
47
|
+
name = m.group(1)
|
|
48
|
+
value = module_parts[0]
|
|
49
|
+
if name in captures:
|
|
50
|
+
if captures[name] != value:
|
|
51
|
+
return False
|
|
52
|
+
else:
|
|
53
|
+
captures[name] = value
|
|
54
|
+
return _match_with_captures(pattern_parts[1:], module_parts[1:], captures)
|
|
55
|
+
|
|
28
56
|
if pattern_parts[0] == "*" or pattern_parts[0] == module_parts[0]:
|
|
29
|
-
return
|
|
57
|
+
return _match_with_captures(pattern_parts[1:], module_parts[1:], captures)
|
|
30
58
|
|
|
31
59
|
return False
|
|
32
60
|
|
|
33
61
|
|
|
34
|
-
def find_matching_rules(
|
|
35
|
-
|
|
62
|
+
def find_matching_rules(
|
|
63
|
+
module: str, rules: list[Rule]
|
|
64
|
+
) -> list[tuple[Rule, dict[str, str]]]:
|
|
65
|
+
result = []
|
|
66
|
+
for r in rules:
|
|
67
|
+
captures = match_pattern_with_captures(r.modules, module)
|
|
68
|
+
if captures is not None:
|
|
69
|
+
result.append((r, captures))
|
|
70
|
+
return result
|
|
36
71
|
|
|
37
72
|
|
|
38
73
|
def _merge_allow_deny(
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
from python_dependency_linter.checker import Violation, check_import
|
|
1
|
+
from python_dependency_linter.checker import Violation, check_import, resolve_captures
|
|
2
2
|
from python_dependency_linter.config import AllowDeny, Rule
|
|
3
3
|
from python_dependency_linter.parser import ImportInfo
|
|
4
4
|
from python_dependency_linter.resolver import ImportCategory
|
|
@@ -150,3 +150,76 @@ def test_no_allow_for_category_means_allow_all():
|
|
|
150
150
|
source_module="contexts.boards.domain",
|
|
151
151
|
)
|
|
152
152
|
assert result is None
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def test_resolve_captures_single():
|
|
156
|
+
result = resolve_captures("src.contexts.{context}.domain", {"context": "analytics"})
|
|
157
|
+
assert result == "src.contexts.analytics.domain"
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def test_resolve_captures_multiple():
|
|
161
|
+
result = resolve_captures(
|
|
162
|
+
"src.{ctx}.adapters.{dir}", {"ctx": "auth", "dir": "inbound"}
|
|
163
|
+
)
|
|
164
|
+
assert result == "src.auth.adapters.inbound"
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def test_resolve_captures_no_placeholders():
|
|
168
|
+
result = resolve_captures("src.shared.domain", {"context": "analytics"})
|
|
169
|
+
assert result == "src.shared.domain"
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def test_resolve_captures_unresolved_placeholder():
|
|
173
|
+
result = resolve_captures("src.{unknown}.domain", {"context": "analytics"})
|
|
174
|
+
assert result == "src.{unknown}.domain"
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def test_cross_context_isolation_allowed():
|
|
178
|
+
"""Same context's domain import should be allowed."""
|
|
179
|
+
rule = Rule(
|
|
180
|
+
name="domain-layer",
|
|
181
|
+
modules="contexts.{context}.domain",
|
|
182
|
+
allow=AllowDeny(local=["contexts.{context}.domain", "shared.domain"]),
|
|
183
|
+
)
|
|
184
|
+
result = check_import(
|
|
185
|
+
import_info=ImportInfo(module="contexts.boards.domain.models", lineno=1),
|
|
186
|
+
category=ImportCategory.LOCAL,
|
|
187
|
+
merged_rule=rule,
|
|
188
|
+
source_module="contexts.boards.domain",
|
|
189
|
+
captures={"context": "boards"},
|
|
190
|
+
)
|
|
191
|
+
assert result is None
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def test_cross_context_isolation_violation():
|
|
195
|
+
"""Different context's domain import should be denied."""
|
|
196
|
+
rule = Rule(
|
|
197
|
+
name="domain-layer",
|
|
198
|
+
modules="contexts.{context}.domain",
|
|
199
|
+
allow=AllowDeny(local=["contexts.{context}.domain", "shared.domain"]),
|
|
200
|
+
)
|
|
201
|
+
result = check_import(
|
|
202
|
+
import_info=ImportInfo(module="contexts.auth.domain.models", lineno=5),
|
|
203
|
+
category=ImportCategory.LOCAL,
|
|
204
|
+
merged_rule=rule,
|
|
205
|
+
source_module="contexts.boards.domain",
|
|
206
|
+
captures={"context": "boards"},
|
|
207
|
+
)
|
|
208
|
+
assert isinstance(result, Violation)
|
|
209
|
+
assert result.imported_module == "contexts.auth.domain.models"
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def test_check_import_no_captures_backward_compat():
|
|
213
|
+
"""Existing behavior works when no captures provided."""
|
|
214
|
+
rule = Rule(
|
|
215
|
+
name="domain-isolation",
|
|
216
|
+
modules="contexts.*.domain",
|
|
217
|
+
allow=AllowDeny(third_party=["pydantic"]),
|
|
218
|
+
)
|
|
219
|
+
result = check_import(
|
|
220
|
+
import_info=ImportInfo(module="pydantic", lineno=1),
|
|
221
|
+
category=ImportCategory.THIRD_PARTY,
|
|
222
|
+
merged_rule=rule,
|
|
223
|
+
source_module="contexts.boards.domain",
|
|
224
|
+
)
|
|
225
|
+
assert result is None
|
|
@@ -7,7 +7,7 @@ from python_dependency_linter.cli import main
|
|
|
7
7
|
FIXTURES = Path(__file__).parent / "fixtures"
|
|
8
8
|
|
|
9
9
|
|
|
10
|
-
def test_cli_check_with_violations(tmp_path):
|
|
10
|
+
def test_cli_check_with_violations(tmp_path, monkeypatch):
|
|
11
11
|
config_content = """\
|
|
12
12
|
rules:
|
|
13
13
|
- name: domain-isolation
|
|
@@ -27,16 +27,15 @@ rules:
|
|
|
27
27
|
dst = tmp_path / "contexts"
|
|
28
28
|
shutil.copytree(src, dst)
|
|
29
29
|
|
|
30
|
+
monkeypatch.chdir(tmp_path)
|
|
30
31
|
runner = CliRunner()
|
|
31
|
-
result = runner.invoke(
|
|
32
|
-
main, ["check", "--config", str(config_file), "--project-root", str(tmp_path)]
|
|
33
|
-
)
|
|
32
|
+
result = runner.invoke(main, ["check"])
|
|
34
33
|
assert result.exit_code == 1
|
|
35
34
|
assert "[domain-isolation]" in result.output
|
|
36
35
|
assert "Found" in result.output
|
|
37
36
|
|
|
38
37
|
|
|
39
|
-
def test_cli_check_no_violations(tmp_path):
|
|
38
|
+
def test_cli_check_no_violations(tmp_path, monkeypatch):
|
|
40
39
|
config_content = """\
|
|
41
40
|
rules:
|
|
42
41
|
- name: allow-all
|
|
@@ -55,15 +54,14 @@ rules:
|
|
|
55
54
|
dst = tmp_path / "contexts"
|
|
56
55
|
shutil.copytree(src, dst)
|
|
57
56
|
|
|
57
|
+
monkeypatch.chdir(tmp_path)
|
|
58
58
|
runner = CliRunner()
|
|
59
|
-
result = runner.invoke(
|
|
60
|
-
main, ["check", "--config", str(config_file), "--project-root", str(tmp_path)]
|
|
61
|
-
)
|
|
59
|
+
result = runner.invoke(main, ["check"])
|
|
62
60
|
assert result.exit_code == 0
|
|
63
61
|
assert "No violations found." in result.output
|
|
64
62
|
|
|
65
63
|
|
|
66
|
-
def test_cli_check_with_include(tmp_path):
|
|
64
|
+
def test_cli_check_with_include(tmp_path, monkeypatch):
|
|
67
65
|
"""Files outside include paths should be skipped."""
|
|
68
66
|
config_content = """\
|
|
69
67
|
include:
|
|
@@ -74,7 +72,7 @@ rules:
|
|
|
74
72
|
deny:
|
|
75
73
|
third_party: [pydantic]
|
|
76
74
|
"""
|
|
77
|
-
config_file = tmp_path / "
|
|
75
|
+
config_file = tmp_path / ".python-dependency-linter.yaml"
|
|
78
76
|
config_file.write_text(config_content)
|
|
79
77
|
|
|
80
78
|
# Create files inside and outside include path
|
|
@@ -88,16 +86,52 @@ rules:
|
|
|
88
86
|
(other / "__init__.py").write_text("")
|
|
89
87
|
(other / "app.py").write_text("import pydantic\n")
|
|
90
88
|
|
|
89
|
+
monkeypatch.chdir(tmp_path)
|
|
91
90
|
runner = CliRunner()
|
|
92
|
-
result = runner.invoke(
|
|
93
|
-
main, ["check", "--config", str(config_file), "--project-root", str(tmp_path)]
|
|
94
|
-
)
|
|
91
|
+
result = runner.invoke(main, ["check"])
|
|
95
92
|
assert result.exit_code == 1
|
|
96
93
|
assert "src/app.py" in result.output
|
|
97
94
|
assert "other/app.py" not in result.output
|
|
98
95
|
|
|
99
96
|
|
|
100
|
-
def
|
|
97
|
+
def test_cli_check_with_include_nested(tmp_path, monkeypatch):
|
|
98
|
+
"""Include should match files in deeply nested subdirectories."""
|
|
99
|
+
config_content = """\
|
|
100
|
+
include:
|
|
101
|
+
- src
|
|
102
|
+
rules:
|
|
103
|
+
- name: domain-isolation
|
|
104
|
+
modules: "**"
|
|
105
|
+
deny:
|
|
106
|
+
third_party: [pydantic]
|
|
107
|
+
"""
|
|
108
|
+
config_file = tmp_path / ".python-dependency-linter.yaml"
|
|
109
|
+
config_file.write_text(config_content)
|
|
110
|
+
|
|
111
|
+
# Create deeply nested files inside include path
|
|
112
|
+
nested = tmp_path / "src" / "contexts" / "analytics" / "domain"
|
|
113
|
+
nested.mkdir(parents=True)
|
|
114
|
+
(tmp_path / "src" / "__init__.py").write_text("")
|
|
115
|
+
(tmp_path / "src" / "contexts" / "__init__.py").write_text("")
|
|
116
|
+
(tmp_path / "src" / "contexts" / "analytics" / "__init__.py").write_text("")
|
|
117
|
+
(nested / "__init__.py").write_text("")
|
|
118
|
+
(nested / "models.py").write_text("import pydantic\n")
|
|
119
|
+
|
|
120
|
+
# Create files outside include path
|
|
121
|
+
other = tmp_path / "other"
|
|
122
|
+
other.mkdir()
|
|
123
|
+
(other / "__init__.py").write_text("")
|
|
124
|
+
(other / "app.py").write_text("import pydantic\n")
|
|
125
|
+
|
|
126
|
+
monkeypatch.chdir(tmp_path)
|
|
127
|
+
runner = CliRunner()
|
|
128
|
+
result = runner.invoke(main, ["check"])
|
|
129
|
+
assert result.exit_code == 1
|
|
130
|
+
assert "src/contexts/analytics/domain/models.py" in result.output
|
|
131
|
+
assert "other/app.py" not in result.output
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def test_cli_check_with_exclude(tmp_path, monkeypatch):
|
|
101
135
|
"""Files matching exclude patterns should be skipped."""
|
|
102
136
|
config_content = """\
|
|
103
137
|
exclude:
|
|
@@ -108,7 +142,7 @@ rules:
|
|
|
108
142
|
deny:
|
|
109
143
|
third_party: [pydantic]
|
|
110
144
|
"""
|
|
111
|
-
config_file = tmp_path / "
|
|
145
|
+
config_file = tmp_path / ".python-dependency-linter.yaml"
|
|
112
146
|
config_file.write_text(config_content)
|
|
113
147
|
|
|
114
148
|
src = tmp_path / "src"
|
|
@@ -121,16 +155,15 @@ rules:
|
|
|
121
155
|
(generated / "__init__.py").write_text("")
|
|
122
156
|
(generated / "models.py").write_text("import pydantic\n")
|
|
123
157
|
|
|
158
|
+
monkeypatch.chdir(tmp_path)
|
|
124
159
|
runner = CliRunner()
|
|
125
|
-
result = runner.invoke(
|
|
126
|
-
main, ["check", "--config", str(config_file), "--project-root", str(tmp_path)]
|
|
127
|
-
)
|
|
160
|
+
result = runner.invoke(main, ["check"])
|
|
128
161
|
assert result.exit_code == 1
|
|
129
162
|
assert "src/app.py" in result.output
|
|
130
163
|
assert "generated/" not in result.output
|
|
131
164
|
|
|
132
165
|
|
|
133
|
-
def test_cli_check_with_include_and_exclude(tmp_path):
|
|
166
|
+
def test_cli_check_with_include_and_exclude(tmp_path, monkeypatch):
|
|
134
167
|
"""Exclude should filter within included paths."""
|
|
135
168
|
config_content = """\
|
|
136
169
|
include:
|
|
@@ -143,7 +176,7 @@ rules:
|
|
|
143
176
|
deny:
|
|
144
177
|
third_party: [pydantic]
|
|
145
178
|
"""
|
|
146
|
-
config_file = tmp_path / "
|
|
179
|
+
config_file = tmp_path / ".python-dependency-linter.yaml"
|
|
147
180
|
config_file.write_text(config_content)
|
|
148
181
|
|
|
149
182
|
app = tmp_path / "src"
|
|
@@ -156,16 +189,51 @@ rules:
|
|
|
156
189
|
(generated / "__init__.py").write_text("")
|
|
157
190
|
(generated / "models.py").write_text("import pydantic\n")
|
|
158
191
|
|
|
192
|
+
monkeypatch.chdir(tmp_path)
|
|
159
193
|
runner = CliRunner()
|
|
160
|
-
result = runner.invoke(
|
|
161
|
-
main, ["check", "--config", str(config_file), "--project-root", str(tmp_path)]
|
|
162
|
-
)
|
|
194
|
+
result = runner.invoke(main, ["check"])
|
|
163
195
|
assert result.exit_code == 1
|
|
164
196
|
assert "src/app.py" in result.output
|
|
165
197
|
assert "generated/" not in result.output
|
|
166
198
|
|
|
167
199
|
|
|
168
|
-
def test_cli_check_config_not_found():
|
|
200
|
+
def test_cli_check_config_not_found(tmp_path, monkeypatch):
|
|
201
|
+
monkeypatch.chdir(tmp_path)
|
|
202
|
+
runner = CliRunner()
|
|
203
|
+
result = runner.invoke(main, ["check"])
|
|
204
|
+
assert result.exit_code == 2
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def test_cli_check_explicit_config_not_found():
|
|
169
208
|
runner = CliRunner()
|
|
170
209
|
result = runner.invoke(main, ["check", "--config", "nonexistent.yaml"])
|
|
171
210
|
assert result.exit_code == 2
|
|
211
|
+
assert "not found" in result.output.lower()
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def test_cli_check_with_explicit_config(tmp_path, monkeypatch):
|
|
215
|
+
"""--config should use the config file's parent as project root."""
|
|
216
|
+
project_dir = tmp_path / "project"
|
|
217
|
+
project_dir.mkdir()
|
|
218
|
+
|
|
219
|
+
config_content = """\
|
|
220
|
+
rules:
|
|
221
|
+
- name: domain-isolation
|
|
222
|
+
modules: "**"
|
|
223
|
+
deny:
|
|
224
|
+
third_party: [pydantic]
|
|
225
|
+
"""
|
|
226
|
+
config_file = project_dir / "custom-config.yaml"
|
|
227
|
+
config_file.write_text(config_content)
|
|
228
|
+
|
|
229
|
+
src = project_dir / "src"
|
|
230
|
+
src.mkdir()
|
|
231
|
+
(src / "__init__.py").write_text("")
|
|
232
|
+
(src / "app.py").write_text("import pydantic\n")
|
|
233
|
+
|
|
234
|
+
# Run from a different directory, but point --config to project_dir
|
|
235
|
+
monkeypatch.chdir(tmp_path)
|
|
236
|
+
runner = CliRunner()
|
|
237
|
+
result = runner.invoke(main, ["check", "--config", str(config_file)])
|
|
238
|
+
assert result.exit_code == 1
|
|
239
|
+
assert "src/app.py" in result.output
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
from pathlib import Path
|
|
2
2
|
|
|
3
|
-
from python_dependency_linter.config import load_config
|
|
3
|
+
from python_dependency_linter.config import find_config, load_config
|
|
4
4
|
|
|
5
5
|
FIXTURES = Path(__file__).parent / "fixtures"
|
|
6
6
|
|
|
@@ -81,3 +81,46 @@ def test_load_config_file_not_found():
|
|
|
81
81
|
|
|
82
82
|
with pytest.raises(FileNotFoundError):
|
|
83
83
|
load_config(Path("nonexistent.yaml"))
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def test_find_config_yaml_in_cwd(tmp_path, monkeypatch):
|
|
87
|
+
(tmp_path / ".python-dependency-linter.yaml").write_text("rules: []\n")
|
|
88
|
+
monkeypatch.chdir(tmp_path)
|
|
89
|
+
assert find_config() == tmp_path / ".python-dependency-linter.yaml"
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def test_find_config_yaml_in_parent(tmp_path, monkeypatch):
|
|
93
|
+
(tmp_path / ".python-dependency-linter.yaml").write_text("rules: []\n")
|
|
94
|
+
child = tmp_path / "sub"
|
|
95
|
+
child.mkdir()
|
|
96
|
+
monkeypatch.chdir(child)
|
|
97
|
+
assert find_config() == tmp_path / ".python-dependency-linter.yaml"
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def test_find_config_pyproject_toml(tmp_path, monkeypatch):
|
|
101
|
+
toml_content = "[tool.python-dependency-linter]\nrules = []\n"
|
|
102
|
+
(tmp_path / "pyproject.toml").write_text(toml_content)
|
|
103
|
+
monkeypatch.chdir(tmp_path)
|
|
104
|
+
assert find_config() == tmp_path / "pyproject.toml"
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def test_find_config_yaml_preferred_over_toml(tmp_path, monkeypatch):
|
|
108
|
+
"""When both exist in the same directory, YAML wins."""
|
|
109
|
+
(tmp_path / ".python-dependency-linter.yaml").write_text("rules: []\n")
|
|
110
|
+
toml_content = "[tool.python-dependency-linter]\nrules = []\n"
|
|
111
|
+
(tmp_path / "pyproject.toml").write_text(toml_content)
|
|
112
|
+
monkeypatch.chdir(tmp_path)
|
|
113
|
+
assert find_config() == tmp_path / ".python-dependency-linter.yaml"
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def test_find_config_not_found(tmp_path, monkeypatch):
|
|
117
|
+
monkeypatch.chdir(tmp_path)
|
|
118
|
+
result = find_config()
|
|
119
|
+
assert result is None
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def test_find_config_skips_pyproject_without_section(tmp_path, monkeypatch):
|
|
123
|
+
"""pyproject.toml without [tool.python-dependency-linter] should be skipped."""
|
|
124
|
+
(tmp_path / "pyproject.toml").write_text("[tool.other]\nfoo = 1\n")
|
|
125
|
+
monkeypatch.chdir(tmp_path)
|
|
126
|
+
assert find_config() is None
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
from python_dependency_linter.config import AllowDeny, Rule
|
|
2
2
|
from python_dependency_linter.matcher import (
|
|
3
3
|
find_matching_rules,
|
|
4
|
+
match_pattern_with_captures,
|
|
4
5
|
matches_pattern,
|
|
5
6
|
merge_rules,
|
|
6
7
|
)
|
|
@@ -73,8 +74,8 @@ def test_find_matching_rules():
|
|
|
73
74
|
]
|
|
74
75
|
matched = find_matching_rules("contexts.boards.domain", rules)
|
|
75
76
|
assert len(matched) == 2
|
|
76
|
-
assert matched[0].name == "r1"
|
|
77
|
-
assert matched[1].name == "r2"
|
|
77
|
+
assert matched[0][0].name == "r1"
|
|
78
|
+
assert matched[1][0].name == "r2"
|
|
78
79
|
|
|
79
80
|
|
|
80
81
|
def test_merge_rules_merges_allow():
|
|
@@ -117,3 +118,99 @@ def test_merge_rules_merges_deny():
|
|
|
117
118
|
merged = merge_rules([rule1, rule2])
|
|
118
119
|
|
|
119
120
|
assert sorted(merged.deny.third_party) == ["boto3", "requests"]
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def test_capture_single():
|
|
124
|
+
result = match_pattern_with_captures(
|
|
125
|
+
"src.contexts.{context}.domain", "src.contexts.analytics.domain"
|
|
126
|
+
)
|
|
127
|
+
assert result == {"context": "analytics"}
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def test_capture_multiple():
|
|
131
|
+
result = match_pattern_with_captures(
|
|
132
|
+
"src.contexts.{ctx}.adapters.{dir}", "src.contexts.auth.adapters.inbound"
|
|
133
|
+
)
|
|
134
|
+
assert result == {"ctx": "auth", "dir": "inbound"}
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def test_capture_duplicate_name_consistent():
|
|
138
|
+
result = match_pattern_with_captures("src.{a}.middle.{a}", "src.foo.middle.foo")
|
|
139
|
+
assert result == {"a": "foo"}
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def test_capture_duplicate_name_inconsistent():
|
|
143
|
+
result = match_pattern_with_captures("src.{a}.middle.{a}", "src.foo.middle.bar")
|
|
144
|
+
assert result is None
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def test_capture_no_match():
|
|
148
|
+
result = match_pattern_with_captures(
|
|
149
|
+
"src.contexts.{context}.domain", "src.utils.helpers"
|
|
150
|
+
)
|
|
151
|
+
assert result is None
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def test_capture_no_captures_with_star():
|
|
155
|
+
result = match_pattern_with_captures("src.*.domain", "src.analytics.domain")
|
|
156
|
+
assert result == {}
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def test_capture_coexist_with_star():
|
|
160
|
+
result = match_pattern_with_captures(
|
|
161
|
+
"src.{ctx}.*.domain", "src.auth.adapters.domain"
|
|
162
|
+
)
|
|
163
|
+
assert result == {"ctx": "auth"}
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def test_capture_coexist_with_double_star():
|
|
167
|
+
result = match_pattern_with_captures(
|
|
168
|
+
"src.{ctx}.**.domain", "src.auth.deep.nested.domain"
|
|
169
|
+
)
|
|
170
|
+
assert result == {"ctx": "auth"}
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def test_capture_exact_no_wildcards():
|
|
174
|
+
result = match_pattern_with_captures(
|
|
175
|
+
"src.contexts.analytics.domain", "src.contexts.analytics.domain"
|
|
176
|
+
)
|
|
177
|
+
assert result == {}
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def test_capture_exact_no_wildcards_no_match():
|
|
181
|
+
result = match_pattern_with_captures(
|
|
182
|
+
"src.contexts.analytics.domain", "src.contexts.auth.domain"
|
|
183
|
+
)
|
|
184
|
+
assert result is None
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def test_find_matching_rules_with_captures():
|
|
188
|
+
rules = [
|
|
189
|
+
Rule(
|
|
190
|
+
name="domain-layer",
|
|
191
|
+
modules="contexts.{context}.domain",
|
|
192
|
+
allow=AllowDeny(local=["contexts.{context}.domain"]),
|
|
193
|
+
),
|
|
194
|
+
Rule(
|
|
195
|
+
name="adapters",
|
|
196
|
+
modules="contexts.*.adapters",
|
|
197
|
+
deny=AllowDeny(third_party=["boto3"]),
|
|
198
|
+
),
|
|
199
|
+
]
|
|
200
|
+
matched = find_matching_rules("contexts.boards.domain", rules)
|
|
201
|
+
assert len(matched) == 1
|
|
202
|
+
rule, captures = matched[0]
|
|
203
|
+
assert rule.name == "domain-layer"
|
|
204
|
+
assert captures == {"context": "boards"}
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def test_capture_after_double_star():
|
|
208
|
+
result = match_pattern_with_captures(
|
|
209
|
+
"src.**.{layer}.models", "src.deep.nested.domain.models"
|
|
210
|
+
)
|
|
211
|
+
assert result == {"layer": "domain"}
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def test_capture_after_double_star_backtrack():
|
|
215
|
+
result = match_pattern_with_captures("**.{x}.end", "a.b.c.end")
|
|
216
|
+
assert result == {"x": "c"}
|
{python_dependency_linter-0.3.0 → python_dependency_linter-0.5.0}/.claude/skills/commit/SKILL.md
RENAMED
|
File without changes
|
{python_dependency_linter-0.3.0 → python_dependency_linter-0.5.0}/.claude/skills/release/SKILL.md
RENAMED
|
File without changes
|
|
File without changes
|
{python_dependency_linter-0.3.0 → python_dependency_linter-0.5.0}/.github/ISSUE_TEMPLATE/config.yml
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_dependency_linter-0.3.0 → python_dependency_linter-0.5.0}/.github/pull_request_template.md
RENAMED
|
File without changes
|
|
File without changes
|
{python_dependency_linter-0.3.0 → python_dependency_linter-0.5.0}/.github/workflows/publish.yaml
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_dependency_linter-0.3.0 → python_dependency_linter-0.5.0}/python_dependency_linter/parser.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_dependency_linter-0.3.0 → python_dependency_linter-0.5.0}/tests/fixtures/sample_config.yaml
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|