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.
Files changed (50) hide show
  1. {python_dependency_linter-0.3.0 → python_dependency_linter-0.5.0}/CHANGELOG.md +30 -0
  2. {python_dependency_linter-0.3.0 → python_dependency_linter-0.5.0}/PKG-INFO +46 -15
  3. {python_dependency_linter-0.3.0 → python_dependency_linter-0.5.0}/README.md +45 -14
  4. {python_dependency_linter-0.3.0 → python_dependency_linter-0.5.0}/python_dependency_linter/checker.py +40 -0
  5. {python_dependency_linter-0.3.0 → python_dependency_linter-0.5.0}/python_dependency_linter/cli.py +31 -16
  6. {python_dependency_linter-0.3.0 → python_dependency_linter-0.5.0}/python_dependency_linter/config.py +31 -2
  7. {python_dependency_linter-0.3.0 → python_dependency_linter-0.5.0}/python_dependency_linter/matcher.py +42 -7
  8. {python_dependency_linter-0.3.0 → python_dependency_linter-0.5.0}/tests/test_checker.py +74 -1
  9. {python_dependency_linter-0.3.0 → python_dependency_linter-0.5.0}/tests/test_cli.py +92 -24
  10. {python_dependency_linter-0.3.0 → python_dependency_linter-0.5.0}/tests/test_config.py +44 -1
  11. {python_dependency_linter-0.3.0 → python_dependency_linter-0.5.0}/tests/test_matcher.py +99 -2
  12. {python_dependency_linter-0.3.0 → python_dependency_linter-0.5.0}/.claude/skills/commit/SKILL.md +0 -0
  13. {python_dependency_linter-0.3.0 → python_dependency_linter-0.5.0}/.claude/skills/release/SKILL.md +0 -0
  14. {python_dependency_linter-0.3.0 → python_dependency_linter-0.5.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  15. {python_dependency_linter-0.3.0 → python_dependency_linter-0.5.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  16. {python_dependency_linter-0.3.0 → python_dependency_linter-0.5.0}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  17. {python_dependency_linter-0.3.0 → python_dependency_linter-0.5.0}/.github/dependabot.yml +0 -0
  18. {python_dependency_linter-0.3.0 → python_dependency_linter-0.5.0}/.github/pull_request_template.md +0 -0
  19. {python_dependency_linter-0.3.0 → python_dependency_linter-0.5.0}/.github/workflows/ci.yaml +0 -0
  20. {python_dependency_linter-0.3.0 → python_dependency_linter-0.5.0}/.github/workflows/publish.yaml +0 -0
  21. {python_dependency_linter-0.3.0 → python_dependency_linter-0.5.0}/.gitignore +0 -0
  22. {python_dependency_linter-0.3.0 → python_dependency_linter-0.5.0}/.pre-commit-config.yaml +0 -0
  23. {python_dependency_linter-0.3.0 → python_dependency_linter-0.5.0}/.pre-commit-hooks.yaml +0 -0
  24. {python_dependency_linter-0.3.0 → python_dependency_linter-0.5.0}/CLAUDE.md +0 -0
  25. {python_dependency_linter-0.3.0 → python_dependency_linter-0.5.0}/CONTRIBUTING.md +0 -0
  26. {python_dependency_linter-0.3.0 → python_dependency_linter-0.5.0}/LICENSE +0 -0
  27. {python_dependency_linter-0.3.0 → python_dependency_linter-0.5.0}/pyproject.toml +0 -0
  28. {python_dependency_linter-0.3.0 → python_dependency_linter-0.5.0}/python_dependency_linter/__init__.py +0 -0
  29. {python_dependency_linter-0.3.0 → python_dependency_linter-0.5.0}/python_dependency_linter/parser.py +0 -0
  30. {python_dependency_linter-0.3.0 → python_dependency_linter-0.5.0}/python_dependency_linter/reporter.py +0 -0
  31. {python_dependency_linter-0.3.0 → python_dependency_linter-0.5.0}/python_dependency_linter/resolver.py +0 -0
  32. {python_dependency_linter-0.3.0 → python_dependency_linter-0.5.0}/tests/fixtures/sample_config.yaml +0 -0
  33. {python_dependency_linter-0.3.0 → python_dependency_linter-0.5.0}/tests/fixtures/sample_project/contexts/__init__.py +0 -0
  34. {python_dependency_linter-0.3.0 → python_dependency_linter-0.5.0}/tests/fixtures/sample_project/contexts/auth/__init__.py +0 -0
  35. {python_dependency_linter-0.3.0 → python_dependency_linter-0.5.0}/tests/fixtures/sample_project/contexts/auth/application/__init__.py +0 -0
  36. {python_dependency_linter-0.3.0 → python_dependency_linter-0.5.0}/tests/fixtures/sample_project/contexts/auth/application/service.py +0 -0
  37. {python_dependency_linter-0.3.0 → python_dependency_linter-0.5.0}/tests/fixtures/sample_project/contexts/auth/domain/__init__.py +0 -0
  38. {python_dependency_linter-0.3.0 → python_dependency_linter-0.5.0}/tests/fixtures/sample_project/contexts/auth/domain/models.py +0 -0
  39. {python_dependency_linter-0.3.0 → python_dependency_linter-0.5.0}/tests/fixtures/sample_project/contexts/boards/__init__.py +0 -0
  40. {python_dependency_linter-0.3.0 → python_dependency_linter-0.5.0}/tests/fixtures/sample_project/contexts/boards/adapters/__init__.py +0 -0
  41. {python_dependency_linter-0.3.0 → python_dependency_linter-0.5.0}/tests/fixtures/sample_project/contexts/boards/adapters/repository.py +0 -0
  42. {python_dependency_linter-0.3.0 → python_dependency_linter-0.5.0}/tests/fixtures/sample_project/contexts/boards/application/__init__.py +0 -0
  43. {python_dependency_linter-0.3.0 → python_dependency_linter-0.5.0}/tests/fixtures/sample_project/contexts/boards/application/service.py +0 -0
  44. {python_dependency_linter-0.3.0 → python_dependency_linter-0.5.0}/tests/fixtures/sample_project/contexts/boards/domain/__init__.py +0 -0
  45. {python_dependency_linter-0.3.0 → python_dependency_linter-0.5.0}/tests/fixtures/sample_project/contexts/boards/domain/models.py +0 -0
  46. {python_dependency_linter-0.3.0 → python_dependency_linter-0.5.0}/tests/fixtures/sample_pyproject.toml +0 -0
  47. {python_dependency_linter-0.3.0 → python_dependency_linter-0.5.0}/tests/test_parser.py +0 -0
  48. {python_dependency_linter-0.3.0 → python_dependency_linter-0.5.0}/tests/test_reporter.py +0 -0
  49. {python_dependency_linter-0.3.0 → python_dependency_linter-0.5.0}/tests/test_resolver.py +0 -0
  50. {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.0
3
+ Version: 0.5.0
4
4
  Summary: A dependency linter for Python projects
5
5
  License-Expression: MIT
6
6
  License-File: LICENSE
@@ -127,25 +127,30 @@ rules:
127
127
 
128
128
  Isolate domain from infrastructure. Ports (interfaces) live in domain, adapters depend on domain but not vice versa.
129
129
 
130
+ Using named captures (`{context}`), you can enforce that each bounded context only depends on its own domain — not other contexts' domains:
131
+
130
132
  ```yaml
131
133
  rules:
132
134
  - name: domain-no-infra
133
- modules: contexts.*.domain
135
+ modules: contexts.{context}.domain
134
136
  allow:
135
137
  standard_library: [dataclasses, typing, abc]
136
138
  third_party: []
137
- local: [contexts.*.domain]
139
+ local: [contexts.{context}.domain, shared.domain]
138
140
 
139
141
  - name: adapters-depend-on-domain
140
- modules: contexts.*.adapters
142
+ modules: contexts.{context}.adapters
141
143
  allow:
142
144
  standard_library: ["*"]
143
145
  third_party: ["*"]
144
146
  local:
145
- - contexts.*.adapters
146
- - contexts.*.domain
147
+ - contexts.{context}.adapters
148
+ - contexts.{context}.domain
149
+ - shared
147
150
  ```
148
151
 
152
+ With `{context}`, `contexts.boards.domain` can only import from `contexts.boards.domain` and `shared.domain` — not from `contexts.auth.domain`. See [Named Capture](#named-capture) for details.
153
+
149
154
  ## Configuration
150
155
 
151
156
  ### Include / Exclude
@@ -247,6 +252,35 @@ modules: contexts.*.domain # matches contexts.boards.domain, contexts.auth.doma
247
252
  modules: contexts.**.domain # matches contexts.boards.domain, contexts.boards.sub.domain, ...
248
253
  ```
249
254
 
255
+ ### Named Capture
256
+
257
+ `{name}` captures a single level (like `*`) and allows back-referencing the captured value in `allow` and `deny`:
258
+
259
+ ```yaml
260
+ rules:
261
+ - name: domain-isolation
262
+ modules: contexts.{context}.domain
263
+ allow:
264
+ local: [contexts.{context}.domain, shared.domain]
265
+ ```
266
+
267
+ When this rule matches `contexts.boards.domain`, `{context}` captures `"boards"`. The `allow` pattern `contexts.{context}.domain` resolves to `contexts.boards.domain`, so only the same context's domain is allowed.
268
+
269
+ You can use multiple captures in a single rule:
270
+
271
+ ```yaml
272
+ rules:
273
+ - name: bounded-context-layers
274
+ modules: contexts.{context}.{layer}
275
+ allow:
276
+ local:
277
+ - contexts.{context}.{layer}
278
+ - contexts.{context}.domain
279
+ - shared
280
+ ```
281
+
282
+ Named captures coexist with `*` and `**` wildcards. `{name}` always matches exactly one level.
283
+
250
284
  ### Submodule Matching
251
285
 
252
286
  When a pattern is used in `allow` or `deny`, it also matches submodules of the matched module. For example:
@@ -309,14 +343,11 @@ third_party = ["boto3"]
309
343
  ## CLI
310
344
 
311
345
  ```bash
312
- # Check with default config (.python-dependency-linter.yaml)
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 looks for `.python-dependency-linter.yaml` in the current directory. If the config file does not exist, the tool prints an error and exits with code `2`:
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: .python-dependency-linter.yaml
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 or project root):
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, --project-root, src]
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.*.domain
110
+ modules: contexts.{context}.domain
109
111
  allow:
110
112
  standard_library: [dataclasses, typing, abc]
111
113
  third_party: []
112
- local: [contexts.*.domain]
114
+ local: [contexts.{context}.domain, shared.domain]
113
115
 
114
116
  - name: adapters-depend-on-domain
115
- modules: contexts.*.adapters
117
+ modules: contexts.{context}.adapters
116
118
  allow:
117
119
  standard_library: ["*"]
118
120
  third_party: ["*"]
119
121
  local:
120
- - contexts.*.adapters
121
- - contexts.*.domain
122
+ - contexts.{context}.adapters
123
+ - contexts.{context}.domain
124
+ - shared
122
125
  ```
123
126
 
127
+ With `{context}`, `contexts.boards.domain` can only import from `contexts.boards.domain` and `shared.domain` — not from `contexts.auth.domain`. See [Named Capture](#named-capture) for details.
128
+
124
129
  ## Configuration
125
130
 
126
131
  ### Include / Exclude
@@ -222,6 +227,35 @@ modules: contexts.*.domain # matches contexts.boards.domain, contexts.auth.doma
222
227
  modules: contexts.**.domain # matches contexts.boards.domain, contexts.boards.sub.domain, ...
223
228
  ```
224
229
 
230
+ ### Named Capture
231
+
232
+ `{name}` captures a single level (like `*`) and allows back-referencing the captured value in `allow` and `deny`:
233
+
234
+ ```yaml
235
+ rules:
236
+ - name: domain-isolation
237
+ modules: contexts.{context}.domain
238
+ allow:
239
+ local: [contexts.{context}.domain, shared.domain]
240
+ ```
241
+
242
+ When this rule matches `contexts.boards.domain`, `{context}` captures `"boards"`. The `allow` pattern `contexts.{context}.domain` resolves to `contexts.boards.domain`, so only the same context's domain is allowed.
243
+
244
+ You can use multiple captures in a single rule:
245
+
246
+ ```yaml
247
+ rules:
248
+ - name: bounded-context-layers
249
+ modules: contexts.{context}.{layer}
250
+ allow:
251
+ local:
252
+ - contexts.{context}.{layer}
253
+ - contexts.{context}.domain
254
+ - shared
255
+ ```
256
+
257
+ Named captures coexist with `*` and `**` wildcards. `{name}` always matches exactly one level.
258
+
225
259
  ### Submodule Matching
226
260
 
227
261
  When a pattern is used in `allow` or `deny`, it also matches submodules of the matched module. For example:
@@ -284,14 +318,11 @@ third_party = ["boto3"]
284
318
  ## CLI
285
319
 
286
320
  ```bash
287
- # Check with default config (.python-dependency-linter.yaml)
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 looks for `.python-dependency-linter.yaml` in the current directory. If the config file does not exist, the tool prints an error and exits with code `2`:
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: .python-dependency-linter.yaml
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 or project root):
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, --project-root, src]
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)
@@ -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(path.match(p) for p in patterns)
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=".python-dependency-linter.yaml",
88
+ default=None,
88
89
  help="Path to config file.",
89
90
  )
90
- @click.option("--project-root", default=".", help="Project root directory.")
91
- def check(config_path: str, project_root: str):
92
- root = Path(project_root).resolve()
93
- config_file = Path(config_path)
94
-
95
- try:
96
- config = load_config(config_file)
97
- except FileNotFoundError as e:
98
- click.echo(f"Error: {e}", err=True)
99
- raise SystemExit(2)
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
- matching_rules = find_matching_rules(package, config.rules)
108
- if not matching_rules:
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
 
@@ -62,14 +62,18 @@ def _load_yaml(path: Path) -> Config:
62
62
  )
63
63
 
64
64
 
65
- def _load_pyproject_toml(path: Path) -> Config:
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
- data = tomllib.load(f)
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
- return _match(pattern_parts, module_parts)
17
+ captures: dict[str, str] = {}
18
+ if _match_with_captures(pattern_parts, module_parts, captures):
19
+ return captures
20
+ return None
10
21
 
11
22
 
12
- def _match(pattern_parts: list[str], module_parts: list[str]) -> bool:
23
+ def _match_with_captures(
24
+ pattern_parts: list[str],
25
+ module_parts: list[str],
26
+ captures: dict[str, str],
27
+ ) -> bool:
13
28
  if not pattern_parts and not module_parts:
14
29
  return True
15
30
  if not pattern_parts:
16
31
  return False
17
32
 
18
33
  if pattern_parts[0] == "**":
19
- # "**" matches one or more parts
20
34
  for i in range(1, len(module_parts) + 1):
21
- if _match(pattern_parts[1:], module_parts[i:]):
35
+ snapshot = dict(captures)
36
+ if _match_with_captures(pattern_parts[1:], module_parts[i:], captures):
22
37
  return True
38
+ captures.clear()
39
+ captures.update(snapshot)
23
40
  return False
24
41
 
25
42
  if not module_parts:
26
43
  return False
27
44
 
45
+ m = _CAPTURE_RE.match(pattern_parts[0])
46
+ if m:
47
+ name = m.group(1)
48
+ value = module_parts[0]
49
+ if name in captures:
50
+ if captures[name] != value:
51
+ return False
52
+ else:
53
+ captures[name] = value
54
+ return _match_with_captures(pattern_parts[1:], module_parts[1:], captures)
55
+
28
56
  if pattern_parts[0] == "*" or pattern_parts[0] == module_parts[0]:
29
- return _match(pattern_parts[1:], module_parts[1:])
57
+ return _match_with_captures(pattern_parts[1:], module_parts[1:], captures)
30
58
 
31
59
  return False
32
60
 
33
61
 
34
- def find_matching_rules(module: str, rules: list[Rule]) -> list[Rule]:
35
- return [r for r in rules if matches_pattern(r.modules, module)]
62
+ def find_matching_rules(
63
+ module: str, rules: list[Rule]
64
+ ) -> list[tuple[Rule, dict[str, str]]]:
65
+ result = []
66
+ for r in rules:
67
+ captures = match_pattern_with_captures(r.modules, module)
68
+ if captures is not None:
69
+ result.append((r, captures))
70
+ return result
36
71
 
37
72
 
38
73
  def _merge_allow_deny(
@@ -1,4 +1,4 @@
1
- from python_dependency_linter.checker import Violation, check_import
1
+ from python_dependency_linter.checker import Violation, check_import, resolve_captures
2
2
  from python_dependency_linter.config import AllowDeny, Rule
3
3
  from python_dependency_linter.parser import ImportInfo
4
4
  from python_dependency_linter.resolver import ImportCategory
@@ -150,3 +150,76 @@ def test_no_allow_for_category_means_allow_all():
150
150
  source_module="contexts.boards.domain",
151
151
  )
152
152
  assert result is None
153
+
154
+
155
+ def test_resolve_captures_single():
156
+ result = resolve_captures("src.contexts.{context}.domain", {"context": "analytics"})
157
+ assert result == "src.contexts.analytics.domain"
158
+
159
+
160
+ def test_resolve_captures_multiple():
161
+ result = resolve_captures(
162
+ "src.{ctx}.adapters.{dir}", {"ctx": "auth", "dir": "inbound"}
163
+ )
164
+ assert result == "src.auth.adapters.inbound"
165
+
166
+
167
+ def test_resolve_captures_no_placeholders():
168
+ result = resolve_captures("src.shared.domain", {"context": "analytics"})
169
+ assert result == "src.shared.domain"
170
+
171
+
172
+ def test_resolve_captures_unresolved_placeholder():
173
+ result = resolve_captures("src.{unknown}.domain", {"context": "analytics"})
174
+ assert result == "src.{unknown}.domain"
175
+
176
+
177
+ def test_cross_context_isolation_allowed():
178
+ """Same context's domain import should be allowed."""
179
+ rule = Rule(
180
+ name="domain-layer",
181
+ modules="contexts.{context}.domain",
182
+ allow=AllowDeny(local=["contexts.{context}.domain", "shared.domain"]),
183
+ )
184
+ result = check_import(
185
+ import_info=ImportInfo(module="contexts.boards.domain.models", lineno=1),
186
+ category=ImportCategory.LOCAL,
187
+ merged_rule=rule,
188
+ source_module="contexts.boards.domain",
189
+ captures={"context": "boards"},
190
+ )
191
+ assert result is None
192
+
193
+
194
+ def test_cross_context_isolation_violation():
195
+ """Different context's domain import should be denied."""
196
+ rule = Rule(
197
+ name="domain-layer",
198
+ modules="contexts.{context}.domain",
199
+ allow=AllowDeny(local=["contexts.{context}.domain", "shared.domain"]),
200
+ )
201
+ result = check_import(
202
+ import_info=ImportInfo(module="contexts.auth.domain.models", lineno=5),
203
+ category=ImportCategory.LOCAL,
204
+ merged_rule=rule,
205
+ source_module="contexts.boards.domain",
206
+ captures={"context": "boards"},
207
+ )
208
+ assert isinstance(result, Violation)
209
+ assert result.imported_module == "contexts.auth.domain.models"
210
+
211
+
212
+ def test_check_import_no_captures_backward_compat():
213
+ """Existing behavior works when no captures provided."""
214
+ rule = Rule(
215
+ name="domain-isolation",
216
+ modules="contexts.*.domain",
217
+ allow=AllowDeny(third_party=["pydantic"]),
218
+ )
219
+ result = check_import(
220
+ import_info=ImportInfo(module="pydantic", lineno=1),
221
+ category=ImportCategory.THIRD_PARTY,
222
+ merged_rule=rule,
223
+ source_module="contexts.boards.domain",
224
+ )
225
+ assert result is None
@@ -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 / "config.yaml"
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 test_cli_check_with_exclude(tmp_path):
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 / "config.yaml"
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 / "config.yaml"
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"}