pymodularity 0.2.0__tar.gz → 0.3.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pymodularity-0.3.1/PKG-INFO +112 -0
- pymodularity-0.3.1/README.md +100 -0
- {pymodularity-0.2.0 → pymodularity-0.3.1}/pyproject.toml +11 -6
- {pymodularity-0.2.0 → pymodularity-0.3.1}/pyproject.toml.orig +17 -6
- {pymodularity-0.2.0 → pymodularity-0.3.1}/src/pymodularity/_check.py +25 -7
- {pymodularity-0.2.0 → pymodularity-0.3.1}/src/pymodularity/_deps.py +64 -4
- {pymodularity-0.2.0 → pymodularity-0.3.1}/src/pymodularity/_metrics.py +5 -3
- pymodularity-0.3.1/src/pymodularity/_parse/_resolve.py +175 -0
- pymodularity-0.3.1/src/pymodularity/_parse/attrs.py +144 -0
- {pymodularity-0.2.0 → pymodularity-0.3.1}/src/pymodularity/_parse/graph.py +108 -94
- pymodularity-0.3.1/src/pymodularity/_parse/imports.py +179 -0
- pymodularity-0.3.1/src/pymodularity/_parse/packages.py +94 -0
- {pymodularity-0.2.0 → pymodularity-0.3.1}/src/pymodularity/_structure.py +5 -4
- {pymodularity-0.2.0 → pymodularity-0.3.1}/src/pymodularity/cli.py +95 -27
- pymodularity-0.2.0/PKG-INFO +0 -85
- pymodularity-0.2.0/README.md +0 -73
- pymodularity-0.2.0/src/pymodularity/_parse/_resolve.py +0 -225
- pymodularity-0.2.0/src/pymodularity/_parse/packages.py +0 -32
- {pymodularity-0.2.0 → pymodularity-0.3.1}/src/pymodularity/__init__.py +0 -0
- {pymodularity-0.2.0 → pymodularity-0.3.1}/src/pymodularity/__main__.py +0 -0
- {pymodularity-0.2.0 → pymodularity-0.3.1}/src/pymodularity/_cycles.py +0 -0
- {pymodularity-0.2.0 → pymodularity-0.3.1}/src/pymodularity/_parse/__init__.py +0 -0
- {pymodularity-0.2.0 → pymodularity-0.3.1}/src/pymodularity/_parse/names.py +0 -0
- {pymodularity-0.2.0 → pymodularity-0.3.1}/src/pymodularity/_scores.py +0 -0
- {pymodularity-0.2.0 → pymodularity-0.3.1}/src/pymodularity/_view_edges.py +0 -0
- {pymodularity-0.2.0 → pymodularity-0.3.1}/src/pymodularity/_views/__init__.py +0 -0
- {pymodularity-0.2.0 → pymodularity-0.3.1}/src/pymodularity/_views/_summary.py +0 -0
- {pymodularity-0.2.0 → pymodularity-0.3.1}/src/pymodularity/_views/render.py +0 -0
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pymodularity
|
|
3
|
+
Version: 0.3.1
|
|
4
|
+
Summary: See and check a repo's real Python imports: a privacy convention on PEP 8 underscores, an import-cycle ban, and a coupling report, all derived from the code.
|
|
5
|
+
Author: Tilo Himmelsbach
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Requires-Python: >=3.11
|
|
8
|
+
Project-URL: Homepage, https://github.com/dertilo/pymodularity
|
|
9
|
+
Project-URL: Repository, https://github.com/dertilo/pymodularity
|
|
10
|
+
Project-URL: Issues, https://github.com/dertilo/pymodularity/issues
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
|
|
13
|
+
# pymodularity
|
|
14
|
+
|
|
15
|
+
The underscore's position in a Python import path sets the import's scope.
|
|
16
|
+
model: Rust's `pub(super)` and `pub(crate)`, applied to PEP 8 underscores.
|
|
17
|
+
|
|
18
|
+
`pymodularity` does 3 jobs on a repo's real Python imports:
|
|
19
|
+
|
|
20
|
+
- it draws them as mermaid diagrams.
|
|
21
|
+
- it checks them against conventions derived from the code itself, with no allow-list to maintain.
|
|
22
|
+
- it reports which files and directories carry the most structural coupling.
|
|
23
|
+
|
|
24
|
+
The analysis needs only the standard library, so `pymodularity` has no runtime dependencies.
|
|
25
|
+
exception: `check`'s dependency rule needs `uv` on PATH.
|
|
26
|
+
|
|
27
|
+
## Install and run
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
uvx pymodularity check .
|
|
31
|
+
uvx pymodularity graph . --out docs/imports
|
|
32
|
+
uvx pymodularity metrics .
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Or, inside a project: `uv add --dev pymodularity` and run `pymodularity ...` in that project's venv.
|
|
36
|
+
|
|
37
|
+
## The rules `check` enforces
|
|
38
|
+
|
|
39
|
+
`pymodularity check .` exits 1 on any finding.
|
|
40
|
+
Each finding names the importing file, the target and the owner.
|
|
41
|
+
|
|
42
|
+
- **No import cycles:** between files, and between directories at every depth.
|
|
43
|
+
- `TYPE_CHECKING` imports and in-function imports count.
|
|
44
|
+
- no baseline: an existing cycle fails too.
|
|
45
|
+
- **Private items stay inside their owner:** only code inside the owner may import a `_`-prefixed module or name.
|
|
46
|
+
- private module: the owner is the directory holding it.
|
|
47
|
+
- private name from a package: the owner is the package directory.
|
|
48
|
+
- private name from a regular file: the owner is that file alone, so even a sibling file is a finding.
|
|
49
|
+
- attribute access such as `mod._x` counts like an import.
|
|
50
|
+
- **Declared dependencies match the imports:** [deptry](https://github.com/fpgmaas/deptry) reports unused, missing and transitive dependencies per package root.
|
|
51
|
+
- requirement: `uv` on PATH. Without it, `check` exits 2.
|
|
52
|
+
- `--no-deps` skips this rule.
|
|
53
|
+
- **Structure limits:** at most 500 lines per file, 10 entries per directory, 100 files per package root and 10 public names per file.
|
|
54
|
+
- no config, no baseline.
|
|
55
|
+
- ceiling: a limit marks the size that is clearly too much. It is no target.
|
|
56
|
+
- below a limit: a smaller interface is still better. `metrics` reports interface width and exposure share.
|
|
57
|
+
|
|
58
|
+
Worked cases: the [showcase](tests/showcase/README.md) shows each rule except the dependency rule as allowed and finding cases.
|
|
59
|
+
Definitions: [docs/rules.md](docs/rules.md) holds the counting rules, the deptry run, and the ruff rules for functions and classes.
|
|
60
|
+
|
|
61
|
+
## The 3 subcommands
|
|
62
|
+
|
|
63
|
+
All 3 also take a repeatable `--exclude PATTERN`, merged with `[tool.pymodularity] exclude` in `<repo>`'s own pyproject.toml.
|
|
64
|
+
Pattern: `fnmatch` against the POSIX path relative to `<repo>`, matched on the path itself or any parent directory.
|
|
65
|
+
Use case: generated code (an OpenAPI client, protobuf stubs) and vendored code.
|
|
66
|
+
Effect: an excluded tree is invisible to every rule and report, except deptry's scan of a kept package that contains it.
|
|
67
|
+
An import from kept code into it behaves like an import of code `pymodularity` can't resolve.
|
|
68
|
+
Details: [docs/rules.md](docs/rules.md#excluding-code).
|
|
69
|
+
|
|
70
|
+
- `pymodularity check <repo> [--no-deps]`: runs the 4 rules above and prints the findings.
|
|
71
|
+
- exit code: 1 on any finding.
|
|
72
|
+
- `--no-deps`: skips the dependency rule.
|
|
73
|
+
- `pymodularity graph <repo> --out DIR [--html]`: writes one mermaid view per directory under `DIR`.
|
|
74
|
+
- `index.md`: written for every view.
|
|
75
|
+
- `--html`: adds a clickable `index.html` per view. The browser needs network access to load mermaid from a CDN.
|
|
76
|
+
- `pymodularity metrics <repo>`: prints a coupling report to stdout.
|
|
77
|
+
- reach: transitive dependents and dependencies, plus a `score` per file and per directory.
|
|
78
|
+
- interface width: the names other code imports from a unit.
|
|
79
|
+
- exposure share: how much of what a file defines other code uses.
|
|
80
|
+
- entry files: how much of a directory outside code reaches into.
|
|
81
|
+
- misplaced files: files whose closest partners sit mostly in another directory.
|
|
82
|
+
- exit code: always 0, because the report is no gate.
|
|
83
|
+
|
|
84
|
+
## Blind spots
|
|
85
|
+
|
|
86
|
+
Worked cases: the [showcase](tests/showcase/README.md) gap cases G1 to G8, each tagged with its outcome.
|
|
87
|
+
|
|
88
|
+
`pymodularity` shows import coupling only.
|
|
89
|
+
Import coupling is a lower bound on logical coupling: "if A changes, must B change?"
|
|
90
|
+
Coupling that bypasses Python imports stays invisible:
|
|
91
|
+
|
|
92
|
+
- HTTP APIs, shared database schemas and tables, config keys and env vars, message and file formats, string-keyed registries, runtime `sys.path` edits.
|
|
93
|
+
- Two files with no edge can still force each other's edits. **Never read a missing edge as independence.**
|
|
94
|
+
|
|
95
|
+
Other limits:
|
|
96
|
+
|
|
97
|
+
- ambiguous import: `check` prints `warning: ambiguous import` to stderr and draws no edge.
|
|
98
|
+
- cause: several [import roots](docs/internals.md#how-an-absolute-import-resolves-to-a-file) provide the module, and none is an ancestor of the importer.
|
|
99
|
+
- runtime `sys.path` edit: an import that resolves only through such an edit gets an edge only if the inserted directory is an import root.
|
|
100
|
+
- script sibling: `python main.py` importing its sibling `execution.py`, with no package root nearby, gets no edge.
|
|
101
|
+
- reason: resolution looks only through import roots, never through the importer's own directory.
|
|
102
|
+
- tests: `pymodularity` always skips test directories (`tests/`, `test/`) and test files (`test_*.py`, `*_test.py`, `conftest.py`).
|
|
103
|
+
- reason: a test importing a sibling would draw a production dependency that production never has.
|
|
104
|
+
- `metrics` as a gate: its numbers move even on a repo that passes `check`.
|
|
105
|
+
- consequence: a gate on them rewards gaming the number, for example replacing an import with a string-keyed registry.
|
|
106
|
+
- attribute form, missed cases:
|
|
107
|
+
- an instance attribute such as `obj._y`, because `pymodularity` has no type information.
|
|
108
|
+
- complement for that case: ruff's `SLF001`. It has no owner scope, so it also flags access from inside the owner.
|
|
109
|
+
- a base name bound to a third-party module, or to a module outside the repo.
|
|
110
|
+
- `getattr(mod, "_x")` and other `importlib` access.
|
|
111
|
+
- a name the file also rebinds anywhere: a parameter, an assignment target, a `for`, `with`, `except` or comprehension target, a nested `def` or `class`.
|
|
112
|
+
- reason for the last case: a gate must trade false findings for missed ones, so `pymodularity` skips such a name.
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# pymodularity
|
|
2
|
+
|
|
3
|
+
The underscore's position in a Python import path sets the import's scope.
|
|
4
|
+
model: Rust's `pub(super)` and `pub(crate)`, applied to PEP 8 underscores.
|
|
5
|
+
|
|
6
|
+
`pymodularity` does 3 jobs on a repo's real Python imports:
|
|
7
|
+
|
|
8
|
+
- it draws them as mermaid diagrams.
|
|
9
|
+
- it checks them against conventions derived from the code itself, with no allow-list to maintain.
|
|
10
|
+
- it reports which files and directories carry the most structural coupling.
|
|
11
|
+
|
|
12
|
+
The analysis needs only the standard library, so `pymodularity` has no runtime dependencies.
|
|
13
|
+
exception: `check`'s dependency rule needs `uv` on PATH.
|
|
14
|
+
|
|
15
|
+
## Install and run
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
uvx pymodularity check .
|
|
19
|
+
uvx pymodularity graph . --out docs/imports
|
|
20
|
+
uvx pymodularity metrics .
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Or, inside a project: `uv add --dev pymodularity` and run `pymodularity ...` in that project's venv.
|
|
24
|
+
|
|
25
|
+
## The rules `check` enforces
|
|
26
|
+
|
|
27
|
+
`pymodularity check .` exits 1 on any finding.
|
|
28
|
+
Each finding names the importing file, the target and the owner.
|
|
29
|
+
|
|
30
|
+
- **No import cycles:** between files, and between directories at every depth.
|
|
31
|
+
- `TYPE_CHECKING` imports and in-function imports count.
|
|
32
|
+
- no baseline: an existing cycle fails too.
|
|
33
|
+
- **Private items stay inside their owner:** only code inside the owner may import a `_`-prefixed module or name.
|
|
34
|
+
- private module: the owner is the directory holding it.
|
|
35
|
+
- private name from a package: the owner is the package directory.
|
|
36
|
+
- private name from a regular file: the owner is that file alone, so even a sibling file is a finding.
|
|
37
|
+
- attribute access such as `mod._x` counts like an import.
|
|
38
|
+
- **Declared dependencies match the imports:** [deptry](https://github.com/fpgmaas/deptry) reports unused, missing and transitive dependencies per package root.
|
|
39
|
+
- requirement: `uv` on PATH. Without it, `check` exits 2.
|
|
40
|
+
- `--no-deps` skips this rule.
|
|
41
|
+
- **Structure limits:** at most 500 lines per file, 10 entries per directory, 100 files per package root and 10 public names per file.
|
|
42
|
+
- no config, no baseline.
|
|
43
|
+
- ceiling: a limit marks the size that is clearly too much. It is no target.
|
|
44
|
+
- below a limit: a smaller interface is still better. `metrics` reports interface width and exposure share.
|
|
45
|
+
|
|
46
|
+
Worked cases: the [showcase](tests/showcase/README.md) shows each rule except the dependency rule as allowed and finding cases.
|
|
47
|
+
Definitions: [docs/rules.md](docs/rules.md) holds the counting rules, the deptry run, and the ruff rules for functions and classes.
|
|
48
|
+
|
|
49
|
+
## The 3 subcommands
|
|
50
|
+
|
|
51
|
+
All 3 also take a repeatable `--exclude PATTERN`, merged with `[tool.pymodularity] exclude` in `<repo>`'s own pyproject.toml.
|
|
52
|
+
Pattern: `fnmatch` against the POSIX path relative to `<repo>`, matched on the path itself or any parent directory.
|
|
53
|
+
Use case: generated code (an OpenAPI client, protobuf stubs) and vendored code.
|
|
54
|
+
Effect: an excluded tree is invisible to every rule and report, except deptry's scan of a kept package that contains it.
|
|
55
|
+
An import from kept code into it behaves like an import of code `pymodularity` can't resolve.
|
|
56
|
+
Details: [docs/rules.md](docs/rules.md#excluding-code).
|
|
57
|
+
|
|
58
|
+
- `pymodularity check <repo> [--no-deps]`: runs the 4 rules above and prints the findings.
|
|
59
|
+
- exit code: 1 on any finding.
|
|
60
|
+
- `--no-deps`: skips the dependency rule.
|
|
61
|
+
- `pymodularity graph <repo> --out DIR [--html]`: writes one mermaid view per directory under `DIR`.
|
|
62
|
+
- `index.md`: written for every view.
|
|
63
|
+
- `--html`: adds a clickable `index.html` per view. The browser needs network access to load mermaid from a CDN.
|
|
64
|
+
- `pymodularity metrics <repo>`: prints a coupling report to stdout.
|
|
65
|
+
- reach: transitive dependents and dependencies, plus a `score` per file and per directory.
|
|
66
|
+
- interface width: the names other code imports from a unit.
|
|
67
|
+
- exposure share: how much of what a file defines other code uses.
|
|
68
|
+
- entry files: how much of a directory outside code reaches into.
|
|
69
|
+
- misplaced files: files whose closest partners sit mostly in another directory.
|
|
70
|
+
- exit code: always 0, because the report is no gate.
|
|
71
|
+
|
|
72
|
+
## Blind spots
|
|
73
|
+
|
|
74
|
+
Worked cases: the [showcase](tests/showcase/README.md) gap cases G1 to G8, each tagged with its outcome.
|
|
75
|
+
|
|
76
|
+
`pymodularity` shows import coupling only.
|
|
77
|
+
Import coupling is a lower bound on logical coupling: "if A changes, must B change?"
|
|
78
|
+
Coupling that bypasses Python imports stays invisible:
|
|
79
|
+
|
|
80
|
+
- HTTP APIs, shared database schemas and tables, config keys and env vars, message and file formats, string-keyed registries, runtime `sys.path` edits.
|
|
81
|
+
- Two files with no edge can still force each other's edits. **Never read a missing edge as independence.**
|
|
82
|
+
|
|
83
|
+
Other limits:
|
|
84
|
+
|
|
85
|
+
- ambiguous import: `check` prints `warning: ambiguous import` to stderr and draws no edge.
|
|
86
|
+
- cause: several [import roots](docs/internals.md#how-an-absolute-import-resolves-to-a-file) provide the module, and none is an ancestor of the importer.
|
|
87
|
+
- runtime `sys.path` edit: an import that resolves only through such an edit gets an edge only if the inserted directory is an import root.
|
|
88
|
+
- script sibling: `python main.py` importing its sibling `execution.py`, with no package root nearby, gets no edge.
|
|
89
|
+
- reason: resolution looks only through import roots, never through the importer's own directory.
|
|
90
|
+
- tests: `pymodularity` always skips test directories (`tests/`, `test/`) and test files (`test_*.py`, `*_test.py`, `conftest.py`).
|
|
91
|
+
- reason: a test importing a sibling would draw a production dependency that production never has.
|
|
92
|
+
- `metrics` as a gate: its numbers move even on a repo that passes `check`.
|
|
93
|
+
- consequence: a gate on them rewards gaming the number, for example replacing an import with a string-keyed registry.
|
|
94
|
+
- attribute form, missed cases:
|
|
95
|
+
- an instance attribute such as `obj._y`, because `pymodularity` has no type information.
|
|
96
|
+
- complement for that case: ruff's `SLF001`. It has no owner scope, so it also flags access from inside the owner.
|
|
97
|
+
- a base name bound to a third-party module, or to a module outside the repo.
|
|
98
|
+
- `getattr(mod, "_x")` and other `importlib` access.
|
|
99
|
+
- a name the file also rebinds anywhere: a parameter, an assignment target, a `for`, `with`, `except` or comprehension target, a nested `def` or `class`.
|
|
100
|
+
- reason for the last case: a gate must trade false findings for missed ones, so `pymodularity` skips such a name.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "pymodularity"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.3.1"
|
|
4
4
|
description = "See and check a repo's real Python imports: a privacy convention on PEP 8 underscores, an import-cycle ban, and a coupling report, all derived from the code."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = "MIT"
|
|
@@ -22,22 +22,27 @@ pymodularity = "pymodularity.cli:main"
|
|
|
22
22
|
dev = [
|
|
23
23
|
"pytest",
|
|
24
24
|
"ruff",
|
|
25
|
-
"
|
|
25
|
+
"ty==0.0.90",
|
|
26
26
|
]
|
|
27
27
|
|
|
28
28
|
[build-system]
|
|
29
29
|
requires = ["uv_build>=0.10.7,<0.11.0"]
|
|
30
30
|
build-backend = "uv_build"
|
|
31
31
|
|
|
32
|
+
[tool.pytest.ini_options]
|
|
33
|
+
pythonpath = ["tests"]
|
|
34
|
+
|
|
32
35
|
[tool.ruff]
|
|
33
36
|
line-length = 90
|
|
34
37
|
target-version = "py311"
|
|
38
|
+
exclude = ["tests/showcase"]
|
|
39
|
+
|
|
40
|
+
[tool.ty.environment]
|
|
41
|
+
python-version = "3.11"
|
|
35
42
|
|
|
36
|
-
[tool.
|
|
43
|
+
[tool.ty.src]
|
|
37
44
|
include = [
|
|
38
45
|
"src",
|
|
39
46
|
"tests",
|
|
40
47
|
]
|
|
41
|
-
|
|
42
|
-
reportUnusedCallResult = false
|
|
43
|
-
reportAny = false
|
|
48
|
+
exclude = ["tests/showcase"]
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "pymodularity"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.3.1"
|
|
4
4
|
description = "See and check a repo's real Python imports: a privacy convention on PEP 8 underscores, an import-cycle ban, and a coupling report, all derived from the code."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = "MIT"
|
|
@@ -17,18 +17,29 @@ Issues = "https://github.com/dertilo/pymodularity/issues"
|
|
|
17
17
|
pymodularity = "pymodularity.cli:main"
|
|
18
18
|
|
|
19
19
|
[dependency-groups]
|
|
20
|
-
dev = [
|
|
20
|
+
dev = [
|
|
21
|
+
"pytest",
|
|
22
|
+
"ruff",
|
|
23
|
+
"ty==0.0.90",
|
|
24
|
+
]
|
|
21
25
|
|
|
22
26
|
[build-system]
|
|
23
27
|
requires = ["uv_build>=0.10.7,<0.11.0"]
|
|
24
28
|
build-backend = "uv_build"
|
|
25
29
|
|
|
30
|
+
[tool.pytest.ini_options]
|
|
31
|
+
pythonpath = ["tests"]
|
|
32
|
+
|
|
26
33
|
[tool.ruff]
|
|
27
34
|
line-length = 90
|
|
28
35
|
target-version = "py311"
|
|
36
|
+
# a static fixture repo, not real project code: committed golden-test input
|
|
37
|
+
exclude = ["tests/showcase"]
|
|
38
|
+
|
|
39
|
+
[tool.ty.environment]
|
|
40
|
+
python-version = "3.11"
|
|
29
41
|
|
|
30
|
-
[tool.
|
|
42
|
+
[tool.ty.src]
|
|
31
43
|
include = ["src", "tests"]
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
reportAny = false
|
|
44
|
+
# a static fixture repo, not real project code: committed golden-test input
|
|
45
|
+
exclude = ["tests/showcase"]
|
|
@@ -34,15 +34,22 @@ def _visibility_scope(rel: Path, package_roots: Sequence[Path]) -> Path:
|
|
|
34
34
|
@dataclass(frozen=True, slots=True)
|
|
35
35
|
class _PrivacyViolation:
|
|
36
36
|
"""An import that reaches a module private to a directory it is not
|
|
37
|
-
under.
|
|
37
|
+
under. `via_attribute` marks a module reached through an attribute
|
|
38
|
+
chain (`import a; a._internal.x`) rather than an import statement
|
|
39
|
+
itself -- same scope rule, same `find_privacy_violations`, called a
|
|
40
|
+
second time on the attribute-form edges `_parse.graph` collects."""
|
|
38
41
|
|
|
39
42
|
importer: Path
|
|
40
43
|
target: Path
|
|
41
44
|
owner: Path
|
|
45
|
+
via_attribute: bool = False
|
|
42
46
|
|
|
43
47
|
|
|
44
48
|
def find_privacy_violations(
|
|
45
|
-
edge_names: EdgeNames,
|
|
49
|
+
edge_names: EdgeNames,
|
|
50
|
+
package_roots: Sequence[Path],
|
|
51
|
+
*,
|
|
52
|
+
via_attribute: bool = False,
|
|
46
53
|
) -> tuple[_PrivacyViolation, ...]:
|
|
47
54
|
violations: list[_PrivacyViolation] = []
|
|
48
55
|
scope_cache: dict[Path, Path] = {}
|
|
@@ -56,7 +63,9 @@ def find_privacy_violations(
|
|
|
56
63
|
scope = scope_of(target_rel)
|
|
57
64
|
if importer_rel.is_relative_to(scope):
|
|
58
65
|
continue
|
|
59
|
-
violations.append(
|
|
66
|
+
violations.append(
|
|
67
|
+
_PrivacyViolation(importer_rel, target_rel, scope, via_attribute)
|
|
68
|
+
)
|
|
60
69
|
|
|
61
70
|
violations.sort(key=lambda v: (str(v.importer), str(v.target)))
|
|
62
71
|
return tuple(violations)
|
|
@@ -84,16 +93,22 @@ def _name_owner(target_rel: Path) -> _NameOwner:
|
|
|
84
93
|
@dataclass(frozen=True, slots=True)
|
|
85
94
|
class _NameViolation:
|
|
86
95
|
"""An import of a private name (`from m import _x`) that reaches
|
|
87
|
-
outside the directory (package) or file that name is private to.
|
|
96
|
+
outside the directory (package) or file that name is private to.
|
|
97
|
+
`via_attribute` marks a name reached as `mod._x` rather than imported --
|
|
98
|
+
same scope rule, same `find_name_violations`, called a second time on
|
|
99
|
+
the attribute-form edges `_parse.graph` collects."""
|
|
88
100
|
|
|
89
101
|
importer: Path
|
|
90
102
|
target: Path
|
|
91
103
|
name: str
|
|
92
104
|
owner: Path
|
|
93
105
|
owner_is_dir: bool
|
|
106
|
+
via_attribute: bool = False
|
|
94
107
|
|
|
95
108
|
|
|
96
|
-
def find_name_violations(
|
|
109
|
+
def find_name_violations(
|
|
110
|
+
edge_names: EdgeNames, *, via_attribute: bool = False
|
|
111
|
+
) -> tuple[_NameViolation, ...]:
|
|
97
112
|
violations: list[_NameViolation] = []
|
|
98
113
|
for (importer_rel, target_rel), names in edge_names.items():
|
|
99
114
|
for name in names:
|
|
@@ -109,6 +124,7 @@ def find_name_violations(edge_names: EdgeNames) -> tuple[_NameViolation, ...]:
|
|
|
109
124
|
name,
|
|
110
125
|
owner_info.owner,
|
|
111
126
|
owner_info.owner_is_dir,
|
|
127
|
+
via_attribute,
|
|
112
128
|
)
|
|
113
129
|
)
|
|
114
130
|
violations.sort(key=lambda v: (str(v.importer), str(v.target), v.name))
|
|
@@ -149,12 +165,14 @@ def print_check_findings(
|
|
|
149
165
|
total_private = len(privacy_violations) + len(name_violations)
|
|
150
166
|
print(f"## Private-item violations: {total_private} finding(s)")
|
|
151
167
|
for v in privacy_violations:
|
|
152
|
-
|
|
168
|
+
suffix = " (via attribute access)" if v.via_attribute else ""
|
|
169
|
+
print(f"- {v.importer} -> {v.target}: allowed only inside {v.owner}{suffix}")
|
|
153
170
|
for nv in name_violations:
|
|
154
171
|
scope = "directory" if nv.owner_is_dir else "file"
|
|
172
|
+
suffix = " (via attribute access)" if nv.via_attribute else ""
|
|
155
173
|
print(
|
|
156
174
|
f"- {nv.importer} -> {nv.target}: name {nv.name} private to "
|
|
157
|
-
+ f"{scope} {nv.owner}"
|
|
175
|
+
+ f"{scope} {nv.owner}{suffix}"
|
|
158
176
|
)
|
|
159
177
|
print()
|
|
160
178
|
|
|
@@ -22,7 +22,7 @@ from dataclasses import dataclass, field
|
|
|
22
22
|
from pathlib import Path
|
|
23
23
|
from typing import cast
|
|
24
24
|
|
|
25
|
-
from pymodularity._parse.packages import find_package_roots
|
|
25
|
+
from pymodularity._parse.packages import find_package_roots, should_skip_dir
|
|
26
26
|
|
|
27
27
|
_DEPTRY_VERSION = "0.25.1"
|
|
28
28
|
|
|
@@ -85,6 +85,59 @@ def _has_runtime_dependencies(pyproject: Path) -> bool:
|
|
|
85
85
|
return isinstance(deps, list) and len(cast(list[object], deps)) > 0
|
|
86
86
|
|
|
87
87
|
|
|
88
|
+
def _top_level_package_names(repo: Path, pkg: Path) -> tuple[str, ...]:
|
|
89
|
+
"""Top-level first-party package/module names deptry should be told
|
|
90
|
+
about explicitly: deptry does not treat a hatch-autodetected src-layout
|
|
91
|
+
package (no explicit `[tool.hatch.build.targets.wheel] packages`) as
|
|
92
|
+
first-party on its own, so a module importing its own package is
|
|
93
|
+
wrongly reported as DEP003. `pkg/src` is the base if present, the same
|
|
94
|
+
src-layout check `_resolve.build_index` uses for import roots;
|
|
95
|
+
otherwise pkg itself."""
|
|
96
|
+
base = repo / pkg / "src"
|
|
97
|
+
if not base.is_dir():
|
|
98
|
+
base = repo / pkg
|
|
99
|
+
if not base.is_dir():
|
|
100
|
+
return ()
|
|
101
|
+
names: set[str] = set()
|
|
102
|
+
for entry in base.iterdir():
|
|
103
|
+
if entry.name.startswith(".") or should_skip_dir(entry.name):
|
|
104
|
+
continue
|
|
105
|
+
if entry.is_dir() and (entry / "__init__.py").is_file():
|
|
106
|
+
names.add(entry.name)
|
|
107
|
+
elif entry.is_file() and entry.suffix == ".py" and entry.stem != "__init__":
|
|
108
|
+
names.add(entry.stem)
|
|
109
|
+
return tuple(sorted(names))
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _configured_known_first_party(pyproject: Path) -> tuple[str, ...]:
|
|
113
|
+
"""`[tool.deptry] known_first_party` from the package's own
|
|
114
|
+
pyproject.toml. deptry's CLI `--known-first-party` replaces this
|
|
115
|
+
config value rather than merging with it (deptry's own
|
|
116
|
+
`read_configuration_from_pyproject_toml`: pyproject.toml overrides the
|
|
117
|
+
default, the command line overrides pyproject.toml -- the command line
|
|
118
|
+
always wins, it never merges), so callers must merge this in
|
|
119
|
+
themselves to avoid dropping a user's own setting."""
|
|
120
|
+
with pyproject.open("rb") as f:
|
|
121
|
+
data = tomllib.load(f)
|
|
122
|
+
tool = cast(dict[str, object], data.get("tool", {}))
|
|
123
|
+
deptry_cfg = cast(dict[str, object], tool.get("deptry", {}))
|
|
124
|
+
configured = deptry_cfg.get("known_first_party", [])
|
|
125
|
+
if not isinstance(configured, list):
|
|
126
|
+
return ()
|
|
127
|
+
return tuple(str(name) for name in cast(list[object], configured))
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _known_first_party_names(repo: Path, pkg: Path, pyproject: Path) -> tuple[str, ...]:
|
|
131
|
+
"""Union of the autodetected top-level package names and whatever the
|
|
132
|
+
package's own pyproject.toml already configures for deptry."""
|
|
133
|
+
return tuple(
|
|
134
|
+
sorted(
|
|
135
|
+
set(_top_level_package_names(repo, pkg))
|
|
136
|
+
| set(_configured_known_first_party(pyproject))
|
|
137
|
+
)
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
|
|
88
141
|
def _nested_exclusions(pkg: Path, package_roots: Sequence[Path]) -> tuple[Path, ...]:
|
|
89
142
|
"""Subdirectories of pkg that are themselves package roots, relative to
|
|
90
143
|
pkg, so a package's own scan does not re-scan a nested package."""
|
|
@@ -155,6 +208,7 @@ def _run_deptry_on(
|
|
|
155
208
|
cmd = [
|
|
156
209
|
"uv",
|
|
157
210
|
"run",
|
|
211
|
+
"--isolated",
|
|
158
212
|
"--directory",
|
|
159
213
|
str(pkg_abs),
|
|
160
214
|
"--with",
|
|
@@ -167,6 +221,8 @@ def _run_deptry_on(
|
|
|
167
221
|
for rel in _nested_exclusions(pkg, package_roots):
|
|
168
222
|
pattern = "^" + re.escape(rel.as_posix()) + "(/|$)"
|
|
169
223
|
cmd += ["--extend-exclude", pattern]
|
|
224
|
+
for name in _known_first_party_names(repo, pkg, pkg_abs / "pyproject.toml"):
|
|
225
|
+
cmd += ["--known-first-party", name]
|
|
170
226
|
json_out = tmp_dir / f"{pkg.as_posix().replace('/', '__') or 'root'}.json"
|
|
171
227
|
cmd += ["--json-output", str(json_out)]
|
|
172
228
|
|
|
@@ -189,13 +245,17 @@ class UvMissing:
|
|
|
189
245
|
all, never a silent skip."""
|
|
190
246
|
|
|
191
247
|
|
|
192
|
-
def run_dependency_checks(
|
|
248
|
+
def run_dependency_checks(
|
|
249
|
+
repo: Path, excludes: Sequence[str] = ()
|
|
250
|
+
) -> _DepsResult | UvMissing:
|
|
193
251
|
"""Scans every package root under repo with deptry, one uv-managed
|
|
194
|
-
environment per package.
|
|
252
|
+
environment per package. `excludes` prunes matching trees out of
|
|
253
|
+
package-root discovery first, so a package root entirely under an
|
|
254
|
+
excluded tree gets no deptry run at all."""
|
|
195
255
|
if shutil.which("uv") is None:
|
|
196
256
|
return UvMissing()
|
|
197
257
|
|
|
198
|
-
package_roots = find_package_roots(repo)
|
|
258
|
+
package_roots = find_package_roots(repo, excludes)
|
|
199
259
|
findings: list[_DepFinding] = []
|
|
200
260
|
skipped: list[_Skipped] = []
|
|
201
261
|
failed: list[_Failed] = []
|
|
@@ -254,7 +254,7 @@ def _compute_misplaced(
|
|
|
254
254
|
# tie-break deterministically: smallest path among the ties
|
|
255
255
|
other_dir = min(d for d, c in other_counts.items() if c == best)
|
|
256
256
|
other = best
|
|
257
|
-
if other >= 3 and other >
|
|
257
|
+
if other >= 3 and 2 * other > len(partners):
|
|
258
258
|
imported_by = sum(1 for p in importers if p.parent == other_dir)
|
|
259
259
|
imports = sum(1 for p in imports_of if p.parent == other_dir)
|
|
260
260
|
results.append(_Misplaced(f, own, other, other_dir, imported_by, imports))
|
|
@@ -434,8 +434,10 @@ def render_metrics(
|
|
|
434
434
|
lines.append(
|
|
435
435
|
"partners(f) = files f imports directly plus files importing f "
|
|
436
436
|
+ "directly; own = partners in f's own parent directory; other = "
|
|
437
|
-
+ "the largest count among all other parent directories
|
|
438
|
-
+ "
|
|
437
|
+
+ "the largest count among all other parent directories (a "
|
|
438
|
+
+ "subdirectory of f's own directory counts as other), naming "
|
|
439
|
+
+ "that directory; misplaced when other >= 3 and other exceeds "
|
|
440
|
+
+ "half of all partners. "
|
|
439
441
|
+ "'imported by' = those other-directory partners that import f; "
|
|
440
442
|
+ "'imports' = those other-directory partners f imports; a "
|
|
441
443
|
+ "partner doing both counts in both, so the two may sum to more "
|