python-dependency-linter 0.2.0__tar.gz → 0.4.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 (58) hide show
  1. python_dependency_linter-0.4.0/.claude/skills/commit/SKILL.md +24 -0
  2. python_dependency_linter-0.4.0/.claude/skills/release/SKILL.md +14 -0
  3. python_dependency_linter-0.4.0/.github/pull_request_template.md +27 -0
  4. python_dependency_linter-0.4.0/CHANGELOG.md +94 -0
  5. python_dependency_linter-0.4.0/CLAUDE.md +9 -0
  6. python_dependency_linter-0.4.0/CONTRIBUTING.md +84 -0
  7. {python_dependency_linter-0.2.0 → python_dependency_linter-0.4.0}/PKG-INFO +43 -8
  8. {python_dependency_linter-0.2.0 → python_dependency_linter-0.4.0}/README.md +42 -7
  9. {python_dependency_linter-0.2.0 → python_dependency_linter-0.4.0}/python_dependency_linter/cli.py +62 -16
  10. {python_dependency_linter-0.2.0 → python_dependency_linter-0.4.0}/python_dependency_linter/config.py +43 -4
  11. python_dependency_linter-0.4.0/python_dependency_linter/parser.py +60 -0
  12. python_dependency_linter-0.4.0/tests/fixtures/sample_project/contexts/boards/__init__.py +1 -0
  13. python_dependency_linter-0.4.0/tests/fixtures/sample_project/contexts/boards/adapters/repository.py +7 -0
  14. python_dependency_linter-0.4.0/tests/test_cli.py +239 -0
  15. python_dependency_linter-0.4.0/tests/test_config.py +126 -0
  16. python_dependency_linter-0.4.0/tests/test_parser.py +74 -0
  17. python_dependency_linter-0.4.0/uv.lock +368 -0
  18. python_dependency_linter-0.2.0/.github/pull_request_template.md +0 -13
  19. python_dependency_linter-0.2.0/CHANGELOG.md +0 -5
  20. python_dependency_linter-0.2.0/python_dependency_linter/parser.py +0 -27
  21. python_dependency_linter-0.2.0/tests/fixtures/sample_project/contexts/boards/adapters/repository.py +0 -3
  22. python_dependency_linter-0.2.0/tests/fixtures/sample_project/contexts/boards/domain/__init__.py +0 -0
  23. python_dependency_linter-0.2.0/tests/test_cli.py +0 -69
  24. python_dependency_linter-0.2.0/tests/test_config.py +0 -43
  25. python_dependency_linter-0.2.0/tests/test_parser.py +0 -30
  26. {python_dependency_linter-0.2.0 → python_dependency_linter-0.4.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  27. {python_dependency_linter-0.2.0 → python_dependency_linter-0.4.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  28. {python_dependency_linter-0.2.0 → python_dependency_linter-0.4.0}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  29. {python_dependency_linter-0.2.0 → python_dependency_linter-0.4.0}/.github/dependabot.yml +0 -0
  30. {python_dependency_linter-0.2.0 → python_dependency_linter-0.4.0}/.github/workflows/ci.yaml +0 -0
  31. {python_dependency_linter-0.2.0 → python_dependency_linter-0.4.0}/.github/workflows/publish.yaml +0 -0
  32. {python_dependency_linter-0.2.0 → python_dependency_linter-0.4.0}/.gitignore +0 -0
  33. {python_dependency_linter-0.2.0 → python_dependency_linter-0.4.0}/.pre-commit-config.yaml +0 -0
  34. {python_dependency_linter-0.2.0 → python_dependency_linter-0.4.0}/.pre-commit-hooks.yaml +0 -0
  35. {python_dependency_linter-0.2.0 → python_dependency_linter-0.4.0}/LICENSE +0 -0
  36. {python_dependency_linter-0.2.0 → python_dependency_linter-0.4.0}/pyproject.toml +0 -0
  37. {python_dependency_linter-0.2.0 → python_dependency_linter-0.4.0}/python_dependency_linter/__init__.py +0 -0
  38. {python_dependency_linter-0.2.0 → python_dependency_linter-0.4.0}/python_dependency_linter/checker.py +0 -0
  39. {python_dependency_linter-0.2.0 → python_dependency_linter-0.4.0}/python_dependency_linter/matcher.py +0 -0
  40. {python_dependency_linter-0.2.0 → python_dependency_linter-0.4.0}/python_dependency_linter/reporter.py +0 -0
  41. {python_dependency_linter-0.2.0 → python_dependency_linter-0.4.0}/python_dependency_linter/resolver.py +0 -0
  42. {python_dependency_linter-0.2.0 → python_dependency_linter-0.4.0}/tests/fixtures/sample_config.yaml +0 -0
  43. {python_dependency_linter-0.2.0 → python_dependency_linter-0.4.0}/tests/fixtures/sample_project/contexts/__init__.py +0 -0
  44. {python_dependency_linter-0.2.0 → python_dependency_linter-0.4.0}/tests/fixtures/sample_project/contexts/auth/__init__.py +0 -0
  45. {python_dependency_linter-0.2.0 → python_dependency_linter-0.4.0}/tests/fixtures/sample_project/contexts/auth/application/__init__.py +0 -0
  46. {python_dependency_linter-0.2.0 → python_dependency_linter-0.4.0}/tests/fixtures/sample_project/contexts/auth/application/service.py +0 -0
  47. {python_dependency_linter-0.2.0 → python_dependency_linter-0.4.0}/tests/fixtures/sample_project/contexts/auth/domain/__init__.py +0 -0
  48. {python_dependency_linter-0.2.0 → python_dependency_linter-0.4.0}/tests/fixtures/sample_project/contexts/auth/domain/models.py +0 -0
  49. {python_dependency_linter-0.2.0/tests/fixtures/sample_project/contexts/boards → python_dependency_linter-0.4.0/tests/fixtures/sample_project/contexts/boards/adapters}/__init__.py +0 -0
  50. {python_dependency_linter-0.2.0/tests/fixtures/sample_project/contexts/boards/adapters → python_dependency_linter-0.4.0/tests/fixtures/sample_project/contexts/boards/application}/__init__.py +0 -0
  51. {python_dependency_linter-0.2.0 → python_dependency_linter-0.4.0}/tests/fixtures/sample_project/contexts/boards/application/service.py +0 -0
  52. {python_dependency_linter-0.2.0/tests/fixtures/sample_project/contexts/boards/application → python_dependency_linter-0.4.0/tests/fixtures/sample_project/contexts/boards/domain}/__init__.py +0 -0
  53. {python_dependency_linter-0.2.0 → python_dependency_linter-0.4.0}/tests/fixtures/sample_project/contexts/boards/domain/models.py +0 -0
  54. {python_dependency_linter-0.2.0 → python_dependency_linter-0.4.0}/tests/fixtures/sample_pyproject.toml +0 -0
  55. {python_dependency_linter-0.2.0 → python_dependency_linter-0.4.0}/tests/test_checker.py +0 -0
  56. {python_dependency_linter-0.2.0 → python_dependency_linter-0.4.0}/tests/test_matcher.py +0 -0
  57. {python_dependency_linter-0.2.0 → python_dependency_linter-0.4.0}/tests/test_reporter.py +0 -0
  58. {python_dependency_linter-0.2.0 → python_dependency_linter-0.4.0}/tests/test_resolver.py +0 -0
@@ -0,0 +1,24 @@
1
+ ---
2
+ name: commit
3
+ description: Create a git commit following the project's commit convention
4
+ ---
5
+
6
+ Create a git commit following the project's commit convention defined in [CONTRIBUTING.md](../../../CONTRIBUTING.md).
7
+
8
+ ## Steps
9
+
10
+ 1. Read `CONTRIBUTING.md` to check the commit convention.
11
+ 2. Run `git status` and `git diff` to review all changes.
12
+ 3. Draft a commit message following the convention.
13
+ 4. Stage only relevant files (do NOT use `git add -A`). Exclude secrets and unrelated files.
14
+ 5. Commit using a HEREDOC for proper formatting:
15
+ ```bash
16
+ git commit -m "$(cat <<'EOF'
17
+ <commit message here>
18
+
19
+ Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
20
+ EOF
21
+ )"
22
+ ```
23
+ 6. Run `git status` to verify success.
24
+ 7. If pre-commit hook fails, fix the issue and create a **new** commit (never amend).
@@ -0,0 +1,14 @@
1
+ ---
2
+ name: release
3
+ description: Create a new release by calculating the next version from conventional commits and pushing a git tag
4
+ ---
5
+
6
+ Create a new release following the project's release process defined in [CONTRIBUTING.md](../../../CONTRIBUTING.md).
7
+
8
+ The release workflow is defined in [.github/workflows/publish.yaml](../../../.github/workflows/publish.yaml).
9
+
10
+ ## Steps
11
+
12
+ 1. Read `CONTRIBUTING.md` to check the release process.
13
+ 2. Follow the steps described in the Release section.
14
+ 3. Ask the user to confirm the version before creating and pushing the tag.
@@ -0,0 +1,27 @@
1
+ <!--
2
+ ⚠️ PR Title Convention
3
+ PRs are squash merged, so the PR title becomes the final commit message.
4
+ Please use the following format:
5
+
6
+ <gitmoji> <type>: <Description>
7
+
8
+ Examples:
9
+ ✨ feat: Add support for relative imports
10
+ 🐛 fix: Use exit code 2 for config file not found
11
+
12
+ See CONTRIBUTING.md for the full list of types.
13
+ -->
14
+
15
+ ## What does this PR do?
16
+
17
+ <!-- A brief description of the change. -->
18
+
19
+ ## Related issue
20
+
21
+ <!-- Link to the issue this PR addresses, e.g., Closes #123 -->
22
+
23
+ ## Checklist
24
+
25
+ - [ ] Tests added / updated
26
+ - [ ] `pdl check` runs without errors on the example config
27
+ - [ ] Documentation updated (if applicable)
@@ -0,0 +1,94 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ ## [0.3.0] - 2026-03-30
6
+
7
+ ### Bug Fixes
8
+
9
+ - Use exit code 2 for config file not found (#11)
10
+
11
+ ### Documentation
12
+
13
+ - Add CONTRIBUTING.md and CLAUDE.md
14
+ - Add PR title convention to template and CONTRIBUTING.md
15
+ - Add release process to CONTRIBUTING.md and /release skill
16
+
17
+ ### Features
18
+
19
+ - Resolve relative imports to absolute module names (#10)
20
+ - Add include/exclude file filtering options (#12)
21
+
22
+ ### Miscellaneous
23
+
24
+ - Add /commit skill for Claude Code
25
+ - Add uv.lock for reproducible builds
26
+ ## [0.2.0] - 2026-03-30
27
+
28
+ ### Documentation
29
+
30
+ - Remove design spec and implementation plan docs
31
+ - Add GitHub issue and PR templates
32
+ - Add example for omitted category behavior in allow rules
33
+ - Add architecture examples to README
34
+ - Add pre-commit hook custom options example
35
+ - Expand pyproject.toml examples with multiple rules and deny
36
+ - Add "What It Does" section to README
37
+
38
+ ### Features
39
+
40
+ - Support `**` glob pattern for matching nested submodules (#6)
41
+ - Add undocumented features to README
42
+
43
+ ### Miscellaneous
44
+
45
+ - Use hatch-vcs for dynamic versioning from git tags
46
+ - Add gitmoji preprocessor to git-cliff config
47
+ - Move git-cliff config from cliff.toml to pyproject.toml
48
+ - Skip CHANGELOG update commits in git-cliff output
49
+
50
+ ### Testing
51
+
52
+ - Limit CI to source and test file changes
53
+
54
+ ### Build
55
+
56
+ - Bump actions/checkout from 4 to 6 (#3)
57
+ - Bump actions/setup-python from 5 to 6 (#2)
58
+ ## [0.1.0] - 2026-03-30
59
+
60
+ ### Bug Fixes
61
+
62
+ - Add isort config and fix plan typo
63
+ - Add tomli fallback for Python 3.10 compatibility
64
+ - Add import content to fixture files
65
+
66
+ ### CI/CD
67
+
68
+ - Add GitHub Actions for CI, PyPI publish, and Dependabot
69
+ - Add git-cliff changelog generation on release
70
+ - Add GitHub Release creation on tag
71
+
72
+ ### Documentation
73
+
74
+ - Add design spec for python-dependency-linter
75
+ - Add implementation plan
76
+ - Add README
77
+ - Add MIT LICENSE
78
+
79
+ ### Features
80
+
81
+ - Add config loading for YAML and pyproject.toml
82
+ - Add AST-based import parser
83
+ - Add import resolver for classification
84
+ - Add wildcard matcher and rule merging
85
+ - Add dependency checker with allow/deny logic
86
+ - Add violation reporter
87
+ - Add CLI with check command
88
+ - Add pre-commit hook definition
89
+
90
+ ### Miscellaneous
91
+
92
+ - Initialize project scaffolding
93
+ - Add license, readme, and classifiers to pyproject.toml
94
+ - Add .gitignore
@@ -0,0 +1,9 @@
1
+ # CLAUDE.md
2
+
3
+ ## Project Overview
4
+
5
+ Python dependency linter (`pdl`) - A CLI tool that lints layer dependency rules in Python projects.
6
+
7
+ ## Contributing
8
+
9
+ Follow the conventions in [CONTRIBUTING.md](./CONTRIBUTING.md).
@@ -0,0 +1,84 @@
1
+ # Contributing
2
+
3
+ ## Commit Convention
4
+
5
+ Commit messages must follow [Conventional Commits](https://www.conventionalcommits.org/) with [gitmoji](https://gitmoji.dev/) prefix.
6
+
7
+ ### Format
8
+
9
+ ```
10
+ <gitmoji> <type>: <description>
11
+ ```
12
+
13
+ - The first letter after the colon must be **capitalized**.
14
+ - The description must be in **English**.
15
+
16
+ ### Types
17
+
18
+ | Gitmoji | Type | Description |
19
+ |---------|------------|--------------------------|
20
+ | ✨ | `feat` | New feature |
21
+ | 🐛 | `fix` | Bug fix |
22
+ | ♻️ | `refactor` | Code refactoring |
23
+ | 📝 | `docs` | Documentation |
24
+ | ✅ | `test` | Adding or updating tests |
25
+ | 🔧 | `chore` | Maintenance tasks |
26
+ | 👷 | `ci` | CI/CD changes |
27
+ | ⚡ | `perf` | Performance improvement |
28
+
29
+ ### Examples
30
+
31
+ ```
32
+ ✨ feat: Add support for relative imports
33
+ 🐛 fix: Use exit code 2 for config file not found
34
+ ♻️ refactor: Simplify module resolver logic
35
+ ```
36
+
37
+ ## Pull Request Convention
38
+
39
+ - PRs are always **squash merged**, so the PR title becomes the final commit message.
40
+ - PR titles must follow the same format as commit messages (`<gitmoji> <type>: <description>`).
41
+ - PR descriptions must be written in **English**.
42
+
43
+ ## Pre-commit Hooks
44
+
45
+ This project uses [pre-commit](https://pre-commit.com/) with [ruff](https://docs.astral.sh/ruff/) for linting and formatting.
46
+
47
+ ```bash
48
+ # Install pre-commit hooks
49
+ pre-commit install
50
+
51
+ # Run manually
52
+ pre-commit run --all-files
53
+ ```
54
+
55
+ All commits must pass the pre-commit hooks before being accepted.
56
+
57
+ ## Release
58
+
59
+ Releases are automated via GitHub Actions. You only need to create and push a version tag.
60
+
61
+ ### Steps
62
+
63
+ 1. Calculate the next version based on conventional commits:
64
+ ```bash
65
+ uvx git-cliff --bumped-version
66
+ ```
67
+ 2. Review the commits since the last tag:
68
+ ```bash
69
+ git log $(git describe --tags --abbrev=0)..HEAD --oneline
70
+ ```
71
+ 3. Push the latest commits to `main`:
72
+ ```bash
73
+ git push origin main
74
+ ```
75
+ 4. Create and push the tag:
76
+ ```bash
77
+ git tag <version>
78
+ git push origin <version>
79
+ ```
80
+
81
+ The GitHub Actions workflow will then automatically:
82
+ - Generate `CHANGELOG.md` and commit it to `main`
83
+ - Create a GitHub Release with release notes
84
+ - Publish the package to PyPI
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-dependency-linter
3
- Version: 0.2.0
3
+ Version: 0.4.0
4
4
  Summary: A dependency linter for Python projects
5
5
  License-Expression: MIT
6
6
  License-File: LICENSE
@@ -148,6 +148,35 @@ rules:
148
148
 
149
149
  ## Configuration
150
150
 
151
+ ### Include / Exclude
152
+
153
+ Control which files are scanned using `include` and `exclude`:
154
+
155
+ ```yaml
156
+ include:
157
+ - src
158
+ exclude:
159
+ - src/generated/**
160
+
161
+ rules:
162
+ - name: ...
163
+ ```
164
+
165
+ - **No `include` or `exclude`** — All `.py` files under the project root are scanned
166
+ - **`include` only** — Only files matching the given paths are scanned
167
+ - **`exclude` only** — All files except those matching the given paths are scanned
168
+ - **Both** — `include` is applied first, then `exclude` filters within that result
169
+
170
+ Bare directory names (e.g., `src`) and trailing-slash forms (e.g., `src/`) are treated the same as `src/**`.
171
+
172
+ In `pyproject.toml`:
173
+
174
+ ```toml
175
+ [tool.python-dependency-linter]
176
+ include = ["src"]
177
+ exclude = ["src/generated/**"]
178
+ ```
179
+
151
180
  ### Rule Structure
152
181
 
153
182
  Each rule has:
@@ -177,6 +206,8 @@ Dependencies are classified into three categories (per PEP 8):
177
206
  - `third_party` — Installed packages (`pydantic`, `sqlalchemy`, ...)
178
207
  - `local` — Modules in your project
179
208
 
209
+ Both absolute imports (`from contexts.boards.domain import models`) and relative imports (`from ..domain import models`) are analyzed. Relative imports are resolved to absolute module names based on the file's location.
210
+
180
211
  ### Behavior
181
212
 
182
213
  - **No rule** — Everything is allowed
@@ -278,20 +309,24 @@ third_party = ["boto3"]
278
309
  ## CLI
279
310
 
280
311
  ```bash
281
- # Check with default config (.python-dependency-linter.yaml)
312
+ # Check with auto-discovered config (searches upward from cwd)
282
313
  pdl check
283
314
 
284
- # Specify config file
315
+ # Specify config file (project root = config file's parent directory)
285
316
  pdl check --config path/to/config.yaml
286
-
287
- # Specify project root
288
- pdl check --project-root path/to/project
289
317
  ```
290
318
 
291
319
  Exit codes:
292
320
 
293
321
  - `0` — No violations
294
322
  - `1` — Violations found
323
+ - `2` — Config file not found
324
+
325
+ 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`:
326
+
327
+ ```
328
+ Error: Config file not found. Create .python-dependency-linter.yaml or configure [tool.python-dependency-linter] in pyproject.toml.
329
+ ```
295
330
 
296
331
  ## Pre-commit
297
332
 
@@ -304,14 +339,14 @@ Add to `.pre-commit-config.yaml`:
304
339
  - id: python-dependency-linter
305
340
  ```
306
341
 
307
- To pass custom options (e.g., a different config file or project root):
342
+ To pass custom options (e.g., a different config file):
308
343
 
309
344
  ```yaml
310
345
  - repo: https://github.com/heumsi/python-dependency-linter
311
346
  rev: v0.1.0
312
347
  hooks:
313
348
  - id: python-dependency-linter
314
- args: [--config, custom-config.yaml, --project-root, src]
349
+ args: [--config, custom-config.yaml]
315
350
  ```
316
351
 
317
352
  ## License
@@ -123,6 +123,35 @@ rules:
123
123
 
124
124
  ## Configuration
125
125
 
126
+ ### Include / Exclude
127
+
128
+ Control which files are scanned using `include` and `exclude`:
129
+
130
+ ```yaml
131
+ include:
132
+ - src
133
+ exclude:
134
+ - src/generated/**
135
+
136
+ rules:
137
+ - name: ...
138
+ ```
139
+
140
+ - **No `include` or `exclude`** — All `.py` files under the project root are scanned
141
+ - **`include` only** — Only files matching the given paths are scanned
142
+ - **`exclude` only** — All files except those matching the given paths are scanned
143
+ - **Both** — `include` is applied first, then `exclude` filters within that result
144
+
145
+ Bare directory names (e.g., `src`) and trailing-slash forms (e.g., `src/`) are treated the same as `src/**`.
146
+
147
+ In `pyproject.toml`:
148
+
149
+ ```toml
150
+ [tool.python-dependency-linter]
151
+ include = ["src"]
152
+ exclude = ["src/generated/**"]
153
+ ```
154
+
126
155
  ### Rule Structure
127
156
 
128
157
  Each rule has:
@@ -152,6 +181,8 @@ Dependencies are classified into three categories (per PEP 8):
152
181
  - `third_party` — Installed packages (`pydantic`, `sqlalchemy`, ...)
153
182
  - `local` — Modules in your project
154
183
 
184
+ Both absolute imports (`from contexts.boards.domain import models`) and relative imports (`from ..domain import models`) are analyzed. Relative imports are resolved to absolute module names based on the file's location.
185
+
155
186
  ### Behavior
156
187
 
157
188
  - **No rule** — Everything is allowed
@@ -253,20 +284,24 @@ third_party = ["boto3"]
253
284
  ## CLI
254
285
 
255
286
  ```bash
256
- # Check with default config (.python-dependency-linter.yaml)
287
+ # Check with auto-discovered config (searches upward from cwd)
257
288
  pdl check
258
289
 
259
- # Specify config file
290
+ # Specify config file (project root = config file's parent directory)
260
291
  pdl check --config path/to/config.yaml
261
-
262
- # Specify project root
263
- pdl check --project-root path/to/project
264
292
  ```
265
293
 
266
294
  Exit codes:
267
295
 
268
296
  - `0` — No violations
269
297
  - `1` — Violations found
298
+ - `2` — Config file not found
299
+
300
+ 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`:
301
+
302
+ ```
303
+ Error: Config file not found. Create .python-dependency-linter.yaml or configure [tool.python-dependency-linter] in pyproject.toml.
304
+ ```
270
305
 
271
306
  ## Pre-commit
272
307
 
@@ -279,14 +314,14 @@ Add to `.pre-commit-config.yaml`:
279
314
  - id: python-dependency-linter
280
315
  ```
281
316
 
282
- To pass custom options (e.g., a different config file or project root):
317
+ To pass custom options (e.g., a different config file):
283
318
 
284
319
  ```yaml
285
320
  - repo: https://github.com/heumsi/python-dependency-linter
286
321
  rev: v0.1.0
287
322
  hooks:
288
323
  - id: python-dependency-linter
289
- args: [--config, custom-config.yaml, --project-root, src]
324
+ args: [--config, custom-config.yaml]
290
325
  ```
291
326
 
292
327
  ## License
@@ -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
@@ -36,8 +37,43 @@ def _package_module(file_path: Path, project_root: Path) -> str:
36
37
  return ".".join(parts)
37
38
 
38
39
 
39
- def _find_python_files(project_root: Path) -> list[Path]:
40
- return sorted(project_root.rglob("*.py"))
40
+ def _normalize_pattern(pattern: str, project_root: Path) -> str:
41
+ """Normalize a pattern so that bare directory names match all files within."""
42
+ clean = pattern.rstrip("/")
43
+ candidate = project_root / clean
44
+ if candidate.is_dir() or not any(c in clean for c in ("*", "?")):
45
+ clean = f"{clean}/**"
46
+ return clean
47
+
48
+
49
+ def _matches_any(path: Path, patterns: list[str]) -> bool:
50
+ return any(fnmatch.fnmatch(str(path), p) for p in patterns)
51
+
52
+
53
+ def _find_python_files(
54
+ project_root: Path,
55
+ include: list[str] | None = None,
56
+ exclude: list[str] | None = None,
57
+ ) -> list[Path]:
58
+ all_files = sorted(project_root.rglob("*.py"))
59
+
60
+ if include is not None:
61
+ normalized = [_normalize_pattern(p, project_root) for p in include]
62
+ all_files = [
63
+ f
64
+ for f in all_files
65
+ if _matches_any(f.relative_to(project_root), normalized)
66
+ ]
67
+
68
+ if exclude is not None:
69
+ normalized = [_normalize_pattern(p, project_root) for p in exclude]
70
+ all_files = [
71
+ f
72
+ for f in all_files
73
+ if not _matches_any(f.relative_to(project_root), normalized)
74
+ ]
75
+
76
+ return all_files
41
77
 
42
78
 
43
79
  @click.group()
@@ -49,22 +85,32 @@ def main():
49
85
  @click.option(
50
86
  "--config",
51
87
  "config_path",
52
- default=".python-dependency-linter.yaml",
88
+ default=None,
53
89
  help="Path to config file.",
54
90
  )
55
- @click.option("--project-root", default=".", help="Project root directory.")
56
- def check(config_path: str, project_root: str):
57
- root = Path(project_root).resolve()
58
- config_file = Path(config_path)
59
-
60
- try:
61
- config = load_config(config_file)
62
- except FileNotFoundError as e:
63
- click.echo(f"Error: {e}", err=True)
64
- raise SystemExit(1)
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)
65
111
 
66
112
  all_violations = []
67
- python_files = _find_python_files(root)
113
+ python_files = _find_python_files(root, config.include, config.exclude)
68
114
 
69
115
  for file_path in python_files:
70
116
  module = _file_to_module(file_path, root)
@@ -74,7 +120,7 @@ def check(config_path: str, project_root: str):
74
120
  continue
75
121
 
76
122
  merged_rule = merge_rules(matching_rules)
77
- imports = parse_imports(file_path)
123
+ imports = parse_imports(file_path, root)
78
124
 
79
125
  file_violations = []
80
126
  for imp in imports:
@@ -24,6 +24,8 @@ class Rule:
24
24
  @dataclass
25
25
  class Config:
26
26
  rules: list[Rule]
27
+ include: list[str] | None = None
28
+ exclude: list[str] | None = None
27
29
 
28
30
 
29
31
  def _parse_allow_deny(data: dict | None) -> AllowDeny | None:
@@ -53,19 +55,56 @@ def _parse_rules(rules_data: list[dict]) -> list[Rule]:
53
55
  def _load_yaml(path: Path) -> Config:
54
56
  with open(path) as f:
55
57
  data = yaml.safe_load(f)
56
- return Config(rules=_parse_rules(data["rules"]))
58
+ return Config(
59
+ rules=_parse_rules(data["rules"]),
60
+ include=data.get("include"),
61
+ exclude=data.get("exclude"),
62
+ )
57
63
 
58
64
 
59
- def _load_pyproject_toml(path: Path) -> Config:
65
+ def _load_toml(path: Path) -> dict:
60
66
  try:
61
67
  import tomllib
62
68
  except ImportError:
63
69
  import tomli as tomllib # type: ignore[no-redef]
64
70
 
65
71
  with open(path, "rb") as f:
66
- data = tomllib.load(f)
72
+ return tomllib.load(f)
73
+
74
+
75
+ def _load_pyproject_toml(path: Path) -> Config:
76
+ data = _load_toml(path)
67
77
  tool_config = data["tool"]["python-dependency-linter"]
68
- return Config(rules=_parse_rules(tool_config["rules"]))
78
+ return Config(
79
+ rules=_parse_rules(tool_config["rules"]),
80
+ include=tool_config.get("include"),
81
+ exclude=tool_config.get("exclude"),
82
+ )
83
+
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
69
108
 
70
109
 
71
110
  def load_config(path: Path) -> Config:
@@ -0,0 +1,60 @@
1
+ from __future__ import annotations
2
+
3
+ import ast
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+
7
+
8
+ @dataclass(frozen=True)
9
+ class ImportInfo:
10
+ module: str
11
+ lineno: int
12
+
13
+
14
+ def _resolve_relative_import(
15
+ file_path: Path,
16
+ project_root: Path,
17
+ level: int,
18
+ module: str | None,
19
+ ) -> str | None:
20
+ """Resolve a relative import to an absolute module name.
21
+
22
+ Returns ``None`` when *level* exceeds the package depth (i.e. the
23
+ import would escape *project_root*).
24
+ """
25
+ relative = file_path.relative_to(project_root)
26
+ parts = list(relative.with_suffix("").parts)
27
+ parts = parts[:-1]
28
+
29
+ # level=1 means current package, level=2 means parent package, etc.
30
+ go_up = level - 1
31
+ if go_up >= len(parts):
32
+ return None
33
+
34
+ base_parts = parts[: len(parts) - go_up]
35
+ resolved = ".".join(base_parts)
36
+ if module:
37
+ resolved = f"{resolved}.{module}" if resolved else module
38
+ return resolved or None
39
+
40
+
41
+ def parse_imports(file_path: Path, project_root: Path) -> list[ImportInfo]:
42
+ source = file_path.read_text()
43
+ tree = ast.parse(source, filename=str(file_path))
44
+
45
+ imports = []
46
+ for node in ast.walk(tree):
47
+ if isinstance(node, ast.Import):
48
+ for alias in node.names:
49
+ imports.append(ImportInfo(module=alias.name, lineno=node.lineno))
50
+ elif isinstance(node, ast.ImportFrom):
51
+ if node.level and node.level > 0:
52
+ resolved = _resolve_relative_import(
53
+ file_path, project_root, node.level, node.module
54
+ )
55
+ if resolved is not None:
56
+ imports.append(ImportInfo(module=resolved, lineno=node.lineno))
57
+ elif node.module is not None:
58
+ imports.append(ImportInfo(module=node.module, lineno=node.lineno))
59
+
60
+ return imports
@@ -0,0 +1 @@
1
+ from .domain import models # noqa: F401