pymap-cli 0.3.0__tar.gz → 0.4.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {pymap_cli-0.3.0 → pymap_cli-0.4.0}/CHANGELOG.md +31 -0
- {pymap_cli-0.3.0 → pymap_cli-0.4.0}/PKG-INFO +1 -1
- {pymap_cli-0.3.0 → pymap_cli-0.4.0}/src/pymap/__init__.py +1 -1
- {pymap_cli-0.3.0 → pymap_cli-0.4.0}/src/pymap/mapper.py +4 -1
- {pymap_cli-0.3.0 → pymap_cli-0.4.0}/src/pymap/runner.py +7 -8
- {pymap_cli-0.3.0 → pymap_cli-0.4.0}/src/pymap/templates/explorer.html +77 -12
- pymap_cli-0.4.0/src/pymap/tools/__init__.py +81 -0
- {pymap_cli-0.3.0 → pymap_cli-0.4.0}/src/pymap/tools/code2flow.py +12 -3
- {pymap_cli-0.3.0 → pymap_cli-0.4.0}/src/pymap/tools/pydeps.py +2 -2
- {pymap_cli-0.3.0 → pymap_cli-0.4.0}/src/pymap/tools/pyreverse.py +2 -1
- {pymap_cli-0.3.0 → pymap_cli-0.4.0}/src/pymap/tools/tach.py +3 -2
- pymap_cli-0.4.0/tests/test_explorer.py +114 -0
- {pymap_cli-0.3.0 → pymap_cli-0.4.0}/tests/test_tools.py +71 -1
- pymap_cli-0.3.0/src/pymap/tools/__init__.py +0 -22
- {pymap_cli-0.3.0 → pymap_cli-0.4.0}/.gitignore +0 -0
- {pymap_cli-0.3.0 → pymap_cli-0.4.0}/LICENSE +0 -0
- {pymap_cli-0.3.0 → pymap_cli-0.4.0}/README.md +0 -0
- {pymap_cli-0.3.0 → pymap_cli-0.4.0}/pyproject.toml +0 -0
- {pymap_cli-0.3.0 → pymap_cli-0.4.0}/src/pymap/__main__.py +0 -0
- {pymap_cli-0.3.0 → pymap_cli-0.4.0}/src/pymap/analysis/__init__.py +0 -0
- {pymap_cli-0.3.0 → pymap_cli-0.4.0}/src/pymap/analysis/calls.py +0 -0
- {pymap_cli-0.3.0 → pymap_cli-0.4.0}/src/pymap/analysis/cycles.py +0 -0
- {pymap_cli-0.3.0 → pymap_cli-0.4.0}/src/pymap/analysis/flow.py +0 -0
- {pymap_cli-0.3.0 → pymap_cli-0.4.0}/src/pymap/analysis/symbols.py +0 -0
- {pymap_cli-0.3.0 → pymap_cli-0.4.0}/src/pymap/cli.py +0 -0
- {pymap_cli-0.3.0 → pymap_cli-0.4.0}/src/pymap/py.typed +0 -0
- {pymap_cli-0.3.0 → pymap_cli-0.4.0}/src/pymap/render.py +0 -0
- {pymap_cli-0.3.0 → pymap_cli-0.4.0}/src/pymap/settings.py +0 -0
- {pymap_cli-0.3.0 → pymap_cli-0.4.0}/src/pymap/templates/__init__.py +0 -0
- {pymap_cli-0.3.0 → pymap_cli-0.4.0}/src/pymap/templates/index.html +0 -0
- {pymap_cli-0.3.0 → pymap_cli-0.4.0}/src/pymap/templates/section.html +0 -0
- {pymap_cli-0.3.0 → pymap_cli-0.4.0}/tests/conftest.py +0 -0
- {pymap_cli-0.3.0 → pymap_cli-0.4.0}/tests/sample/__init__.py +0 -0
- {pymap_cli-0.3.0 → pymap_cli-0.4.0}/tests/sample/engine.py +0 -0
- {pymap_cli-0.3.0 → pymap_cli-0.4.0}/tests/sample/entry.py +0 -0
- {pymap_cli-0.3.0 → pymap_cli-0.4.0}/tests/test_analysis.py +0 -0
- {pymap_cli-0.3.0 → pymap_cli-0.4.0}/tests/test_cli.py +0 -0
- {pymap_cli-0.3.0 → pymap_cli-0.4.0}/tests/test_contract.py +0 -0
- {pymap_cli-0.3.0 → pymap_cli-0.4.0}/tests/test_mapper.py +0 -0
- {pymap_cli-0.3.0 → pymap_cli-0.4.0}/tests/test_render.py +0 -0
- {pymap_cli-0.3.0 → pymap_cli-0.4.0}/tests/test_settings.py +0 -0
|
@@ -4,6 +4,37 @@ 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.4.0] — 2026-09-04
|
|
8
|
+
|
|
9
|
+
Reported from the field: code2flow crashed on a real codebase, and pymap
|
|
10
|
+
handled it badly in two separate ways.
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
- **The explorer came out empty when there was no call graph.** The navigation
|
|
14
|
+
panel is built from the graph's roots, so with no edges there was nothing to
|
|
15
|
+
branch from: pymap announced "1476 symbols" and then showed a blank panel and
|
|
16
|
+
no starting point. Symbols are now listed by module instead, each keeping its
|
|
17
|
+
own flow diagram, and the landing page says what is missing and why.
|
|
18
|
+
- **A crashing tool dumped its traceback into the terminal.** The full output
|
|
19
|
+
now goes to `<output>/<tool>.log`; what stays on screen is the cause, where
|
|
20
|
+
the detail went, and what the failure costs.
|
|
21
|
+
- `runner.run()` trimmed output to its last 1500 characters, which cut the
|
|
22
|
+
`Traceback` header off the top of a crash — leaving the saved log incomplete
|
|
23
|
+
and the crash unrecognised. Trimming is a display concern and has moved out of
|
|
24
|
+
the runner; crashes are now also identified by their stack frames.
|
|
25
|
+
|
|
26
|
+
### Added
|
|
27
|
+
- `tests/test_explorer.py` runs the explorer script under Node against a stubbed
|
|
28
|
+
DOM and asserts the panel is populated, with and without a call graph. The
|
|
29
|
+
empty-panel failure was invisible to every existing test: the page rendered,
|
|
30
|
+
it just had nothing in it.
|
|
31
|
+
|
|
32
|
+
### Note
|
|
33
|
+
code2flow asserts that a call's target is a name, attribute, subscript or call.
|
|
34
|
+
It raises `AssertionError` on `(f if cond else g)(x)`, an immediately-invoked
|
|
35
|
+
lambda, `(f or g)(x)` and `(await f())()`. That is a code2flow limitation;
|
|
36
|
+
pymap now degrades instead of following it down.
|
|
37
|
+
|
|
7
38
|
## [0.3.0] — 2026-09-04
|
|
8
39
|
|
|
9
40
|
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.4.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
|
|
@@ -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.4.0"
|
|
24
24
|
__all__ = ["Report", "Settings", "__version__", "map_codebase"]
|
|
25
25
|
|
|
26
26
|
|
|
@@ -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"])
|
|
@@ -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:
|
|
@@ -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,81 @@
|
|
|
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
|
+
if output.strip():
|
|
56
|
+
path = settings.output / f"{tool}.log"
|
|
57
|
+
kept = output[:MAX_LOG]
|
|
58
|
+
if len(output) > MAX_LOG:
|
|
59
|
+
kept += f"\n\n[truncated: {len(output) - MAX_LOG} more characters]\n"
|
|
60
|
+
try:
|
|
61
|
+
path.write_text(kept, encoding="utf-8")
|
|
62
|
+
where = f" (output in {path.name})"
|
|
63
|
+
except OSError:
|
|
64
|
+
where = ""
|
|
65
|
+
lines = [f"{tool} {summarise_output(output)}{where}"]
|
|
66
|
+
if looks_like_a_crash(output):
|
|
67
|
+
lines.append(f"this is a {tool} limitation, not a pymap error")
|
|
68
|
+
if consequence:
|
|
69
|
+
lines.append(consequence)
|
|
70
|
+
return "\n".join(lines)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def count_svg(path: str | Path) -> tuple[int, int]:
|
|
74
|
+
"""(nodes, edges) of an SVG produced by Graphviz."""
|
|
75
|
+
try:
|
|
76
|
+
text = Path(path).read_text(encoding="utf-8", errors="ignore")
|
|
77
|
+
except OSError:
|
|
78
|
+
return 0, 0
|
|
79
|
+
titles = re.findall(r"<title>([^<]+)</title>", text)
|
|
80
|
+
edges = [t for t in titles if "->" in t or "->" in t]
|
|
81
|
+
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,6 +17,7 @@ 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
|
|
|
@@ -76,7 +77,7 @@ def execute(settings: Settings) -> dict[str, Any]:
|
|
|
76
77
|
|
|
77
78
|
ok, log = runner.run([*base, "sync"], cwd=str(workspace), timeout=settings.timeout)
|
|
78
79
|
if not ok:
|
|
79
|
-
return dict(empty, log=
|
|
80
|
+
return dict(empty, log=record_failure(settings, "tach", log))
|
|
80
81
|
|
|
81
82
|
mermaid = settings.output / "tach.mmd"
|
|
82
83
|
drawn, _ = runner.run(
|
|
@@ -87,7 +88,7 @@ def execute(settings: Settings) -> dict[str, Any]:
|
|
|
87
88
|
|
|
88
89
|
deps: dict[str, list[str]] = {}
|
|
89
90
|
mapped, raw = runner.run(
|
|
90
|
-
[*base, "map", "-o", "-"], cwd=str(workspace), timeout=settings.timeout
|
|
91
|
+
[*base, "map", "-o", "-"], cwd=str(workspace), timeout=settings.timeout
|
|
91
92
|
)
|
|
92
93
|
if mapped:
|
|
93
94
|
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"]
|
|
@@ -1,7 +1,16 @@
|
|
|
1
1
|
"""Tool wrappers: clean fallback when absent, and tach's internal logic."""
|
|
2
2
|
|
|
3
3
|
from pymap import runner
|
|
4
|
-
from pymap.tools import
|
|
4
|
+
from pymap.tools import (
|
|
5
|
+
MAX_LOG,
|
|
6
|
+
code2flow,
|
|
7
|
+
count_svg,
|
|
8
|
+
pydeps,
|
|
9
|
+
pyreverse,
|
|
10
|
+
record_failure,
|
|
11
|
+
summarise_output,
|
|
12
|
+
tach,
|
|
13
|
+
)
|
|
5
14
|
|
|
6
15
|
SVG = (
|
|
7
16
|
"<svg><g><title>a</title></g><g><title>b</title></g><g><title>a->b</title></g></svg>"
|
|
@@ -89,3 +98,64 @@ def test_run_reports_an_unknown_command():
|
|
|
89
98
|
ok, output = runner.run(["definitely-not-a-real-binary-xyz"])
|
|
90
99
|
assert not ok
|
|
91
100
|
assert output
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
# --- failure reporting -------------------------------------------------------
|
|
104
|
+
|
|
105
|
+
TRACEBACK_OUTPUT = """Traceback (most recent call last):
|
|
106
|
+
File "/x/code2flow/engine.py", line 734, in code2flow
|
|
107
|
+
file_groups, all_nodes, edges = map_it(sources, language, no_trimming,
|
|
108
|
+
File "/x/code2flow/python.py", line 18, in get_call_from_func_element
|
|
109
|
+
assert type(func) in (ast.Attribute, ast.Name, ast.Subscript, ast.Call)
|
|
110
|
+
AssertionError
|
|
111
|
+
"""
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def test_summarise_output_picks_the_exception_out_of_a_traceback():
|
|
115
|
+
assert summarise_output(TRACEBACK_OUTPUT) == "crashed: AssertionError"
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def test_summarise_output_keeps_a_plain_message():
|
|
119
|
+
assert summarise_output("could not open file\n") == "could not open file"
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def test_summarise_output_handles_silence():
|
|
123
|
+
assert summarise_output(" \n") == "failed without producing any output"
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def test_record_failure_moves_the_traceback_into_a_file(settings):
|
|
127
|
+
settings.output.mkdir(parents=True, exist_ok=True)
|
|
128
|
+
message = record_failure(settings, "code2flow", TRACEBACK_OUTPUT, "calls are lost")
|
|
129
|
+
|
|
130
|
+
assert (settings.output / "code2flow.log").read_text() == TRACEBACK_OUTPUT
|
|
131
|
+
# What stays on screen: cause, where the detail went, what it costs.
|
|
132
|
+
assert "crashed: AssertionError" in message
|
|
133
|
+
assert "code2flow.log" in message
|
|
134
|
+
assert "not a pymap error" in message
|
|
135
|
+
assert "calls are lost" in message
|
|
136
|
+
# And not the wall of traceback itself.
|
|
137
|
+
assert 'File "/x/code2flow' not in message
|
|
138
|
+
assert len(message.splitlines()) == 3
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def test_record_failure_without_output_writes_no_file(settings):
|
|
142
|
+
settings.output.mkdir(parents=True, exist_ok=True)
|
|
143
|
+
record_failure(settings, "tach", "")
|
|
144
|
+
assert not (settings.output / "tach.log").exists()
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def test_record_failure_recognises_a_traceback_whose_header_was_clipped(settings):
|
|
148
|
+
"""Output can arrive already cut at the top; stack frames still identify it."""
|
|
149
|
+
settings.output.mkdir(parents=True, exist_ok=True)
|
|
150
|
+
clipped = TRACEBACK_OUTPUT.split("\n", 1)[1]
|
|
151
|
+
assert "Traceback" not in clipped
|
|
152
|
+
assert summarise_output(clipped) == "crashed: AssertionError"
|
|
153
|
+
assert "not a pymap error" in record_failure(settings, "code2flow", clipped)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def test_record_failure_caps_a_runaway_log(settings):
|
|
157
|
+
settings.output.mkdir(parents=True, exist_ok=True)
|
|
158
|
+
record_failure(settings, "tach", "x" * (MAX_LOG + 5000))
|
|
159
|
+
written = (settings.output / "tach.log").read_text()
|
|
160
|
+
assert len(written) < MAX_LOG + 200
|
|
161
|
+
assert "truncated" in written
|
|
@@ -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)
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|