underscope 0.1.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.
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: underscope
|
|
3
|
+
Version: 0.1.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/underscope
|
|
9
|
+
Project-URL: Repository, https://github.com/dertilo/underscope
|
|
10
|
+
Project-URL: Issues, https://github.com/dertilo/underscope/issues
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
|
|
13
|
+
# underscope
|
|
14
|
+
|
|
15
|
+
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.
|
|
16
|
+
|
|
17
|
+
`underscope` draws a repo's real Python imports as mermaid diagrams, checks them against two 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.
|
|
18
|
+
|
|
19
|
+
## Install and run
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
uvx underscope check .
|
|
23
|
+
uvx underscope graph . --out docs/imports
|
|
24
|
+
uvx underscope metrics .
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Or, inside a project: `uv add --dev underscope` and run `underscope ...` in that project's venv.
|
|
28
|
+
|
|
29
|
+
## The rules `check` enforces
|
|
30
|
+
|
|
31
|
+
Given this tree:
|
|
32
|
+
|
|
33
|
+
```
|
|
34
|
+
shop/
|
|
35
|
+
__init__.py
|
|
36
|
+
billing/
|
|
37
|
+
__init__.py
|
|
38
|
+
invoice.py # from shop.billing._pricing import compute_total
|
|
39
|
+
_pricing.py # def compute_total(): ...
|
|
40
|
+
catalog/
|
|
41
|
+
__init__.py
|
|
42
|
+
listing.py # from shop.billing._pricing import compute_total <- finding
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
- **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.
|
|
46
|
+
- **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):
|
|
47
|
+
- 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;
|
|
48
|
+
- 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;
|
|
49
|
+
- 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.
|
|
50
|
+
- a parent never reaches into a child's private items, and neither do siblings.
|
|
51
|
+
- `shop/billing/invoice.py` above is allowed (it's under `shop/billing`, `_pricing`'s owner); `shop/catalog/listing.py` is a finding.
|
|
52
|
+
- the attribute form (`import shop.billing._pricing as p; p.compute_total`) is out of scope here — ruff's `SLF001` already flags it.
|
|
53
|
+
|
|
54
|
+
`underscope check .` exits 1 on any finding and prints, per finding, the importing file, the target, and the directory or file that owns it.
|
|
55
|
+
|
|
56
|
+
## The 3 subcommands
|
|
57
|
+
|
|
58
|
+
- `underscope check <repo>` — run both conventions above, print findings, exit 1 on any finding.
|
|
59
|
+
- `underscope 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).
|
|
60
|
+
- `underscope 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.
|
|
61
|
+
|
|
62
|
+
## Blind spots
|
|
63
|
+
|
|
64
|
+
`underscope` shows import coupling only, a lower bound on logical coupling ("if A changes, must B?"). Coupling that bypasses Python imports stays invisible:
|
|
65
|
+
|
|
66
|
+
- HTTP APIs, shared database schemas and tables, config keys and env vars, message and file formats, string-keyed registries, runtime `sys.path` edits.
|
|
67
|
+
- Two files with no edge here can still force each other's edits. **Never read a missing edge as independence.**
|
|
68
|
+
|
|
69
|
+
Other limits:
|
|
70
|
+
|
|
71
|
+
- a `warning: ambiguous import` line (printed to stderr) means several files in the repo match one import; no edge is drawn for it.
|
|
72
|
+
- an import that only resolves through a runtime `sys.path` edit is matched by file path, which can pick the wrong file.
|
|
73
|
+
- a third-party name that happens to equal a package folder elsewhere in the repo draws a false edge.
|
|
74
|
+
- 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.
|
|
75
|
+
- `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,63 @@
|
|
|
1
|
+
# underscope
|
|
2
|
+
|
|
3
|
+
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.
|
|
4
|
+
|
|
5
|
+
`underscope` draws a repo's real Python imports as mermaid diagrams, checks them against two 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.
|
|
6
|
+
|
|
7
|
+
## Install and run
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
uvx underscope check .
|
|
11
|
+
uvx underscope graph . --out docs/imports
|
|
12
|
+
uvx underscope metrics .
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Or, inside a project: `uv add --dev underscope` and run `underscope ...` in that project's venv.
|
|
16
|
+
|
|
17
|
+
## The rules `check` enforces
|
|
18
|
+
|
|
19
|
+
Given this tree:
|
|
20
|
+
|
|
21
|
+
```
|
|
22
|
+
shop/
|
|
23
|
+
__init__.py
|
|
24
|
+
billing/
|
|
25
|
+
__init__.py
|
|
26
|
+
invoice.py # from shop.billing._pricing import compute_total
|
|
27
|
+
_pricing.py # def compute_total(): ...
|
|
28
|
+
catalog/
|
|
29
|
+
__init__.py
|
|
30
|
+
listing.py # from shop.billing._pricing import compute_total <- finding
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
- **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.
|
|
34
|
+
- **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):
|
|
35
|
+
- 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;
|
|
36
|
+
- 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;
|
|
37
|
+
- 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.
|
|
38
|
+
- a parent never reaches into a child's private items, and neither do siblings.
|
|
39
|
+
- `shop/billing/invoice.py` above is allowed (it's under `shop/billing`, `_pricing`'s owner); `shop/catalog/listing.py` is a finding.
|
|
40
|
+
- the attribute form (`import shop.billing._pricing as p; p.compute_total`) is out of scope here — ruff's `SLF001` already flags it.
|
|
41
|
+
|
|
42
|
+
`underscope check .` exits 1 on any finding and prints, per finding, the importing file, the target, and the directory or file that owns it.
|
|
43
|
+
|
|
44
|
+
## The 3 subcommands
|
|
45
|
+
|
|
46
|
+
- `underscope check <repo>` — run both conventions above, print findings, exit 1 on any finding.
|
|
47
|
+
- `underscope 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).
|
|
48
|
+
- `underscope 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.
|
|
49
|
+
|
|
50
|
+
## Blind spots
|
|
51
|
+
|
|
52
|
+
`underscope` shows import coupling only, a lower bound on logical coupling ("if A changes, must B?"). Coupling that bypasses Python imports stays invisible:
|
|
53
|
+
|
|
54
|
+
- HTTP APIs, shared database schemas and tables, config keys and env vars, message and file formats, string-keyed registries, runtime `sys.path` edits.
|
|
55
|
+
- Two files with no edge here can still force each other's edits. **Never read a missing edge as independence.**
|
|
56
|
+
|
|
57
|
+
Other limits:
|
|
58
|
+
|
|
59
|
+
- a `warning: ambiguous import` line (printed to stderr) means several files in the repo match one import; no edge is drawn for it.
|
|
60
|
+
- an import that only resolves through a runtime `sys.path` edit is matched by file path, which can pick the wrong file.
|
|
61
|
+
- a third-party name that happens to equal a package folder elsewhere in the repo draws a false edge.
|
|
62
|
+
- 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.
|
|
63
|
+
- `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,43 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "underscope"
|
|
3
|
+
version = "0.1.0"
|
|
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
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
requires-python = ">=3.11"
|
|
8
|
+
dependencies = []
|
|
9
|
+
|
|
10
|
+
[[project.authors]]
|
|
11
|
+
name = "Tilo Himmelsbach"
|
|
12
|
+
|
|
13
|
+
[project.urls]
|
|
14
|
+
Homepage = "https://github.com/dertilo/underscope"
|
|
15
|
+
Repository = "https://github.com/dertilo/underscope"
|
|
16
|
+
Issues = "https://github.com/dertilo/underscope/issues"
|
|
17
|
+
|
|
18
|
+
[project.scripts]
|
|
19
|
+
underscope = "underscope.cli:main"
|
|
20
|
+
|
|
21
|
+
[dependency-groups]
|
|
22
|
+
dev = [
|
|
23
|
+
"pytest",
|
|
24
|
+
"ruff",
|
|
25
|
+
"basedpyright",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
[build-system]
|
|
29
|
+
requires = ["uv_build>=0.10.7,<0.11.0"]
|
|
30
|
+
build-backend = "uv_build"
|
|
31
|
+
|
|
32
|
+
[tool.ruff]
|
|
33
|
+
line-length = 90
|
|
34
|
+
target-version = "py311"
|
|
35
|
+
|
|
36
|
+
[tool.basedpyright]
|
|
37
|
+
include = [
|
|
38
|
+
"src",
|
|
39
|
+
"tests",
|
|
40
|
+
]
|
|
41
|
+
pythonVersion = "3.11"
|
|
42
|
+
reportUnusedCallResult = false
|
|
43
|
+
reportAny = false
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "underscope"
|
|
3
|
+
version = "0.1.0"
|
|
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
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
requires-python = ">=3.11"
|
|
8
|
+
authors = [{ name = "Tilo Himmelsbach" }]
|
|
9
|
+
dependencies = []
|
|
10
|
+
|
|
11
|
+
[project.urls]
|
|
12
|
+
Homepage = "https://github.com/dertilo/underscope"
|
|
13
|
+
Repository = "https://github.com/dertilo/underscope"
|
|
14
|
+
Issues = "https://github.com/dertilo/underscope/issues"
|
|
15
|
+
|
|
16
|
+
[project.scripts]
|
|
17
|
+
underscope = "underscope.cli:main"
|
|
18
|
+
|
|
19
|
+
[dependency-groups]
|
|
20
|
+
dev = ["pytest", "ruff", "basedpyright"]
|
|
21
|
+
|
|
22
|
+
[build-system]
|
|
23
|
+
requires = ["uv_build>=0.10.7,<0.11.0"]
|
|
24
|
+
build-backend = "uv_build"
|
|
25
|
+
|
|
26
|
+
[tool.ruff]
|
|
27
|
+
line-length = 90
|
|
28
|
+
target-version = "py311"
|
|
29
|
+
|
|
30
|
+
[tool.basedpyright]
|
|
31
|
+
include = ["src", "tests"]
|
|
32
|
+
pythonVersion = "3.11"
|
|
33
|
+
reportUnusedCallResult = false
|
|
34
|
+
reportAny = false
|