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.
Files changed (42) hide show
  1. {pymap_cli-0.3.0 → pymap_cli-0.5.0}/CHANGELOG.md +56 -0
  2. {pymap_cli-0.3.0 → pymap_cli-0.5.0}/PKG-INFO +11 -3
  3. {pymap_cli-0.3.0 → pymap_cli-0.5.0}/README.md +10 -2
  4. {pymap_cli-0.3.0 → pymap_cli-0.5.0}/src/pymap/__init__.py +1 -1
  5. {pymap_cli-0.3.0 → pymap_cli-0.5.0}/src/pymap/cli.py +9 -1
  6. {pymap_cli-0.3.0 → pymap_cli-0.5.0}/src/pymap/mapper.py +8 -3
  7. {pymap_cli-0.3.0 → pymap_cli-0.5.0}/src/pymap/runner.py +7 -8
  8. {pymap_cli-0.3.0 → pymap_cli-0.5.0}/src/pymap/settings.py +3 -0
  9. {pymap_cli-0.3.0 → pymap_cli-0.5.0}/src/pymap/templates/explorer.html +77 -12
  10. pymap_cli-0.5.0/src/pymap/tools/__init__.py +86 -0
  11. {pymap_cli-0.3.0 → pymap_cli-0.5.0}/src/pymap/tools/code2flow.py +12 -3
  12. {pymap_cli-0.3.0 → pymap_cli-0.5.0}/src/pymap/tools/pydeps.py +2 -2
  13. {pymap_cli-0.3.0 → pymap_cli-0.5.0}/src/pymap/tools/pyreverse.py +2 -1
  14. {pymap_cli-0.3.0 → pymap_cli-0.5.0}/src/pymap/tools/tach.py +10 -5
  15. pymap_cli-0.5.0/tests/test_explorer.py +114 -0
  16. pymap_cli-0.5.0/tests/test_tools.py +203 -0
  17. pymap_cli-0.3.0/src/pymap/tools/__init__.py +0 -22
  18. pymap_cli-0.3.0/tests/test_tools.py +0 -91
  19. {pymap_cli-0.3.0 → pymap_cli-0.5.0}/.gitignore +0 -0
  20. {pymap_cli-0.3.0 → pymap_cli-0.5.0}/LICENSE +0 -0
  21. {pymap_cli-0.3.0 → pymap_cli-0.5.0}/pyproject.toml +0 -0
  22. {pymap_cli-0.3.0 → pymap_cli-0.5.0}/src/pymap/__main__.py +0 -0
  23. {pymap_cli-0.3.0 → pymap_cli-0.5.0}/src/pymap/analysis/__init__.py +0 -0
  24. {pymap_cli-0.3.0 → pymap_cli-0.5.0}/src/pymap/analysis/calls.py +0 -0
  25. {pymap_cli-0.3.0 → pymap_cli-0.5.0}/src/pymap/analysis/cycles.py +0 -0
  26. {pymap_cli-0.3.0 → pymap_cli-0.5.0}/src/pymap/analysis/flow.py +0 -0
  27. {pymap_cli-0.3.0 → pymap_cli-0.5.0}/src/pymap/analysis/symbols.py +0 -0
  28. {pymap_cli-0.3.0 → pymap_cli-0.5.0}/src/pymap/py.typed +0 -0
  29. {pymap_cli-0.3.0 → pymap_cli-0.5.0}/src/pymap/render.py +0 -0
  30. {pymap_cli-0.3.0 → pymap_cli-0.5.0}/src/pymap/templates/__init__.py +0 -0
  31. {pymap_cli-0.3.0 → pymap_cli-0.5.0}/src/pymap/templates/index.html +0 -0
  32. {pymap_cli-0.3.0 → pymap_cli-0.5.0}/src/pymap/templates/section.html +0 -0
  33. {pymap_cli-0.3.0 → pymap_cli-0.5.0}/tests/conftest.py +0 -0
  34. {pymap_cli-0.3.0 → pymap_cli-0.5.0}/tests/sample/__init__.py +0 -0
  35. {pymap_cli-0.3.0 → pymap_cli-0.5.0}/tests/sample/engine.py +0 -0
  36. {pymap_cli-0.3.0 → pymap_cli-0.5.0}/tests/sample/entry.py +0 -0
  37. {pymap_cli-0.3.0 → pymap_cli-0.5.0}/tests/test_analysis.py +0 -0
  38. {pymap_cli-0.3.0 → pymap_cli-0.5.0}/tests/test_cli.py +0 -0
  39. {pymap_cli-0.3.0 → pymap_cli-0.5.0}/tests/test_contract.py +0 -0
  40. {pymap_cli-0.3.0 → pymap_cli-0.5.0}/tests/test_mapper.py +0 -0
  41. {pymap_cli-0.3.0 → pymap_cli-0.5.0}/tests/test_render.py +0 -0
  42. {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.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
- - **tach declares at most 60 modules.** Beyond that `tach sync` gets very slow
173
- for a graph that is already unreadable. The count left out is reported.
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
- - **tach declares at most 60 modules.** Beyond that `tach sync` gets very slow
133
- for a graph that is already unreadable. The count left out is reported.
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.3.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
- 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"])
@@ -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" ({result['truncated']} modules beyond the limit)" if result["truncated"] else ""
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
- 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:
@@ -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
- $('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,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 "&#45;&gt;" 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=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,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
- #: Past this point ``tach sync`` gets very slow, for a graph already unreadable.
24
- MAX_MODULES = 60
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[:MAX_MODULES]
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=log or "tach sync failed")
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, full=True
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&#45;&gt;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 "&#45;&gt;" 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&#45;&gt;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