pymodularity 0.2.0__py3-none-any.whl

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/cli.py ADDED
@@ -0,0 +1,276 @@
1
+ """pymodularity: see and check a repo's real Python imports.
2
+
3
+ Build one whole-repo Python import graph with ast (stdlib only) and
4
+ render it as mermaid views, one per directory, with a markdown link list
5
+ to zoom in/out. Nodes are file paths (or directory paths for namespace
6
+ packages without __init__.py); edge labels count distinct (importer file,
7
+ target file) pairs.
8
+ A non-ancestor candidate's root must lack __init__.py only when the
9
+ candidate itself is a single file (package candidates skip that filter).
10
+ Test directories (tests/test) and test files (test_*.py, *_test.py,
11
+ conftest.py) are skipped by default, no flag. `import a.b.c` must match
12
+ exactly; `from a.b import c` tries submodule `a.b.c` then falls back to
13
+ `a.b` exactly, never shorter. Each view opens with a summary: cycles are
14
+ strongly connected components (size >= 2) among the view's inside children
15
+ on the view's own edges, outside (dashed) nodes excluded; the node table
16
+ lists one row per inside child. Both sections carry their own legend text
17
+ in the rendered output defining each count; see that legend for units and
18
+ for the _SCC_FULL_EDGE_LIMIT threshold controlling full vs. collapsed
19
+ one-way-edge lists in Cycles.
20
+
21
+ WARNING, blind spot: the graph shows import coupling only, a lower bound on
22
+ logical coupling ("if A changes, must B?"). Coupling that bypasses Python
23
+ imports stays invisible: HTTP APIs, shared database schemas and tables,
24
+ config keys and env vars, message and file formats, string-keyed registries,
25
+ runtime sys.path edits. Two services with no edge here can still force each
26
+ other's edits. Never read a missing edge as independence.
27
+
28
+ Three subcommands:
29
+
30
+ `pymodularity graph <repo> --out DIR [--html]` writes the views under DIR.
31
+ `--html` also writes an index.html per view (mermaid diagram, clickable
32
+ directory nodes) alongside index.md; the browser needs network access to
33
+ load mermaid from a CDN.
34
+
35
+ `pymodularity check <repo>` runs five convention checks and prints findings
36
+ to stdout, exit 1 on any finding: (1) no import cycles, at every directory
37
+ level (any SCC with more than 1 node in any directory's view, computed the
38
+ same way the view's own Cycles section is, TYPE_CHECKING and
39
+ function-body imports counted, tests excluded as always; no baseline,
40
+ existing cycles fail); (2) a Go-`internal/`-style privacy convention,
41
+ applied to both modules and names (Rust's visibility rule on PEP 8
42
+ underscores): a private item is visible in the thing that defines it and
43
+ that thing's descendants, never in its parent or siblings. For modules: a
44
+ file whose path, relative to the nearest ancestor directory with a
45
+ pyproject.toml [project] table, has a component starting with a single `_`
46
+ (not a dunder) may only be imported by files under the directory holding
47
+ that component, repo-wide, across package boundaries. For names: `from m
48
+ import _x` (or `_x as y`, absolute or relative) where `_x` is not itself a
49
+ resolvable submodule of `m` (that case is the module rule above, not
50
+ double-counted here) -- if `m` resolves to a single file, `_x` is private
51
+ to that file and no other file may import it, including a sibling in the
52
+ same directory or that directory's own `__init__.py`; if `m` resolves to a
53
+ package (`__init__.py` or a namespace package directory), `_x` is private
54
+ to that directory and only files under it may import it. The attribute
55
+ form (`import a.b; a.b._x`) is out of scope here; ruff's SLF001 already
56
+ flags it. (3) dependency declarations, on by default, skipped with
57
+ `--no-deps`: per package root, shells out to a pinned deptry
58
+ (`uv run --directory <pkg> --with deptry==<version> --frozen deptry .
59
+ --json-output <tmp>`, nested package roots excluded via
60
+ `--extend-exclude`), needs `uv` on PATH (exit 2 if missing, never a
61
+ silent skip); the first run creates a `.venv` per scanned package and
62
+ downloads its dependencies. A package is skipped, not scanned, if it
63
+ declares no runtime dependencies or has no uv.lock of its own; any other
64
+ deptry failure, or unparseable JSON, is a finding-level failure. (4) and
65
+ (5) structure limits, fixed constants, no config, no baseline: at most 500
66
+ physical lines per file, 10 entries per directory (`__init__.py`/
67
+ `__main__.py` excluded), 100 scanned files per package root, 15 public
68
+ top-level names per file, and a public top-level name that no scanned
69
+ file imports (a file no scanned file imports at all, `__init__.py` and
70
+ `__main__.py`, and a name reachable through a whole-module import, are all
71
+ exempt).
72
+
73
+ `pymodularity metrics <repo>` prints a coupling report to stdout on the same
74
+ file graph (reach and score per file and per directory, interface width
75
+ per file and per directory, exposure share, entry files, misplaced files)
76
+ and always exits 0: it is a report, never a gate."""
77
+
78
+ import argparse
79
+ import sys
80
+ from pathlib import Path
81
+
82
+ from pymodularity._check import (
83
+ find_name_violations,
84
+ find_privacy_violations,
85
+ print_check_findings,
86
+ )
87
+ from pymodularity._cycles import count_file_level_cycles, find_all_cycle_findings
88
+ from pymodularity._deps import UvMissing, print_deps_findings, run_dependency_checks
89
+ from pymodularity._metrics import render_metrics
90
+ from pymodularity._parse.graph import build_graph
91
+ from pymodularity._parse.packages import find_package_roots
92
+ from pymodularity._scores import compute_scores
93
+ from pymodularity._structure import find_structure_findings, print_structure_findings
94
+ from pymodularity._view_edges import build_view
95
+ from pymodularity._views.render import write_view
96
+
97
+ __version__ = "0.2.0"
98
+
99
+
100
+ class _CheckArgs(argparse.Namespace):
101
+ # placeholder default for the static type only; argparse always
102
+ # overwrites it before parse_args() returns
103
+ repo: Path = Path()
104
+ deps: bool = True
105
+
106
+
107
+ class _GraphArgs(argparse.Namespace):
108
+ repo: Path = Path()
109
+ out: Path = Path()
110
+ html: bool = False
111
+
112
+
113
+ class _MetricsArgs(argparse.Namespace):
114
+ repo: Path = Path()
115
+
116
+
117
+ def _run_check(repo: Path, run_deps: bool) -> int:
118
+ """`pymodularity check <repo>`: the import-cycle, privacy-convention and
119
+ (unless `--no-deps`) dependency-declaration checks, findings printed to
120
+ stdout, exit 1 on any finding, no views written."""
121
+ graph = build_graph(repo)
122
+
123
+ cycle_findings = find_all_cycle_findings(graph.dirs_with_py, graph.edge_names)
124
+
125
+ package_roots = find_package_roots(repo)
126
+ privacy_violations = find_privacy_violations(graph.edge_names, package_roots)
127
+ name_violations = find_name_violations(graph.edge_names)
128
+ distinct_file_cycles = count_file_level_cycles(graph.adj)
129
+ any_finding = print_check_findings(
130
+ cycle_findings, distinct_file_cycles, privacy_violations, name_violations
131
+ )
132
+
133
+ deps_finding = False
134
+ if run_deps:
135
+ deps_result = run_dependency_checks(repo)
136
+ if isinstance(deps_result, UvMissing):
137
+ print(
138
+ "error: `uv` not found on PATH; the dependency-declaration "
139
+ + "rule needs it (use --no-deps to skip)",
140
+ file=sys.stderr,
141
+ )
142
+ return 2
143
+ deps_finding = print_deps_findings(deps_result)
144
+
145
+ structure_findings = find_structure_findings(
146
+ repo,
147
+ graph.py_files,
148
+ graph.dirs_with_py,
149
+ graph.defined_names,
150
+ graph.registered_names,
151
+ )
152
+ structure_finding = print_structure_findings(structure_findings)
153
+
154
+ return 1 if any_finding or deps_finding or structure_finding else 0
155
+
156
+
157
+ def _run_graph(repo: Path, out_dir: Path, write_html: bool) -> int:
158
+ """`pymodularity graph <repo> --out DIR [--html]`: writes one mermaid
159
+ view per directory under DIR."""
160
+ out_dir.mkdir(parents=True, exist_ok=True)
161
+ graph = build_graph(repo)
162
+ scores = compute_scores(graph.dirs_with_py, graph.adj, graph.radj)
163
+
164
+ written = 0
165
+ for directory in sorted(graph.dirs_with_py, key=str):
166
+ pair_edges = build_view(directory, graph.edge_names)
167
+ write_view(
168
+ out_dir,
169
+ repo,
170
+ directory,
171
+ graph.dirs_with_py,
172
+ pair_edges,
173
+ graph.edge_names,
174
+ write_html,
175
+ scores.file_scores,
176
+ scores.dir_scores,
177
+ )
178
+ written += 1
179
+
180
+ print(f"wrote {written} views under {out_dir}", file=sys.stderr)
181
+ return 0
182
+
183
+
184
+ def _run_metrics(repo: Path) -> int:
185
+ """`pymodularity metrics <repo>`: prints a coupling report to stdout,
186
+ always exits 0."""
187
+ graph = build_graph(repo)
188
+ scores = compute_scores(graph.dirs_with_py, graph.adj, graph.radj)
189
+
190
+ print(
191
+ "\n".join(
192
+ render_metrics(
193
+ graph.dirs_with_py,
194
+ graph.edge_names,
195
+ scores.file_scores,
196
+ scores.dir_scores,
197
+ graph.defined_names,
198
+ graph.adj,
199
+ graph.radj,
200
+ )
201
+ )
202
+ )
203
+ return 0
204
+
205
+
206
+ def main() -> int:
207
+ parser = argparse.ArgumentParser(
208
+ prog="pymodularity",
209
+ description="Check and measure a Python repo's imports: no import cycles, "
210
+ + "underscore privacy scoped like Rust's pub(super)/pub(crate), coupling metrics.",
211
+ epilog="Rules, metrics and blind spots: https://github.com/dertilo/pymodularity",
212
+ )
213
+ parser.add_argument(
214
+ "--version", action="version", version=f"pymodularity {__version__}"
215
+ )
216
+ subparsers = parser.add_subparsers(dest="command", required=True)
217
+
218
+ check_parser = subparsers.add_parser(
219
+ "check",
220
+ help=(
221
+ "run the import-cycle, privacy convention and dependency "
222
+ + "declaration checks, print findings to stdout, exit 1 on "
223
+ + "any finding"
224
+ ),
225
+ )
226
+ check_parser.add_argument("repo", type=Path)
227
+ check_parser.add_argument(
228
+ "--no-deps",
229
+ dest="deps",
230
+ action="store_false",
231
+ help="skip the dependency-declaration rule (deptry), on by default",
232
+ )
233
+
234
+ graph_parser = subparsers.add_parser("graph", help="write mermaid views")
235
+ graph_parser.add_argument("repo", type=Path)
236
+ graph_parser.add_argument("--out", type=Path, required=True)
237
+ graph_parser.add_argument(
238
+ "--html",
239
+ action="store_true",
240
+ help=(
241
+ "also write index.html per view; needs network access in the "
242
+ + "browser to load mermaid from a CDN"
243
+ ),
244
+ )
245
+
246
+ metrics_parser = subparsers.add_parser(
247
+ "metrics",
248
+ help=(
249
+ "print a coupling report (reach and score, interface "
250
+ + "width, exposure share, entry files, misplaced files) to "
251
+ + "stdout; always exits 0"
252
+ ),
253
+ )
254
+ metrics_parser.add_argument("repo", type=Path)
255
+
256
+ args = parser.parse_args()
257
+
258
+ if args.command == "check":
259
+ check_args = _CheckArgs()
260
+ check_args.repo = args.repo
261
+ check_args.deps = args.deps
262
+ return _run_check(check_args.repo.resolve(), check_args.deps)
263
+ if args.command == "graph":
264
+ graph_args = _GraphArgs()
265
+ graph_args.repo = args.repo
266
+ graph_args.out = args.out
267
+ graph_args.html = args.html
268
+ return _run_graph(graph_args.repo.resolve(), graph_args.out, graph_args.html)
269
+ metrics_args = _MetricsArgs()
270
+ metrics_args.repo = args.repo
271
+ return _run_metrics(metrics_args.repo.resolve())
272
+
273
+
274
+ if __name__ == "__main__":
275
+ # nosemgrep: no-raise-outside-boundary # boundary: CLI entry point, the process exit boundary
276
+ raise SystemExit(main())
@@ -0,0 +1,85 @@
1
+ Metadata-Version: 2.4
2
+ Name: pymodularity
3
+ Version: 0.2.0
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
+ Formerly published as `underscope`.
16
+
17
+ The underscore's position in a Python import path sets that import's scope — Rust's `pub(super)`/`pub(crate)`, applied to PEP 8 underscores.
18
+
19
+ `pymodularity` draws a repo's real Python imports as mermaid diagrams, checks them against conventions derived from the code itself (no allow-list to maintain), and reports which files and directories carry the most structural coupling. It needs only the standard library to analyze your code — no runtime dependencies. `check`'s dependency-declaration rule additionally needs `uv` on PATH (see below).
20
+
21
+ ## Install and run
22
+
23
+ ```bash
24
+ uvx pymodularity check .
25
+ uvx pymodularity graph . --out docs/imports
26
+ uvx pymodularity metrics .
27
+ ```
28
+
29
+ Or, inside a project: `uv add --dev pymodularity` and run `pymodularity ...` in that project's venv.
30
+
31
+ ## The rules `check` enforces
32
+
33
+ Given this tree:
34
+
35
+ ```
36
+ shop/
37
+ __init__.py
38
+ billing/
39
+ __init__.py
40
+ invoice.py # from shop.billing._pricing import compute_total
41
+ _pricing.py # def compute_total(): ...
42
+ catalog/
43
+ __init__.py
44
+ listing.py # from shop.billing._pricing import compute_total <- finding
45
+ ```
46
+
47
+ - **No import cycle**, at any directory level: between files, and between directories at every depth. Imports under `if TYPE_CHECKING:` and inside function bodies count. There is no baseline — an existing cycle fails too.
48
+ - **A private item stays visible only to its owner and the owner's descendants** (Go's `internal/`, applied as Rust's visibility rule to PEP 8 underscores):
49
+ - a private module — a file, or a path component, named `_x` (single leading underscore, not a dunder) — belongs to the directory holding it; only files under that directory may import it, across package boundaries;
50
+ - a private name imported via `from m import _x` belongs to `m`; if `m` is a single file, no other file may import `_x`, not even a sibling in the same directory;
51
+ - if `m` is a package (an `__init__.py` or a namespace package directory), `_x` belongs to that directory, and files under it may import it.
52
+ - a parent never reaches into a child's private items, and neither do siblings.
53
+ - `shop/billing/invoice.py` above is allowed (it's under `shop/billing`, `_pricing`'s owner); `shop/catalog/listing.py` is a finding.
54
+ - the attribute form (`import shop.billing._pricing as p; p.compute_total`) is out of scope here — ruff's `SLF001` already flags it.
55
+
56
+ `pymodularity check .` exits 1 on any finding and prints, per finding, the importing file, the target, and the directory or file that owns it.
57
+
58
+ - **Dependency declarations are correct**, on by default, skipped with `check --no-deps`: for each package root (a directory with a `pyproject.toml` `[project]` table), runs [deptry](https://github.com/fpgmaas/deptry) (`uv run --directory <pkg> --with deptry==<pinned version> --frozen deptry . --json-output <tmp>`, a nested package root excluded from its parent's scan via `--extend-exclude`) and reports its findings (unused, missing, and transitive dependencies) the same way as the other rules. A package is skipped, not scanned, if it declares no runtime dependencies, or has no `uv.lock` of its own. This rule needs `uv` on PATH — `check` exits 2 with an error if it is missing, never a silent skip. The first run creates a `.venv` inside each scanned package and downloads its dependencies; later runs reuse it.
59
+
60
+ - **Structure limits**, fixed constants, no config, no baseline — every one of these is a finding regardless of how long it's been true:
61
+ - a scanned file has at most 500 physical lines.
62
+ - a directory has at most 10 entries, where an entry is a scanned `.py` file directly in it or a subdirectory holding scanned `.py` files, transitively; `__init__.py` and `__main__.py` don't count as entries (they're still scanned and still count toward the other limits).
63
+ - a package root (the same `pyproject.toml` `[project]`-table directory the privacy and dependency rules anchor on) has at most 100 scanned files, each file counted against its nearest (innermost) package root.
64
+ - a file has at most 15 public top-level names (no leading `_`): `def`/`async def`/`class`, a plain or annotated assignment target (including tuple-unpacking), or a `type X = ...` alias, at top level or nested in a top-level `if`/`try` block (including `elif`/`else`, `except` handlers, `finally`). Imports never count. A top-level function (not a class — `@dataclass` sits on most models) a decorator registers doesn't count either: FastAPI, Typer/Click, Dagster, pytest and the like derive part of their contract (route `operationId`, CLI command name, asset key, fixture name) from the function's name, so counting it toward the width cap would pressure someone into renaming or restructuring around a name the framework, not this tool, owns. Any decorator counts — there's no list of framework names.
65
+
66
+ ## The 3 subcommands
67
+
68
+ - `pymodularity check <repo> [--no-deps]` — run all four rules above, print findings, exit 1 on any finding (`--no-deps` skips the dependency-declaration rule).
69
+ - `pymodularity graph <repo> --out DIR [--html]` — write one mermaid view per directory under `DIR`: `index.md` everywhere, plus a clickable `index.html` per view with `--html` (needs network access in the browser to load mermaid from a CDN).
70
+ - `pymodularity metrics <repo>` — print a coupling report to stdout: reach (dependents/dependencies, transitive) and a `score` per file and per directory; interface width (names other code imports from a unit); exposure share (how much of what a file defines actually gets used elsewhere); entry files (how much of a directory outside code actually reaches into); misplaced files (files whose closest partners sit mostly in another directory). Always exits 0 — a report, never a gate.
71
+
72
+ ## Blind spots
73
+
74
+ `pymodularity` shows import coupling only, a lower bound on logical coupling ("if A changes, must B?"). Coupling that bypasses Python imports stays invisible:
75
+
76
+ - HTTP APIs, shared database schemas and tables, config keys and env vars, message and file formats, string-keyed registries, runtime `sys.path` edits.
77
+ - Two files with no edge here can still force each other's edits. **Never read a missing edge as independence.**
78
+
79
+ Other limits:
80
+
81
+ - a `warning: ambiguous import` line (printed to stderr) means several files in the repo match one import; no edge is drawn for it.
82
+ - an import that only resolves through a runtime `sys.path` edit is matched by file path, which can pick the wrong file.
83
+ - a third-party name that happens to equal a package folder elsewhere in the repo draws a false edge.
84
+ - test directories (`tests/`, `test/`) and test files (`test_*.py`, `*_test.py`, `conftest.py`) are always skipped, no flag — a test importing a sibling would otherwise draw a production dependency that production never has.
85
+ - `metrics` is a report on numbers that keep moving even on a repo that passes `check` clean; gating on it rewards gaming the number (for example, replacing an import with a string-keyed registry) instead of actually lowering coupling.
@@ -0,0 +1,22 @@
1
+ pymodularity/__init__.py,sha256=wzKw3Skwd2WRtwxz9xWCUySGFRrApqoE-syjYDbif7k,133
2
+ pymodularity/__main__.py,sha256=ylHMhTlsxpfQSuBJfFufmceZYgYeIbRiDEGFrC7Yhxs,239
3
+ pymodularity/_check.py,sha256=0lunUJ6GYe3twHJJbXg5v8U_Y1rTuaOiH47Tbormizc,5783
4
+ pymodularity/_cycles.py,sha256=xLXx_ILuIVlqhKWn8oOpzRWTBxLiIsw4rJu70VENEFA,8142
5
+ pymodularity/_deps.py,sha256=kV9uKemHhDiguUUxPbWDcSNFdd-hGhq-fevHsHh08-0,8584
6
+ pymodularity/_metrics.py,sha256=Ivq6TQxjeA20pxGgj8G5XTL58wENyjwFpobZ2w9X31Y,16868
7
+ pymodularity/_parse/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
+ pymodularity/_parse/_resolve.py,sha256=GZ1xJC1LXSpK6f-5FYdpQfK6GRXCjNcOugRXNCOtDaE,6948
9
+ pymodularity/_parse/graph.py,sha256=7IkbjzTLdP2HJAC9VEJOtK0u9N9zkt76XTdLJtSShDQ,13500
10
+ pymodularity/_parse/names.py,sha256=i9I7GqRQqSD_qEuwM7W_d050x3FdjrUdT8ZKmpYDiTM,1001
11
+ pymodularity/_parse/packages.py,sha256=k6EAKIASYikJPEZIiSkqH_NQIfWtGg1oaqcky8oCPVU,1225
12
+ pymodularity/_scores.py,sha256=cCKybbv1bJyKJG1FEU_TutcvDIGB8MtK9jmgJbzVgxg,4533
13
+ pymodularity/_structure.py,sha256=90ogHcLZAlBbvGudxsZ84RYEHStew7TI2vDHBkQ36gM,5455
14
+ pymodularity/_view_edges.py,sha256=tP767BSDYXoJMMF5QwxpB5eSUyfzxL2sxJ0Pf-SAtsU,2513
15
+ pymodularity/_views/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
16
+ pymodularity/_views/_summary.py,sha256=3W37Gv1jkywLUUu4PlFvY6w9gxOR-trC7cMRQyIAAhI,9268
17
+ pymodularity/_views/render.py,sha256=GOqcRtl6m8xNW8dTvxos4PJuD2gTcqjYWBa9RcYggrA,6802
18
+ pymodularity/cli.py,sha256=2gWCRlvgNa7ZRG8xypvFc6C1TzuUtTykp99JC5BKPkY,11329
19
+ pymodularity-0.2.0.dist-info/WHEEL,sha256=9IUYslYuG_asT4WlWvfk_K76nveuLvSTKDoLeL0cV-Q,80
20
+ pymodularity-0.2.0.dist-info/entry_points.txt,sha256=AbfWSHOchayQJ7FbSPuiLgZciZz3bXnJFT1sYzDRkR4,56
21
+ pymodularity-0.2.0.dist-info/METADATA,sha256=9bNeRPUo1bL722d1VBmnqXRwwrse-G-dGAME4YAOj2o,7631
22
+ pymodularity-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.13.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ pymodularity = pymodularity.cli:main
3
+