pymap-cli 0.3.0__tar.gz → 0.5.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {pymap_cli-0.3.0 → pymap_cli-0.5.0}/CHANGELOG.md +56 -0
- {pymap_cli-0.3.0 → pymap_cli-0.5.0}/PKG-INFO +11 -3
- {pymap_cli-0.3.0 → pymap_cli-0.5.0}/README.md +10 -2
- {pymap_cli-0.3.0 → pymap_cli-0.5.0}/src/pymap/__init__.py +1 -1
- {pymap_cli-0.3.0 → pymap_cli-0.5.0}/src/pymap/cli.py +9 -1
- {pymap_cli-0.3.0 → pymap_cli-0.5.0}/src/pymap/mapper.py +8 -3
- {pymap_cli-0.3.0 → pymap_cli-0.5.0}/src/pymap/runner.py +7 -8
- {pymap_cli-0.3.0 → pymap_cli-0.5.0}/src/pymap/settings.py +3 -0
- {pymap_cli-0.3.0 → pymap_cli-0.5.0}/src/pymap/templates/explorer.html +77 -12
- pymap_cli-0.5.0/src/pymap/tools/__init__.py +86 -0
- {pymap_cli-0.3.0 → pymap_cli-0.5.0}/src/pymap/tools/code2flow.py +12 -3
- {pymap_cli-0.3.0 → pymap_cli-0.5.0}/src/pymap/tools/pydeps.py +2 -2
- {pymap_cli-0.3.0 → pymap_cli-0.5.0}/src/pymap/tools/pyreverse.py +2 -1
- {pymap_cli-0.3.0 → pymap_cli-0.5.0}/src/pymap/tools/tach.py +10 -5
- pymap_cli-0.5.0/tests/test_explorer.py +114 -0
- pymap_cli-0.5.0/tests/test_tools.py +203 -0
- pymap_cli-0.3.0/src/pymap/tools/__init__.py +0 -22
- pymap_cli-0.3.0/tests/test_tools.py +0 -91
- {pymap_cli-0.3.0 → pymap_cli-0.5.0}/.gitignore +0 -0
- {pymap_cli-0.3.0 → pymap_cli-0.5.0}/LICENSE +0 -0
- {pymap_cli-0.3.0 → pymap_cli-0.5.0}/pyproject.toml +0 -0
- {pymap_cli-0.3.0 → pymap_cli-0.5.0}/src/pymap/__main__.py +0 -0
- {pymap_cli-0.3.0 → pymap_cli-0.5.0}/src/pymap/analysis/__init__.py +0 -0
- {pymap_cli-0.3.0 → pymap_cli-0.5.0}/src/pymap/analysis/calls.py +0 -0
- {pymap_cli-0.3.0 → pymap_cli-0.5.0}/src/pymap/analysis/cycles.py +0 -0
- {pymap_cli-0.3.0 → pymap_cli-0.5.0}/src/pymap/analysis/flow.py +0 -0
- {pymap_cli-0.3.0 → pymap_cli-0.5.0}/src/pymap/analysis/symbols.py +0 -0
- {pymap_cli-0.3.0 → pymap_cli-0.5.0}/src/pymap/py.typed +0 -0
- {pymap_cli-0.3.0 → pymap_cli-0.5.0}/src/pymap/render.py +0 -0
- {pymap_cli-0.3.0 → pymap_cli-0.5.0}/src/pymap/templates/__init__.py +0 -0
- {pymap_cli-0.3.0 → pymap_cli-0.5.0}/src/pymap/templates/index.html +0 -0
- {pymap_cli-0.3.0 → pymap_cli-0.5.0}/src/pymap/templates/section.html +0 -0
- {pymap_cli-0.3.0 → pymap_cli-0.5.0}/tests/conftest.py +0 -0
- {pymap_cli-0.3.0 → pymap_cli-0.5.0}/tests/sample/__init__.py +0 -0
- {pymap_cli-0.3.0 → pymap_cli-0.5.0}/tests/sample/engine.py +0 -0
- {pymap_cli-0.3.0 → pymap_cli-0.5.0}/tests/sample/entry.py +0 -0
- {pymap_cli-0.3.0 → pymap_cli-0.5.0}/tests/test_analysis.py +0 -0
- {pymap_cli-0.3.0 → pymap_cli-0.5.0}/tests/test_cli.py +0 -0
- {pymap_cli-0.3.0 → pymap_cli-0.5.0}/tests/test_contract.py +0 -0
- {pymap_cli-0.3.0 → pymap_cli-0.5.0}/tests/test_mapper.py +0 -0
- {pymap_cli-0.3.0 → pymap_cli-0.5.0}/tests/test_render.py +0 -0
- {pymap_cli-0.3.0 → pymap_cli-0.5.0}/tests/test_settings.py +0 -0
|
@@ -4,6 +4,62 @@ All notable changes to this project are documented here. The format follows
|
|
|
4
4
|
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project
|
|
5
5
|
adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
6
|
|
|
7
|
+
## [0.5.0] — 2026-09-05
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
- **tach only ever declared 60 modules**, silently dropping the rest from the
|
|
11
|
+
Mermaid diagram — 113 of 240 on a codebase this was reported from. The limit
|
|
12
|
+
was justified by a performance claim that measurement does not support: on
|
|
13
|
+
generated projects of 300 and 1200 modules, `tach sync` + `show` + `map` take
|
|
14
|
+
the same 0.5-1.0s whether 60 or all modules are declared. Every module is now
|
|
15
|
+
declared by default.
|
|
16
|
+
|
|
17
|
+
The cap never affected cycle detection either: the dependency map comes from
|
|
18
|
+
scanning the sources, not from the declaration list. It only shortened the
|
|
19
|
+
diagram, which is now what it is documented to do.
|
|
20
|
+
- A tool whose failure fits on one line no longer writes a log file repeating it.
|
|
21
|
+
|
|
22
|
+
### Added
|
|
23
|
+
- `max_modules` (`--max-modules`, `[tool.pymap] max_modules`) to trim the
|
|
24
|
+
Mermaid diagram on purpose. `0`, the default, declares everything.
|
|
25
|
+
|
|
26
|
+
### Note
|
|
27
|
+
On a large or densely connected codebase, `pydeps` and `pyreverse` are the slow
|
|
28
|
+
steps: they lay the whole graph out with Graphviz and can exhaust the timeout
|
|
29
|
+
without producing anything. Raise `timeout` or point pymap at a subpackage. The
|
|
30
|
+
walkthrough tree does not depend on either.
|
|
31
|
+
|
|
32
|
+
## [0.4.0] — 2026-09-04
|
|
33
|
+
|
|
34
|
+
Reported from the field: code2flow crashed on a real codebase, and pymap
|
|
35
|
+
handled it badly in two separate ways.
|
|
36
|
+
|
|
37
|
+
### Fixed
|
|
38
|
+
- **The explorer came out empty when there was no call graph.** The navigation
|
|
39
|
+
panel is built from the graph's roots, so with no edges there was nothing to
|
|
40
|
+
branch from: pymap announced "1476 symbols" and then showed a blank panel and
|
|
41
|
+
no starting point. Symbols are now listed by module instead, each keeping its
|
|
42
|
+
own flow diagram, and the landing page says what is missing and why.
|
|
43
|
+
- **A crashing tool dumped its traceback into the terminal.** The full output
|
|
44
|
+
now goes to `<output>/<tool>.log`; what stays on screen is the cause, where
|
|
45
|
+
the detail went, and what the failure costs.
|
|
46
|
+
- `runner.run()` trimmed output to its last 1500 characters, which cut the
|
|
47
|
+
`Traceback` header off the top of a crash — leaving the saved log incomplete
|
|
48
|
+
and the crash unrecognised. Trimming is a display concern and has moved out of
|
|
49
|
+
the runner; crashes are now also identified by their stack frames.
|
|
50
|
+
|
|
51
|
+
### Added
|
|
52
|
+
- `tests/test_explorer.py` runs the explorer script under Node against a stubbed
|
|
53
|
+
DOM and asserts the panel is populated, with and without a call graph. The
|
|
54
|
+
empty-panel failure was invisible to every existing test: the page rendered,
|
|
55
|
+
it just had nothing in it.
|
|
56
|
+
|
|
57
|
+
### Note
|
|
58
|
+
code2flow asserts that a call's target is a name, attribute, subscript or call.
|
|
59
|
+
It raises `AssertionError` on `(f if cond else g)(x)`, an immediately-invoked
|
|
60
|
+
lambda, `(f or g)(x)` and `(await f())()`. That is a code2flow limitation;
|
|
61
|
+
pymap now degrades instead of following it down.
|
|
62
|
+
|
|
7
63
|
## [0.3.0] — 2026-09-04
|
|
8
64
|
|
|
9
65
|
First release prepared for publication. The codebase, its comments and the
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: pymap-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.0
|
|
4
4
|
Summary: Map a Python codebase in one command.
|
|
5
5
|
Project-URL: Homepage, https://github.com/hermann225-zrouama/pymap-cli
|
|
6
6
|
Project-URL: Issues, https://github.com/hermann225-zrouama/pymap-cli/issues
|
|
@@ -100,8 +100,14 @@ editor = "vscode" # vscodium, cursor, windsurf, zed, pycharm,
|
|
|
100
100
|
# idea, sublime, none, or "myeditor://{f}:{l}"
|
|
101
101
|
exclude = ["vendor", "generated_*"]
|
|
102
102
|
timeout = 300 # seconds per external tool
|
|
103
|
+
max_modules = 0 # cap on the modules declared to tach; 0 = all
|
|
103
104
|
```
|
|
104
105
|
|
|
106
|
+
On a large codebase, `pydeps` and `pyreverse` are the slow steps — they lay the
|
|
107
|
+
whole graph out with Graphviz, and a dense one can exhaust the timeout and
|
|
108
|
+
produce nothing. Raise `timeout`, or point pymap at a subpackage. The
|
|
109
|
+
walkthrough tree does not depend on either of them.
|
|
110
|
+
|
|
105
111
|
### As a library
|
|
106
112
|
|
|
107
113
|
```python
|
|
@@ -169,8 +175,10 @@ returns raw facts (never HTML), then add one line to `STEPS` and one section to
|
|
|
169
175
|
graph nodes and it is what lets the two data sets be joined. In a project
|
|
170
176
|
with `app/models.py` and `blog/models.py`, their symbols merge in the
|
|
171
177
|
explorer. pymap prints a note when it detects the case.
|
|
172
|
-
- **
|
|
173
|
-
|
|
178
|
+
- **The Mermaid diagram gets unwieldy on a large codebase.** Every module is
|
|
179
|
+
declared to tach by default; set `max_modules` to trim the diagram. It
|
|
180
|
+
changes nothing else — cycles are found by scanning the sources, not the
|
|
181
|
+
declaration list.
|
|
174
182
|
- The call graph is only as good as code2flow's static resolution: calls
|
|
175
183
|
through dynamic dispatch or `getattr` do not appear.
|
|
176
184
|
|
|
@@ -60,8 +60,14 @@ editor = "vscode" # vscodium, cursor, windsurf, zed, pycharm,
|
|
|
60
60
|
# idea, sublime, none, or "myeditor://{f}:{l}"
|
|
61
61
|
exclude = ["vendor", "generated_*"]
|
|
62
62
|
timeout = 300 # seconds per external tool
|
|
63
|
+
max_modules = 0 # cap on the modules declared to tach; 0 = all
|
|
63
64
|
```
|
|
64
65
|
|
|
66
|
+
On a large codebase, `pydeps` and `pyreverse` are the slow steps — they lay the
|
|
67
|
+
whole graph out with Graphviz, and a dense one can exhaust the timeout and
|
|
68
|
+
produce nothing. Raise `timeout`, or point pymap at a subpackage. The
|
|
69
|
+
walkthrough tree does not depend on either of them.
|
|
70
|
+
|
|
65
71
|
### As a library
|
|
66
72
|
|
|
67
73
|
```python
|
|
@@ -129,8 +135,10 @@ returns raw facts (never HTML), then add one line to `STEPS` and one section to
|
|
|
129
135
|
graph nodes and it is what lets the two data sets be joined. In a project
|
|
130
136
|
with `app/models.py` and `blog/models.py`, their symbols merge in the
|
|
131
137
|
explorer. pymap prints a note when it detects the case.
|
|
132
|
-
- **
|
|
133
|
-
|
|
138
|
+
- **The Mermaid diagram gets unwieldy on a large codebase.** Every module is
|
|
139
|
+
declared to tach by default; set `max_modules` to trim the diagram. It
|
|
140
|
+
changes nothing else — cycles are found by scanning the sources, not the
|
|
141
|
+
declaration list.
|
|
134
142
|
- The call graph is only as good as code2flow's static resolution: calls
|
|
135
143
|
through dynamic dispatch or `getattr` do not appear.
|
|
136
144
|
|
|
@@ -20,7 +20,7 @@ if TYPE_CHECKING: # imported lazily at runtime by __getattr__ below
|
|
|
20
20
|
from pymap.mapper import Report, map_codebase
|
|
21
21
|
from pymap.settings import Settings
|
|
22
22
|
|
|
23
|
-
__version__ = "0.
|
|
23
|
+
__version__ = "0.5.0"
|
|
24
24
|
__all__ = ["Report", "Settings", "__version__", "map_codebase"]
|
|
25
25
|
|
|
26
26
|
|
|
@@ -18,7 +18,7 @@ from pymap.mapper import map_codebase
|
|
|
18
18
|
from pymap.runner import missing
|
|
19
19
|
from pymap.settings import EDITORS, EXCLUDED, Settings, detect_target, read_pyproject
|
|
20
20
|
|
|
21
|
-
DEFAULTS = {"output": "pymap-out", "editor": "vscode", "timeout": 300}
|
|
21
|
+
DEFAULTS = {"output": "pymap-out", "editor": "vscode", "timeout": 300, "max_modules": 0}
|
|
22
22
|
|
|
23
23
|
|
|
24
24
|
def build_parser() -> argparse.ArgumentParser:
|
|
@@ -49,6 +49,13 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
49
49
|
parser.add_argument(
|
|
50
50
|
"--timeout", type=int, metavar="S", help="max seconds per external tool (default: 300)"
|
|
51
51
|
)
|
|
52
|
+
parser.add_argument(
|
|
53
|
+
"--max-modules",
|
|
54
|
+
type=int,
|
|
55
|
+
metavar="N",
|
|
56
|
+
help="cap the modules declared to tach, which shortens the Mermaid "
|
|
57
|
+
"diagram (default: 0, meaning all of them)",
|
|
58
|
+
)
|
|
52
59
|
parser.add_argument(
|
|
53
60
|
"--open",
|
|
54
61
|
dest="open_in_browser",
|
|
@@ -109,6 +116,7 @@ def build_settings(args: argparse.Namespace, cwd: Path | None = None) -> Setting
|
|
|
109
116
|
excluded=EXCLUDED + tuple(from_file.get("exclude", ())) + tuple(args.exclude or ()),
|
|
110
117
|
editor=editor,
|
|
111
118
|
timeout=args.timeout or from_file.get("timeout", DEFAULTS["timeout"]),
|
|
119
|
+
max_modules=args.max_modules or from_file.get("max_modules", DEFAULTS["max_modules"]),
|
|
112
120
|
open_in_browser=args.open_in_browser,
|
|
113
121
|
)
|
|
114
122
|
|
|
@@ -69,7 +69,10 @@ def map_codebase(settings: Settings, log: Log = lambda _: None) -> Report:
|
|
|
69
69
|
for index, (name, role, module) in enumerate(STEPS, 1):
|
|
70
70
|
log(f"[{index}/{total}] {name:11} {role}...")
|
|
71
71
|
report.tools[name] = result = module.execute(settings)
|
|
72
|
-
|
|
72
|
+
# A failing tool reports a cause, where its output went, and what is
|
|
73
|
+
# lost -- several lines, each indented under its step.
|
|
74
|
+
for line in str(result["log"] or _summary(name, result)).splitlines():
|
|
75
|
+
log(" " + line)
|
|
73
76
|
|
|
74
77
|
log(f"[{total}/{total}] explorer assembling...")
|
|
75
78
|
report.cycles = cycle_analysis.detect(report.tools["tach"]["deps"])
|
|
@@ -119,9 +122,11 @@ def _summary(name: str, result: dict[str, Any]) -> str:
|
|
|
119
122
|
return f"{result['functions']} functions, {result['calls']} calls"
|
|
120
123
|
if name == "tach":
|
|
121
124
|
cut = (
|
|
122
|
-
f"
|
|
125
|
+
f", {result['truncated']} modules left out of the diagram by max_modules"
|
|
126
|
+
if result["truncated"]
|
|
127
|
+
else ""
|
|
123
128
|
)
|
|
124
|
-
return f"{len(result['deps'])} files linked{cut}"
|
|
129
|
+
return f"{len(result['deps'])} files linked, {result['modules']} modules{cut}"
|
|
125
130
|
return "done"
|
|
126
131
|
|
|
127
132
|
|
|
@@ -73,20 +73,19 @@ def missing() -> list[Tool]:
|
|
|
73
73
|
return [tool for name, tool in TOOLS.items() if not available(name)]
|
|
74
74
|
|
|
75
75
|
|
|
76
|
-
def run(
|
|
77
|
-
|
|
78
|
-
) -> tuple[bool, str]:
|
|
79
|
-
"""Run a command. Pass ``full=True`` when the output has to be parsed.
|
|
76
|
+
def run(cmd: Sequence[str], cwd: str | None = None, timeout: int = 300) -> tuple[bool, str]:
|
|
77
|
+
"""Run a command and return ``(succeeded, complete output)``.
|
|
80
78
|
|
|
81
|
-
|
|
82
|
-
|
|
79
|
+
The output is never trimmed here. Shortening it is a display concern, and
|
|
80
|
+
doing it at this level used to cut the ``Traceback`` header off the top of a
|
|
81
|
+
crash, leaving both the diagnosis and the saved log incomplete. Callers show
|
|
82
|
+
what they need; :func:`pymap.tools.record_failure` writes the rest to a file.
|
|
83
83
|
"""
|
|
84
84
|
try:
|
|
85
85
|
completed = subprocess.run(
|
|
86
86
|
cmd, cwd=cwd, capture_output=True, text=True, timeout=timeout
|
|
87
87
|
)
|
|
88
|
-
|
|
89
|
-
return completed.returncode == 0, output if full else output[-1500:]
|
|
88
|
+
return completed.returncode == 0, completed.stdout + completed.stderr
|
|
90
89
|
except subprocess.TimeoutExpired:
|
|
91
90
|
return False, f"timed out after {timeout}s"
|
|
92
91
|
except (FileNotFoundError, NotADirectoryError, PermissionError) as exc:
|
|
@@ -150,6 +150,9 @@ class Settings:
|
|
|
150
150
|
excluded: Sequence[str] = EXCLUDED
|
|
151
151
|
editor: str = "vscode"
|
|
152
152
|
timeout: int = 300
|
|
153
|
+
#: Cap on the modules declared to tach; 0 declares them all. Only shortens
|
|
154
|
+
#: the Mermaid diagram -- it changes neither the runtime nor the cycles.
|
|
155
|
+
max_modules: int = 0
|
|
153
156
|
open_in_browser: bool = False
|
|
154
157
|
_files: list[Path] | None = field(default=None, repr=False, compare=False)
|
|
155
158
|
|
|
@@ -172,7 +172,7 @@
|
|
|
172
172
|
<header>
|
|
173
173
|
<div class="banner">
|
|
174
174
|
<h1>__PYMAP_NAME__</h1>
|
|
175
|
-
<p><b id="nr">0</b> entry points · __PYMAP_COVERAGE__% documented</p>
|
|
175
|
+
<p id="tally"><b id="nr">0</b> entry points · __PYMAP_COVERAGE__% documented</p>
|
|
176
176
|
</div>
|
|
177
177
|
<input id="q" placeholder="Search a symbol — the tree opens down to it" autocomplete="off">
|
|
178
178
|
</header>
|
|
@@ -217,6 +217,13 @@ const everyKey = new Set([...Object.keys(OUT), ...Object.keys(IN)]);
|
|
|
217
217
|
const roots = [...everyKey].filter(k => !(IN[k] || []).length && (OUT[k] || []).length)
|
|
218
218
|
.sort((a, b) => reach(b) - reach(a) || a.localeCompare(b));
|
|
219
219
|
|
|
220
|
+
/* code2flow supplies the call graph. It is the one input pymap cannot produce
|
|
221
|
+
itself, and it is also the one most likely to be missing: the tool may be
|
|
222
|
+
absent, or it may crash on a construct it does not handle. Everything that
|
|
223
|
+
follows must stay usable without it. */
|
|
224
|
+
const hasCalls = everyKey.size > 0;
|
|
225
|
+
const COVERAGE = __PYMAP_COVERAGE__;
|
|
226
|
+
|
|
220
227
|
const ROOT = __PYMAP_ROOT__;
|
|
221
228
|
const EDITOR = __PYMAP_EDITOR__; // URI template, {f} = absolute file, {l} = line
|
|
222
229
|
let expanded = new Set(), sel = null, selPath = [], highlight = '';
|
|
@@ -472,13 +479,57 @@ function diagram(key) {
|
|
|
472
479
|
}
|
|
473
480
|
|
|
474
481
|
/* ================= TREE ================= */
|
|
482
|
+
|
|
483
|
+
/* Every symbol grouped under its module, biggest module first. This is what
|
|
484
|
+
the panel shows when there is no call graph: without edges there are no
|
|
485
|
+
roots to branch from, and the tree would otherwise be empty. */
|
|
486
|
+
function modulesWithSymbols() {
|
|
487
|
+
const groups = new Map();
|
|
488
|
+
for (const s of SYM) {
|
|
489
|
+
if (s.kind === 'module') continue;
|
|
490
|
+
if (!groups.has(s.module)) groups.set(s.module, []);
|
|
491
|
+
groups.get(s.module).push(s.key);
|
|
492
|
+
}
|
|
493
|
+
return [...groups.entries()]
|
|
494
|
+
.sort((a, b) => b[1].length - a[1].length || a[0].localeCompare(b[0]));
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
function renderFlatList(out) {
|
|
498
|
+
const modules = modulesWithSymbols();
|
|
499
|
+
const total = modules.reduce((n, [, keys]) => n + keys.length, 0);
|
|
500
|
+
out.push(`<div class="section"><h2>Symbols by module</h2>`
|
|
501
|
+
+ `<span class="n">${total}</span></div>`
|
|
502
|
+
+ `<p class="group" style="padding-top:0">no call graph, so no walkthrough `
|
|
503
|
+
+ `order: each symbol still has its own flow diagram</p>`);
|
|
504
|
+
for (const [module, keys] of modules) {
|
|
505
|
+
out.push(`<div class="group">${esc(module)} · ${keys.length}</div>`);
|
|
506
|
+
for (const key of keys) {
|
|
507
|
+
const s = byKey[key];
|
|
508
|
+
out.push(`<div class="branch"><button class="node${seen.has(key) ? ' seen' : ''}" `
|
|
509
|
+
+ `data-path="${esc(key)}" aria-current="${key === sel}"><span class="head">`
|
|
510
|
+
+ `<span class="twist"></span>`
|
|
511
|
+
+ `<span class="name">${mark(shortName(key))}</span>`
|
|
512
|
+
+ (seen.has(key) ? `<span class="check">✓</span>` : '')
|
|
513
|
+
+ `<span class="mod">${esc(s ? s.kind : '')}</span><span class="gap"></span>`
|
|
514
|
+
+ `</span></button></div>`);
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
|
|
475
519
|
function renderTree() {
|
|
476
|
-
|
|
520
|
+
const symbolCount = SYM.filter(s => s.kind !== 'module').length;
|
|
521
|
+
$('tally').innerHTML = hasCalls
|
|
522
|
+
? `<b>${roots.length}</b> entry points · ${COVERAGE}% documented`
|
|
523
|
+
: `<b>${symbolCount}</b> symbols · ${COVERAGE}% documented`;
|
|
477
524
|
const out = [];
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
525
|
+
if (hasCalls) {
|
|
526
|
+
renderGroup(out, entries, "Public surface",
|
|
527
|
+
"documented functions nobody calls: the entry points", false);
|
|
528
|
+
renderGroup(out, orphans, "Not called inside the package",
|
|
529
|
+
"special methods Python invokes, or code with no caller", true);
|
|
530
|
+
} else {
|
|
531
|
+
renderFlatList(out);
|
|
532
|
+
}
|
|
482
533
|
$('nav').innerHTML = out.join('');
|
|
483
534
|
for (const button of $('nav').querySelectorAll('.node')) {
|
|
484
535
|
button.onclick = () => {
|
|
@@ -653,20 +704,34 @@ function renderScene() {
|
|
|
653
704
|
tipHide();
|
|
654
705
|
steps = []; iStep = -1;
|
|
655
706
|
if (!sel) {
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
707
|
+
// Without a call graph there is no reach to rank by, so fall back to the
|
|
708
|
+
// functions with the most steps: the densest logic is a reasonable start.
|
|
709
|
+
const best = hasCalls
|
|
710
|
+
? groupBy(entries).map(group => group[0]).sort((a, b) => reach(b) - reach(a)).slice(0, 3)
|
|
711
|
+
: SYM.filter(s => s.flow && s.flow.length)
|
|
712
|
+
.sort((a, b) => b.flow.length - a.flow.length).slice(0, 3).map(s => s.key);
|
|
713
|
+
canvas.innerHTML = `<div class="home">`
|
|
714
|
+
+ (hasCalls
|
|
715
|
+
? `<h3>Where to start</h3>
|
|
660
716
|
<p>These walkthroughs begin at the functions nobody calls — the package's
|
|
661
717
|
entry points. The first one already covers most of the useful code.</p>`
|
|
718
|
+
: `<h3>No call graph for this codebase</h3>
|
|
719
|
+
<p>code2flow did not produce one, so pymap cannot tell which function
|
|
720
|
+
calls which: there is no walkthrough order, and the calls drawn in a
|
|
721
|
+
diagram are not clickable. Every symbol is still listed on the left, and
|
|
722
|
+
each one keeps its own flow — branches, loops, error paths and the data
|
|
723
|
+
passing through. Installing or upgrading code2flow restores the rest.</p>`)
|
|
662
724
|
+ best.map((key, i) => {
|
|
663
725
|
const s = byKey[key];
|
|
664
726
|
const doc = s && s.doc ? s.doc.trim().split('\n')[0] : 'no docstring';
|
|
727
|
+
const size = hasCalls ? reach(key) : (s && s.flow ? s.flow.length : 0);
|
|
728
|
+
const unit = hasCalls ? 'symbols' : 'steps';
|
|
665
729
|
return `<button class="track" data-track="${esc(key)}">`
|
|
666
730
|
+ `<span class="r">${i + 1}</span><span class="t">`
|
|
667
731
|
+ `<b>${esc(shortName(key))}</b><span>${esc(doc.slice(0, 74))}</span></span>`
|
|
668
|
-
+ `<span class="m">${
|
|
669
|
-
+
|
|
732
|
+
+ `<span class="m">${size} ${unit}`
|
|
733
|
+
+ (hasCalls ? `<br>~${Math.max(5, Math.round(size / 4))} min` : '')
|
|
734
|
+
+ `</span></button>`;
|
|
670
735
|
}).join('')
|
|
671
736
|
+ `<div class="keys">
|
|
672
737
|
<kbd>↓</kbd><kbd>↑</kbd> walk the steps of the flow ·
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Wrappers around external tools: one module per tool.
|
|
2
|
+
|
|
3
|
+
Each module exposes ``execute(settings)`` and returns raw facts, never HTML --
|
|
4
|
+
layout belongs to :mod:`pymap.render`. A missing tool returns an empty result
|
|
5
|
+
carrying an explanatory ``log``; it never aborts the run.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import TYPE_CHECKING
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from pymap.settings import Settings
|
|
16
|
+
|
|
17
|
+
TRACEBACK = "Traceback (most recent call last)"
|
|
18
|
+
|
|
19
|
+
#: A runaway tool must not fill the disk with its own log.
|
|
20
|
+
MAX_LOG = 200_000
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def looks_like_a_crash(output: str) -> bool:
|
|
24
|
+
"""Whether the output is a Python traceback.
|
|
25
|
+
|
|
26
|
+
The header alone is not enough to go on: it is the first thing lost when
|
|
27
|
+
output gets clipped, so stack frames count as evidence too.
|
|
28
|
+
"""
|
|
29
|
+
return TRACEBACK in output or output.count(' File "') >= 2
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def summarise_output(output: str) -> str:
|
|
33
|
+
"""One line describing why a tool failed, from its raw output.
|
|
34
|
+
|
|
35
|
+
A crashing tool prints a traceback whose last line carries the exception;
|
|
36
|
+
everything above it is noise to someone using pymap rather than developing
|
|
37
|
+
the tool. Anything else is reported as the tool worded it.
|
|
38
|
+
"""
|
|
39
|
+
lines = [line.strip() for line in output.splitlines() if line.strip()]
|
|
40
|
+
if not lines:
|
|
41
|
+
return "failed without producing any output"
|
|
42
|
+
if looks_like_a_crash(output):
|
|
43
|
+
return f"crashed: {lines[-1][:120]}"
|
|
44
|
+
return lines[-1][:160]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def record_failure(settings: Settings, tool: str, output: str, consequence: str = "") -> str:
|
|
48
|
+
"""Save a failing tool's full output and return what to show the user.
|
|
49
|
+
|
|
50
|
+
The raw output is a wall of traceback: it belongs in a file, not in the
|
|
51
|
+
terminal. What stays on screen is the cause, where the detail went, and
|
|
52
|
+
what the user loses -- the last one being the part that matters.
|
|
53
|
+
"""
|
|
54
|
+
where = ""
|
|
55
|
+
# Skip the file when the whole reason already fits on screen -- "timed out
|
|
56
|
+
# after 300s" needs no log. Length matters as much as line count: a single
|
|
57
|
+
# very long line is not visible in full either.
|
|
58
|
+
body = output.strip()
|
|
59
|
+
already_shown = len(body.splitlines()) <= 1 and len(body) <= 160
|
|
60
|
+
if body and not already_shown:
|
|
61
|
+
path = settings.output / f"{tool}.log"
|
|
62
|
+
kept = output[:MAX_LOG]
|
|
63
|
+
if len(output) > MAX_LOG:
|
|
64
|
+
kept += f"\n\n[truncated: {len(output) - MAX_LOG} more characters]\n"
|
|
65
|
+
try:
|
|
66
|
+
path.write_text(kept, encoding="utf-8")
|
|
67
|
+
where = f" (output in {path.name})"
|
|
68
|
+
except OSError:
|
|
69
|
+
where = ""
|
|
70
|
+
lines = [f"{tool} {summarise_output(output)}{where}"]
|
|
71
|
+
if looks_like_a_crash(output):
|
|
72
|
+
lines.append(f"this is a {tool} limitation, not a pymap error")
|
|
73
|
+
if consequence:
|
|
74
|
+
lines.append(consequence)
|
|
75
|
+
return "\n".join(lines)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def count_svg(path: str | Path) -> tuple[int, int]:
|
|
79
|
+
"""(nodes, edges) of an SVG produced by Graphviz."""
|
|
80
|
+
try:
|
|
81
|
+
text = Path(path).read_text(encoding="utf-8", errors="ignore")
|
|
82
|
+
except OSError:
|
|
83
|
+
return 0, 0
|
|
84
|
+
titles = re.findall(r"<title>([^<]+)</title>", text)
|
|
85
|
+
edges = [t for t in titles if "->" in t or "->" in t]
|
|
86
|
+
return len(titles) - len(edges), len(edges)
|
|
@@ -11,6 +11,13 @@ from typing import Any
|
|
|
11
11
|
|
|
12
12
|
from pymap import runner
|
|
13
13
|
from pymap.settings import Settings
|
|
14
|
+
from pymap.tools import record_failure
|
|
15
|
+
|
|
16
|
+
#: What the user loses when this step does not run. The explorer keeps every
|
|
17
|
+
#: symbol either way, but without these edges it cannot follow a call.
|
|
18
|
+
CONSEQUENCE = (
|
|
19
|
+
"the explorer still lists every symbol by module, but cannot follow calls between them"
|
|
20
|
+
)
|
|
14
21
|
|
|
15
22
|
ROLE = "call graph"
|
|
16
23
|
|
|
@@ -27,7 +34,7 @@ def execute(settings: Settings) -> dict[str, Any]:
|
|
|
27
34
|
empty: dict[str, Any] = {"json": None, "functions": 0, "calls": 0}
|
|
28
35
|
base = runner.resolve("code2flow")
|
|
29
36
|
if not base:
|
|
30
|
-
return dict(empty, log="code2flow is not installed")
|
|
37
|
+
return dict(empty, log=f"code2flow is not installed\n{CONSEQUENCE}")
|
|
31
38
|
|
|
32
39
|
files = settings.files()
|
|
33
40
|
if not files:
|
|
@@ -43,11 +50,13 @@ def execute(settings: Settings) -> dict[str, Any]:
|
|
|
43
50
|
base + sources + ["-o", str(output)], timeout=max(settings.timeout, MIN_TIMEOUT)
|
|
44
51
|
)
|
|
45
52
|
if not (ok and output.exists()):
|
|
46
|
-
return dict(empty, log=
|
|
53
|
+
return dict(empty, log=record_failure(settings, "code2flow", log, CONSEQUENCE))
|
|
47
54
|
try:
|
|
48
55
|
graph = json.loads(output.read_text(encoding="utf-8"))["graph"]
|
|
49
56
|
except (OSError, ValueError, KeyError):
|
|
50
|
-
return dict(
|
|
57
|
+
return dict(
|
|
58
|
+
empty, json=output, log=f"code2flow produced unreadable JSON\n{CONSEQUENCE}"
|
|
59
|
+
)
|
|
51
60
|
return {
|
|
52
61
|
"json": output,
|
|
53
62
|
"functions": len(graph["nodes"]),
|
|
@@ -6,7 +6,7 @@ from typing import Any
|
|
|
6
6
|
|
|
7
7
|
from pymap import runner
|
|
8
8
|
from pymap.settings import Settings
|
|
9
|
-
from pymap.tools import count_svg
|
|
9
|
+
from pymap.tools import count_svg, record_failure
|
|
10
10
|
|
|
11
11
|
ROLE = "import graph"
|
|
12
12
|
|
|
@@ -37,6 +37,6 @@ def execute(settings: Settings) -> dict[str, Any]:
|
|
|
37
37
|
timeout=settings.timeout,
|
|
38
38
|
)
|
|
39
39
|
if not (ok and svg.exists()):
|
|
40
|
-
return dict(empty, log=
|
|
40
|
+
return dict(empty, log=record_failure(settings, "pydeps", log))
|
|
41
41
|
nodes, edges = count_svg(svg)
|
|
42
42
|
return {"svg": svg, "modules": nodes, "imports": edges, "log": ""}
|
|
@@ -7,6 +7,7 @@ from typing import Any
|
|
|
7
7
|
|
|
8
8
|
from pymap import runner
|
|
9
9
|
from pymap.settings import Settings
|
|
10
|
+
from pymap.tools import record_failure
|
|
10
11
|
|
|
11
12
|
ROLE = "class diagrams"
|
|
12
13
|
|
|
@@ -33,7 +34,7 @@ def execute(settings: Settings) -> dict[str, Any]:
|
|
|
33
34
|
classes = settings.output / f"classes_{project}.svg"
|
|
34
35
|
packages = settings.output / f"packages_{project}.svg"
|
|
35
36
|
if not ok and not classes.exists():
|
|
36
|
-
return dict(empty, log=
|
|
37
|
+
return dict(empty, log=record_failure(settings, "pyreverse", log))
|
|
37
38
|
return {
|
|
38
39
|
"classes": classes if classes.exists() else None,
|
|
39
40
|
"packages": packages if packages.exists() else None,
|
|
@@ -17,11 +17,16 @@ from typing import Any
|
|
|
17
17
|
|
|
18
18
|
from pymap import runner
|
|
19
19
|
from pymap.settings import Settings, is_excluded
|
|
20
|
+
from pymap.tools import record_failure
|
|
20
21
|
|
|
21
22
|
ROLE = "module boundaries"
|
|
22
23
|
|
|
23
|
-
#:
|
|
24
|
-
|
|
24
|
+
#: Declaring every module costs nothing. Measured on generated projects of 300
|
|
25
|
+
#: and 1200 modules: ``tach sync`` + ``show`` + ``map`` take the same 0.5-1.0s
|
|
26
|
+
#: whether 60 or all of them are declared, and the dependency map that feeds
|
|
27
|
+
#: cycle detection comes from scanning the sources, not from the declaration
|
|
28
|
+
#: list. A cap only shortens the Mermaid diagram, so it is opt-in.
|
|
29
|
+
NO_LIMIT = 0
|
|
25
30
|
|
|
26
31
|
|
|
27
32
|
def _copy_sources(target: Path, into: Path, excluded: Sequence[str]) -> None:
|
|
@@ -61,7 +66,7 @@ def execute(settings: Settings) -> dict[str, Any]:
|
|
|
61
66
|
every = _module_paths(settings.target, settings.files())
|
|
62
67
|
if not every:
|
|
63
68
|
return dict(empty, log="no module to declare")
|
|
64
|
-
kept = every[:
|
|
69
|
+
kept = every[: settings.max_modules] if settings.max_modules > NO_LIMIT else every
|
|
65
70
|
|
|
66
71
|
with tempfile.TemporaryDirectory(prefix="pymap-tach-") as tmp:
|
|
67
72
|
workspace = Path(tmp)
|
|
@@ -76,7 +81,7 @@ def execute(settings: Settings) -> dict[str, Any]:
|
|
|
76
81
|
|
|
77
82
|
ok, log = runner.run([*base, "sync"], cwd=str(workspace), timeout=settings.timeout)
|
|
78
83
|
if not ok:
|
|
79
|
-
return dict(empty, log=
|
|
84
|
+
return dict(empty, log=record_failure(settings, "tach", log))
|
|
80
85
|
|
|
81
86
|
mermaid = settings.output / "tach.mmd"
|
|
82
87
|
drawn, _ = runner.run(
|
|
@@ -87,7 +92,7 @@ def execute(settings: Settings) -> dict[str, Any]:
|
|
|
87
92
|
|
|
88
93
|
deps: dict[str, list[str]] = {}
|
|
89
94
|
mapped, raw = runner.run(
|
|
90
|
-
[*base, "map", "-o", "-"], cwd=str(workspace), timeout=settings.timeout
|
|
95
|
+
[*base, "map", "-o", "-"], cwd=str(workspace), timeout=settings.timeout
|
|
91
96
|
)
|
|
92
97
|
if mapped:
|
|
93
98
|
try:
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Behaviour of the explorer script, run under Node with a stubbed DOM.
|
|
2
|
+
|
|
3
|
+
``test_contract.py`` proves the script parses; this proves it renders. The two
|
|
4
|
+
failures worth guarding against here are silent: a page whose navigation panel
|
|
5
|
+
comes out empty looks like a working page with nothing to show.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import re
|
|
10
|
+
import shutil
|
|
11
|
+
import subprocess
|
|
12
|
+
|
|
13
|
+
import pytest
|
|
14
|
+
|
|
15
|
+
from pymap import render
|
|
16
|
+
from pymap.analysis.symbols import extract
|
|
17
|
+
|
|
18
|
+
pytestmark = pytest.mark.skipif(not shutil.which("node"), reason="node is not installed")
|
|
19
|
+
|
|
20
|
+
# Just enough DOM for the script to run to completion. Everything the script
|
|
21
|
+
# reads back is captured; everything else is inert.
|
|
22
|
+
DOM_STUB = """
|
|
23
|
+
class El {
|
|
24
|
+
constructor(id) {
|
|
25
|
+
this.id = id; this.innerHTML = ''; this.textContent = '';
|
|
26
|
+
this.style = {}; this.dataset = {};
|
|
27
|
+
this.classList = { add() {}, remove() {}, contains() { return false; } };
|
|
28
|
+
}
|
|
29
|
+
addEventListener() {}
|
|
30
|
+
querySelectorAll() { return []; }
|
|
31
|
+
querySelector() { return null; }
|
|
32
|
+
getBoundingClientRect() {
|
|
33
|
+
return { left: 0, top: 0, right: 0, bottom: 0, width: 0, height: 0 };
|
|
34
|
+
}
|
|
35
|
+
scrollIntoView() {} focus() {} select() {} blur() {}
|
|
36
|
+
}
|
|
37
|
+
const ELEMENTS = {};
|
|
38
|
+
globalThis.document = {
|
|
39
|
+
getElementById(id) { return ELEMENTS[id] || (ELEMENTS[id] = new El(id)); },
|
|
40
|
+
addEventListener() {},
|
|
41
|
+
activeElement: null,
|
|
42
|
+
body: { style: {} },
|
|
43
|
+
};
|
|
44
|
+
globalThis.window = { innerWidth: 1200, innerHeight: 800, addEventListener() {} };
|
|
45
|
+
globalThis.__report = () => JSON.stringify({
|
|
46
|
+
nav: (ELEMENTS.nav || {}).innerHTML || '',
|
|
47
|
+
canvas: (ELEMENTS.canvas || {}).innerHTML || '',
|
|
48
|
+
tally: (ELEMENTS.tally || {}).innerHTML || '',
|
|
49
|
+
});
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def render_in_node(tmp_path, symbols, relations):
|
|
54
|
+
"""Run the explorer script and return what it wrote into the page."""
|
|
55
|
+
html = render.explorer_page(
|
|
56
|
+
"sample", "/tmp/sample", symbols, relations, 50, "vscode://file/{f}:{l}"
|
|
57
|
+
)
|
|
58
|
+
script = re.search(r"<script>(.*)</script>", html, re.S).group(1)
|
|
59
|
+
bundle = tmp_path / "run.js"
|
|
60
|
+
bundle.write_text(DOM_STUB + script + "\nconsole.log(__report());\n", encoding="utf-8")
|
|
61
|
+
done = subprocess.run(["node", str(bundle)], text=True, capture_output=True)
|
|
62
|
+
assert done.returncode == 0, done.stderr
|
|
63
|
+
return json.loads(done.stdout)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@pytest.fixture
|
|
67
|
+
def symbols(sample):
|
|
68
|
+
return extract(sample, sorted(sample.rglob("*.py")))
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@pytest.fixture
|
|
72
|
+
def relations(symbols):
|
|
73
|
+
"""A call graph shaped like code2flow's, over the sample package."""
|
|
74
|
+
return {
|
|
75
|
+
"entry::main": {"calls": ["engine::process"], "called_by": []},
|
|
76
|
+
"engine::process": {"calls": ["engine::Tank.average"], "called_by": ["entry::main"]},
|
|
77
|
+
"engine::Tank.average": {"calls": [], "called_by": ["engine::process"]},
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def test_navigation_is_populated_with_a_call_graph(tmp_path, symbols, relations):
|
|
82
|
+
page = render_in_node(tmp_path, symbols, relations)
|
|
83
|
+
assert "main" in page["nav"]
|
|
84
|
+
assert "entry points" in page["tally"]
|
|
85
|
+
assert "Where to start" in page["canvas"]
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def test_navigation_is_populated_without_a_call_graph(tmp_path, symbols):
|
|
89
|
+
"""The regression that prompted this file: code2flow can crash, and the
|
|
90
|
+
panel used to come out empty even though every symbol had been extracted."""
|
|
91
|
+
page = render_in_node(tmp_path, symbols, {})
|
|
92
|
+
assert page["nav"].strip(), "the navigation panel must never be empty"
|
|
93
|
+
assert "Symbols by module" in page["nav"]
|
|
94
|
+
assert "process" in page["nav"]
|
|
95
|
+
assert "engine" in page["nav"]
|
|
96
|
+
assert "symbols" in page["tally"]
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def test_the_missing_call_graph_is_explained(tmp_path, symbols):
|
|
100
|
+
page = render_in_node(tmp_path, symbols, {})
|
|
101
|
+
assert "No call graph" in page["canvas"]
|
|
102
|
+
assert "code2flow" in page["canvas"]
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def test_every_symbol_is_listed_when_there_is_no_call_graph(tmp_path, symbols):
|
|
106
|
+
page = render_in_node(tmp_path, symbols, {})
|
|
107
|
+
for symbol in symbols:
|
|
108
|
+
if symbol["kind"] != "module":
|
|
109
|
+
assert symbol["name"] in page["nav"], symbol["key"]
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def test_an_empty_codebase_still_renders(tmp_path):
|
|
113
|
+
page = render_in_node(tmp_path, [], {})
|
|
114
|
+
assert page["tally"]
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
"""Tool wrappers: clean fallback when absent, and tach's internal logic."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from pymap import runner
|
|
6
|
+
from pymap.settings import Settings
|
|
7
|
+
from pymap.tools import (
|
|
8
|
+
MAX_LOG,
|
|
9
|
+
code2flow,
|
|
10
|
+
count_svg,
|
|
11
|
+
pydeps,
|
|
12
|
+
pyreverse,
|
|
13
|
+
record_failure,
|
|
14
|
+
summarise_output,
|
|
15
|
+
tach,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
SVG = (
|
|
19
|
+
"<svg><g><title>a</title></g><g><title>b</title></g><g><title>a->b</title></g></svg>"
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_count_svg_separates_nodes_from_edges(tmp_path):
|
|
24
|
+
path = tmp_path / "g.svg"
|
|
25
|
+
path.write_text(SVG, encoding="utf-8")
|
|
26
|
+
assert count_svg(path) == (2, 1)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_count_svg_on_a_missing_file(tmp_path):
|
|
30
|
+
assert count_svg(tmp_path / "nothing.svg") == (0, 0)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_every_absent_tool_returns_an_empty_result(settings, without_tools):
|
|
34
|
+
settings.output.mkdir(parents=True, exist_ok=True)
|
|
35
|
+
for module in (pydeps, pyreverse, code2flow, tach):
|
|
36
|
+
result = module.execute(settings)
|
|
37
|
+
assert result["log"], module.__name__
|
|
38
|
+
assert not any(value for key, value in result.items() if key != "log")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_tools_report_missing_graphviz(settings, monkeypatch):
|
|
42
|
+
monkeypatch.setattr(
|
|
43
|
+
runner, "resolve", lambda name: None if name == "dot" else ["/bin/" + name]
|
|
44
|
+
)
|
|
45
|
+
for module in (pydeps, pyreverse):
|
|
46
|
+
assert "graphviz" in module.execute(settings)["log"]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_tach_names_modules_from_the_root(sample):
|
|
50
|
+
modules = tach._module_paths(sample, sorted(sample.rglob("*.py")))
|
|
51
|
+
assert "sample.engine" in modules
|
|
52
|
+
assert "sample.entry" in modules
|
|
53
|
+
assert not any(m.endswith("__init__") for m in modules)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_tach_copies_sources_and_nothing_else(tmp_path):
|
|
57
|
+
src = tmp_path / "pkg"
|
|
58
|
+
(src / ".venv" / "lib").mkdir(parents=True)
|
|
59
|
+
(src / ".venv" / "lib" / "intruder.py").write_text("", encoding="utf-8")
|
|
60
|
+
(src / "mod.py").write_text("x = 1\n", encoding="utf-8")
|
|
61
|
+
(src / "data.csv").write_text("a,b\n", encoding="utf-8")
|
|
62
|
+
|
|
63
|
+
tach._copy_sources(src, tmp_path / "copy", (".venv", "__pycache__"))
|
|
64
|
+
copied = {p.name for p in (tmp_path / "copy").rglob("*") if p.is_file()}
|
|
65
|
+
assert copied == {"mod.py"}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def test_tach_survives_a_failing_sync(settings, monkeypatch):
|
|
69
|
+
monkeypatch.setattr(runner, "resolve", lambda name: ["fake-tach"])
|
|
70
|
+
monkeypatch.setattr(runner, "run", lambda *a, **k: (False, "tach unavailable"))
|
|
71
|
+
assert tach.execute(settings)["log"]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# --- tool resolution ---------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def test_resolve_finds_the_tool_of_the_current_venv(monkeypatch, tmp_path):
|
|
78
|
+
"""Common case: pymap launched by full path, its bin/ not on PATH."""
|
|
79
|
+
fake_bin = tmp_path / "bin"
|
|
80
|
+
fake_bin.mkdir()
|
|
81
|
+
(fake_bin / "pydeps").write_text("#!/bin/sh\n", encoding="utf-8")
|
|
82
|
+
(fake_bin / "pydeps").chmod(0o755)
|
|
83
|
+
monkeypatch.setattr(runner.sys, "executable", str(fake_bin / "python"))
|
|
84
|
+
monkeypatch.setenv("PATH", "/nowhere")
|
|
85
|
+
assert runner.resolve("pydeps") == [str(fake_bin / "pydeps")]
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def test_resolve_refuses_the_dash_m_fallback_without_main(monkeypatch):
|
|
89
|
+
"""code2flow has no __main__: better to report it absent than to fail oddly."""
|
|
90
|
+
monkeypatch.setattr(runner.shutil, "which", lambda *a, **k: None)
|
|
91
|
+
monkeypatch.setattr(runner, "_has_main_module", lambda module: False)
|
|
92
|
+
assert runner.resolve("code2flow") is None
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def test_missing_lists_tools_in_declaration_order(monkeypatch):
|
|
96
|
+
monkeypatch.setattr(runner, "resolve", lambda name: None)
|
|
97
|
+
assert [tool.name for tool in runner.missing()] == list(runner.TOOLS)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def test_run_reports_an_unknown_command():
|
|
101
|
+
ok, output = runner.run(["definitely-not-a-real-binary-xyz"])
|
|
102
|
+
assert not ok
|
|
103
|
+
assert output
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
# --- failure reporting -------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
TRACEBACK_OUTPUT = """Traceback (most recent call last):
|
|
109
|
+
File "/x/code2flow/engine.py", line 734, in code2flow
|
|
110
|
+
file_groups, all_nodes, edges = map_it(sources, language, no_trimming,
|
|
111
|
+
File "/x/code2flow/python.py", line 18, in get_call_from_func_element
|
|
112
|
+
assert type(func) in (ast.Attribute, ast.Name, ast.Subscript, ast.Call)
|
|
113
|
+
AssertionError
|
|
114
|
+
"""
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def test_summarise_output_picks_the_exception_out_of_a_traceback():
|
|
118
|
+
assert summarise_output(TRACEBACK_OUTPUT) == "crashed: AssertionError"
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def test_summarise_output_keeps_a_plain_message():
|
|
122
|
+
assert summarise_output("could not open file\n") == "could not open file"
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def test_summarise_output_handles_silence():
|
|
126
|
+
assert summarise_output(" \n") == "failed without producing any output"
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def test_record_failure_moves_the_traceback_into_a_file(settings):
|
|
130
|
+
settings.output.mkdir(parents=True, exist_ok=True)
|
|
131
|
+
message = record_failure(settings, "code2flow", TRACEBACK_OUTPUT, "calls are lost")
|
|
132
|
+
|
|
133
|
+
assert (settings.output / "code2flow.log").read_text() == TRACEBACK_OUTPUT
|
|
134
|
+
# What stays on screen: cause, where the detail went, what it costs.
|
|
135
|
+
assert "crashed: AssertionError" in message
|
|
136
|
+
assert "code2flow.log" in message
|
|
137
|
+
assert "not a pymap error" in message
|
|
138
|
+
assert "calls are lost" in message
|
|
139
|
+
# And not the wall of traceback itself.
|
|
140
|
+
assert 'File "/x/code2flow' not in message
|
|
141
|
+
assert len(message.splitlines()) == 3
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def test_record_failure_without_output_writes_no_file(settings):
|
|
145
|
+
settings.output.mkdir(parents=True, exist_ok=True)
|
|
146
|
+
record_failure(settings, "tach", "")
|
|
147
|
+
assert not (settings.output / "tach.log").exists()
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def test_record_failure_recognises_a_traceback_whose_header_was_clipped(settings):
|
|
151
|
+
"""Output can arrive already cut at the top; stack frames still identify it."""
|
|
152
|
+
settings.output.mkdir(parents=True, exist_ok=True)
|
|
153
|
+
clipped = TRACEBACK_OUTPUT.split("\n", 1)[1]
|
|
154
|
+
assert "Traceback" not in clipped
|
|
155
|
+
assert summarise_output(clipped) == "crashed: AssertionError"
|
|
156
|
+
assert "not a pymap error" in record_failure(settings, "code2flow", clipped)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def test_record_failure_caps_a_runaway_log(settings):
|
|
160
|
+
settings.output.mkdir(parents=True, exist_ok=True)
|
|
161
|
+
record_failure(settings, "tach", "x" * (MAX_LOG + 5000))
|
|
162
|
+
written = (settings.output / "tach.log").read_text()
|
|
163
|
+
assert len(written) < MAX_LOG + 200
|
|
164
|
+
assert "truncated" in written
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def test_tach_declares_every_module_by_default(sample, tmp_path, monkeypatch):
|
|
168
|
+
"""The old cap of 60 silently dropped modules from the Mermaid diagram."""
|
|
169
|
+
settings = Settings(target=sample, output=tmp_path / "out")
|
|
170
|
+
captured = {}
|
|
171
|
+
|
|
172
|
+
def fake_run(cmd, cwd=None, timeout=300):
|
|
173
|
+
if cmd[-1] == "sync":
|
|
174
|
+
captured["toml"] = (Path(cwd) / "tach.toml").read_text()
|
|
175
|
+
return False, "stop here"
|
|
176
|
+
|
|
177
|
+
monkeypatch.setattr(runner, "resolve", lambda name: ["tach"])
|
|
178
|
+
monkeypatch.setattr(runner, "run", fake_run)
|
|
179
|
+
tach.execute(settings)
|
|
180
|
+
every = tach._module_paths(settings.target, settings.files())
|
|
181
|
+
assert captured["toml"].count("[[modules]]") == len(every)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def test_tach_honours_an_explicit_cap(sample, tmp_path, monkeypatch):
|
|
185
|
+
settings = Settings(target=sample, output=tmp_path / "out", max_modules=1)
|
|
186
|
+
monkeypatch.setattr(runner, "resolve", lambda name: ["tach"])
|
|
187
|
+
monkeypatch.setattr(runner, "run", lambda *a, **k: (False, "stop"))
|
|
188
|
+
result = tach.execute(settings)
|
|
189
|
+
assert result["log"]
|
|
190
|
+
|
|
191
|
+
# And the count reported back reflects what was dropped.
|
|
192
|
+
settings2 = Settings(target=sample, output=tmp_path / "out2", max_modules=1)
|
|
193
|
+
every = tach._module_paths(settings2.target, settings2.files())
|
|
194
|
+
assert len(every) > 1, "the sample must have more than one module to cap"
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def test_record_failure_skips_the_file_for_a_one_line_reason(settings):
|
|
198
|
+
""" "timed out after 300s" is already fully on screen; a log adds nothing."""
|
|
199
|
+
settings.output.mkdir(parents=True, exist_ok=True)
|
|
200
|
+
message = record_failure(settings, "pydeps", "timed out after 300s")
|
|
201
|
+
assert not (settings.output / "pydeps.log").exists()
|
|
202
|
+
assert "timed out after 300s" in message
|
|
203
|
+
assert ".log" not in message
|
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
"""Wrappers around external tools: one module per tool.
|
|
2
|
-
|
|
3
|
-
Each module exposes ``execute(settings)`` and returns raw facts, never HTML --
|
|
4
|
-
layout belongs to :mod:`pymap.render`. A missing tool returns an empty result
|
|
5
|
-
carrying an explanatory ``log``; it never aborts the run.
|
|
6
|
-
"""
|
|
7
|
-
|
|
8
|
-
from __future__ import annotations
|
|
9
|
-
|
|
10
|
-
import re
|
|
11
|
-
from pathlib import Path
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
def count_svg(path: str | Path) -> tuple[int, int]:
|
|
15
|
-
"""(nodes, edges) of an SVG produced by Graphviz."""
|
|
16
|
-
try:
|
|
17
|
-
text = Path(path).read_text(encoding="utf-8", errors="ignore")
|
|
18
|
-
except OSError:
|
|
19
|
-
return 0, 0
|
|
20
|
-
titles = re.findall(r"<title>([^<]+)</title>", text)
|
|
21
|
-
edges = [t for t in titles if "->" in t or "->" in t]
|
|
22
|
-
return len(titles) - len(edges), len(edges)
|
|
@@ -1,91 +0,0 @@
|
|
|
1
|
-
"""Tool wrappers: clean fallback when absent, and tach's internal logic."""
|
|
2
|
-
|
|
3
|
-
from pymap import runner
|
|
4
|
-
from pymap.tools import code2flow, count_svg, pydeps, pyreverse, tach
|
|
5
|
-
|
|
6
|
-
SVG = (
|
|
7
|
-
"<svg><g><title>a</title></g><g><title>b</title></g><g><title>a->b</title></g></svg>"
|
|
8
|
-
)
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
def test_count_svg_separates_nodes_from_edges(tmp_path):
|
|
12
|
-
path = tmp_path / "g.svg"
|
|
13
|
-
path.write_text(SVG, encoding="utf-8")
|
|
14
|
-
assert count_svg(path) == (2, 1)
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
def test_count_svg_on_a_missing_file(tmp_path):
|
|
18
|
-
assert count_svg(tmp_path / "nothing.svg") == (0, 0)
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
def test_every_absent_tool_returns_an_empty_result(settings, without_tools):
|
|
22
|
-
settings.output.mkdir(parents=True, exist_ok=True)
|
|
23
|
-
for module in (pydeps, pyreverse, code2flow, tach):
|
|
24
|
-
result = module.execute(settings)
|
|
25
|
-
assert result["log"], module.__name__
|
|
26
|
-
assert not any(value for key, value in result.items() if key != "log")
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
def test_tools_report_missing_graphviz(settings, monkeypatch):
|
|
30
|
-
monkeypatch.setattr(
|
|
31
|
-
runner, "resolve", lambda name: None if name == "dot" else ["/bin/" + name]
|
|
32
|
-
)
|
|
33
|
-
for module in (pydeps, pyreverse):
|
|
34
|
-
assert "graphviz" in module.execute(settings)["log"]
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
def test_tach_names_modules_from_the_root(sample):
|
|
38
|
-
modules = tach._module_paths(sample, sorted(sample.rglob("*.py")))
|
|
39
|
-
assert "sample.engine" in modules
|
|
40
|
-
assert "sample.entry" in modules
|
|
41
|
-
assert not any(m.endswith("__init__") for m in modules)
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
def test_tach_copies_sources_and_nothing_else(tmp_path):
|
|
45
|
-
src = tmp_path / "pkg"
|
|
46
|
-
(src / ".venv" / "lib").mkdir(parents=True)
|
|
47
|
-
(src / ".venv" / "lib" / "intruder.py").write_text("", encoding="utf-8")
|
|
48
|
-
(src / "mod.py").write_text("x = 1\n", encoding="utf-8")
|
|
49
|
-
(src / "data.csv").write_text("a,b\n", encoding="utf-8")
|
|
50
|
-
|
|
51
|
-
tach._copy_sources(src, tmp_path / "copy", (".venv", "__pycache__"))
|
|
52
|
-
copied = {p.name for p in (tmp_path / "copy").rglob("*") if p.is_file()}
|
|
53
|
-
assert copied == {"mod.py"}
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
def test_tach_survives_a_failing_sync(settings, monkeypatch):
|
|
57
|
-
monkeypatch.setattr(runner, "resolve", lambda name: ["fake-tach"])
|
|
58
|
-
monkeypatch.setattr(runner, "run", lambda *a, **k: (False, "tach unavailable"))
|
|
59
|
-
assert tach.execute(settings)["log"]
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
# --- tool resolution ---------------------------------------------------------
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
def test_resolve_finds_the_tool_of_the_current_venv(monkeypatch, tmp_path):
|
|
66
|
-
"""Common case: pymap launched by full path, its bin/ not on PATH."""
|
|
67
|
-
fake_bin = tmp_path / "bin"
|
|
68
|
-
fake_bin.mkdir()
|
|
69
|
-
(fake_bin / "pydeps").write_text("#!/bin/sh\n", encoding="utf-8")
|
|
70
|
-
(fake_bin / "pydeps").chmod(0o755)
|
|
71
|
-
monkeypatch.setattr(runner.sys, "executable", str(fake_bin / "python"))
|
|
72
|
-
monkeypatch.setenv("PATH", "/nowhere")
|
|
73
|
-
assert runner.resolve("pydeps") == [str(fake_bin / "pydeps")]
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
def test_resolve_refuses_the_dash_m_fallback_without_main(monkeypatch):
|
|
77
|
-
"""code2flow has no __main__: better to report it absent than to fail oddly."""
|
|
78
|
-
monkeypatch.setattr(runner.shutil, "which", lambda *a, **k: None)
|
|
79
|
-
monkeypatch.setattr(runner, "_has_main_module", lambda module: False)
|
|
80
|
-
assert runner.resolve("code2flow") is None
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
def test_missing_lists_tools_in_declaration_order(monkeypatch):
|
|
84
|
-
monkeypatch.setattr(runner, "resolve", lambda name: None)
|
|
85
|
-
assert [tool.name for tool in runner.missing()] == list(runner.TOOLS)
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
def test_run_reports_an_unknown_command():
|
|
89
|
-
ok, output = runner.run(["definitely-not-a-real-binary-xyz"])
|
|
90
|
-
assert not ok
|
|
91
|
-
assert output
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|