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.
Files changed (41) hide show
  1. {pymap_cli-0.3.0 → pymap_cli-0.4.0}/CHANGELOG.md +31 -0
  2. {pymap_cli-0.3.0 → pymap_cli-0.4.0}/PKG-INFO +1 -1
  3. {pymap_cli-0.3.0 → pymap_cli-0.4.0}/src/pymap/__init__.py +1 -1
  4. {pymap_cli-0.3.0 → pymap_cli-0.4.0}/src/pymap/mapper.py +4 -1
  5. {pymap_cli-0.3.0 → pymap_cli-0.4.0}/src/pymap/runner.py +7 -8
  6. {pymap_cli-0.3.0 → pymap_cli-0.4.0}/src/pymap/templates/explorer.html +77 -12
  7. pymap_cli-0.4.0/src/pymap/tools/__init__.py +81 -0
  8. {pymap_cli-0.3.0 → pymap_cli-0.4.0}/src/pymap/tools/code2flow.py +12 -3
  9. {pymap_cli-0.3.0 → pymap_cli-0.4.0}/src/pymap/tools/pydeps.py +2 -2
  10. {pymap_cli-0.3.0 → pymap_cli-0.4.0}/src/pymap/tools/pyreverse.py +2 -1
  11. {pymap_cli-0.3.0 → pymap_cli-0.4.0}/src/pymap/tools/tach.py +3 -2
  12. pymap_cli-0.4.0/tests/test_explorer.py +114 -0
  13. {pymap_cli-0.3.0 → pymap_cli-0.4.0}/tests/test_tools.py +71 -1
  14. pymap_cli-0.3.0/src/pymap/tools/__init__.py +0 -22
  15. {pymap_cli-0.3.0 → pymap_cli-0.4.0}/.gitignore +0 -0
  16. {pymap_cli-0.3.0 → pymap_cli-0.4.0}/LICENSE +0 -0
  17. {pymap_cli-0.3.0 → pymap_cli-0.4.0}/README.md +0 -0
  18. {pymap_cli-0.3.0 → pymap_cli-0.4.0}/pyproject.toml +0 -0
  19. {pymap_cli-0.3.0 → pymap_cli-0.4.0}/src/pymap/__main__.py +0 -0
  20. {pymap_cli-0.3.0 → pymap_cli-0.4.0}/src/pymap/analysis/__init__.py +0 -0
  21. {pymap_cli-0.3.0 → pymap_cli-0.4.0}/src/pymap/analysis/calls.py +0 -0
  22. {pymap_cli-0.3.0 → pymap_cli-0.4.0}/src/pymap/analysis/cycles.py +0 -0
  23. {pymap_cli-0.3.0 → pymap_cli-0.4.0}/src/pymap/analysis/flow.py +0 -0
  24. {pymap_cli-0.3.0 → pymap_cli-0.4.0}/src/pymap/analysis/symbols.py +0 -0
  25. {pymap_cli-0.3.0 → pymap_cli-0.4.0}/src/pymap/cli.py +0 -0
  26. {pymap_cli-0.3.0 → pymap_cli-0.4.0}/src/pymap/py.typed +0 -0
  27. {pymap_cli-0.3.0 → pymap_cli-0.4.0}/src/pymap/render.py +0 -0
  28. {pymap_cli-0.3.0 → pymap_cli-0.4.0}/src/pymap/settings.py +0 -0
  29. {pymap_cli-0.3.0 → pymap_cli-0.4.0}/src/pymap/templates/__init__.py +0 -0
  30. {pymap_cli-0.3.0 → pymap_cli-0.4.0}/src/pymap/templates/index.html +0 -0
  31. {pymap_cli-0.3.0 → pymap_cli-0.4.0}/src/pymap/templates/section.html +0 -0
  32. {pymap_cli-0.3.0 → pymap_cli-0.4.0}/tests/conftest.py +0 -0
  33. {pymap_cli-0.3.0 → pymap_cli-0.4.0}/tests/sample/__init__.py +0 -0
  34. {pymap_cli-0.3.0 → pymap_cli-0.4.0}/tests/sample/engine.py +0 -0
  35. {pymap_cli-0.3.0 → pymap_cli-0.4.0}/tests/sample/entry.py +0 -0
  36. {pymap_cli-0.3.0 → pymap_cli-0.4.0}/tests/test_analysis.py +0 -0
  37. {pymap_cli-0.3.0 → pymap_cli-0.4.0}/tests/test_cli.py +0 -0
  38. {pymap_cli-0.3.0 → pymap_cli-0.4.0}/tests/test_contract.py +0 -0
  39. {pymap_cli-0.3.0 → pymap_cli-0.4.0}/tests/test_mapper.py +0 -0
  40. {pymap_cli-0.3.0 → pymap_cli-0.4.0}/tests/test_render.py +0 -0
  41. {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.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.3.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
- log(" " + (result["log"] or _summary(name, result)))
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
- cmd: Sequence[str], cwd: str | None = None, timeout: int = 300, full: bool = False
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
- Returns ``(succeeded, output)``. Without ``full`` the output is trimmed to
82
- its last 1500 characters: enough to diagnose, not enough to drown a log.
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
- output = completed.stdout + completed.stderr
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
- $('nr').textContent = roots.length;
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
- renderGroup(out, entries, "Public surface",
479
- "documented functions nobody calls: the entry points", false);
480
- renderGroup(out, orphans, "Not called inside the package",
481
- "special methods Python invokes, or code with no caller", true);
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
- const best = groupBy(entries).map(group => group[0])
657
- .sort((a, b) => reach(b) - reach(a)).slice(0, 3);
658
- canvas.innerHTML = `<div class="home">
659
- <h3>Where to start</h3>
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">${reach(key)} symbols<br>~${Math.max(5, Math.round(reach(key) / 4))} min</span>`
669
- + `</button>`;
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 "&#45;&gt;" 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=log or "code2flow failed")
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(empty, json=output, log="unreadable code2flow JSON")
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=log or "pydeps failed")
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=log or "pyreverse failed")
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=log or "tach sync failed")
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, full=True
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 code2flow, count_svg, pydeps, pyreverse, tach
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&#45;&gt;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 "&#45;&gt;" 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