pymap-cli 0.3.0__py3-none-any.whl
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/__init__.py +37 -0
- pymap/__main__.py +8 -0
- pymap/analysis/__init__.py +1 -0
- pymap/analysis/calls.py +36 -0
- pymap/analysis/cycles.py +43 -0
- pymap/analysis/flow.py +175 -0
- pymap/analysis/symbols.py +175 -0
- pymap/cli.py +143 -0
- pymap/mapper.py +198 -0
- pymap/py.typed +0 -0
- pymap/render.py +170 -0
- pymap/runner.py +93 -0
- pymap/settings.py +176 -0
- pymap/templates/__init__.py +8 -0
- pymap/templates/explorer.html +822 -0
- pymap/templates/index.html +42 -0
- pymap/templates/section.html +7 -0
- pymap/tools/__init__.py +22 -0
- pymap/tools/code2flow.py +56 -0
- pymap/tools/pydeps.py +42 -0
- pymap/tools/pyreverse.py +41 -0
- pymap/tools/tach.py +104 -0
- pymap_cli-0.3.0.dist-info/METADATA +197 -0
- pymap_cli-0.3.0.dist-info/RECORD +27 -0
- pymap_cli-0.3.0.dist-info/WHEEL +4 -0
- pymap_cli-0.3.0.dist-info/entry_points.txt +2 -0
- pymap_cli-0.3.0.dist-info/licenses/LICENSE +21 -0
pymap/mapper.py
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
"""Orchestration: chain the analyses and write the map.
|
|
2
|
+
|
|
3
|
+
This is also pymap's library API::
|
|
4
|
+
|
|
5
|
+
from pymap import Settings, map_codebase
|
|
6
|
+
|
|
7
|
+
report = map_codebase(Settings(target="src/mypkg"))
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any, Callable
|
|
15
|
+
|
|
16
|
+
from pymap import render
|
|
17
|
+
from pymap.analysis import cycles as cycle_analysis
|
|
18
|
+
from pymap.analysis.calls import load_call_graph
|
|
19
|
+
from pymap.analysis.symbols import Symbol, coverage, duplicate_modules, extract
|
|
20
|
+
from pymap.settings import Settings, count_lines
|
|
21
|
+
from pymap.tools import code2flow, pydeps, pyreverse, tach
|
|
22
|
+
|
|
23
|
+
Log = Callable[[str], Any]
|
|
24
|
+
|
|
25
|
+
#: Steps, in execution order. Adding a tool means adding one line here.
|
|
26
|
+
STEPS = (
|
|
27
|
+
("pydeps", "import graph", pydeps),
|
|
28
|
+
("pyreverse", "classes and inheritance", pyreverse),
|
|
29
|
+
("code2flow", "call graph", code2flow),
|
|
30
|
+
("tach", "modules and cycles", tach),
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class Report:
|
|
36
|
+
"""What pymap found, and where it wrote it."""
|
|
37
|
+
|
|
38
|
+
settings: Settings
|
|
39
|
+
files: list[Path] = field(default_factory=list)
|
|
40
|
+
lines: int = 0
|
|
41
|
+
symbols: list[Symbol] = field(default_factory=list)
|
|
42
|
+
documentable: int = 0
|
|
43
|
+
documented: int = 0
|
|
44
|
+
coverage: int = 0
|
|
45
|
+
cycles: list[list[str]] = field(default_factory=list)
|
|
46
|
+
duplicates: dict[str, int] = field(default_factory=dict)
|
|
47
|
+
tools: dict[str, dict[str, Any]] = field(default_factory=dict)
|
|
48
|
+
index: Path | None = None
|
|
49
|
+
explorer: Path | None = None
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def undocumented(self) -> int:
|
|
53
|
+
return self.documentable - self.documented
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def map_codebase(settings: Settings, log: Log = lambda _: None) -> Report:
|
|
57
|
+
"""Analyse ``settings.target`` and write the map into ``settings.output``."""
|
|
58
|
+
if not settings.target.is_dir():
|
|
59
|
+
raise NotADirectoryError(f"directory not found: {settings.target}")
|
|
60
|
+
settings.output.mkdir(parents=True, exist_ok=True)
|
|
61
|
+
|
|
62
|
+
report = Report(settings=settings, files=settings.files())
|
|
63
|
+
if not report.files:
|
|
64
|
+
raise FileNotFoundError(f"no Python file under {settings.target}")
|
|
65
|
+
report.lines = count_lines(report.files)
|
|
66
|
+
log(f"Mapping {settings.name}: {len(report.files)} files, {report.lines} lines\n")
|
|
67
|
+
|
|
68
|
+
total = len(STEPS) + 1
|
|
69
|
+
for index, (name, role, module) in enumerate(STEPS, 1):
|
|
70
|
+
log(f"[{index}/{total}] {name:11} {role}...")
|
|
71
|
+
report.tools[name] = result = module.execute(settings)
|
|
72
|
+
log(" " + (result["log"] or _summary(name, result)))
|
|
73
|
+
|
|
74
|
+
log(f"[{total}/{total}] explorer assembling...")
|
|
75
|
+
report.cycles = cycle_analysis.detect(report.tools["tach"]["deps"])
|
|
76
|
+
report.symbols = extract(settings.target, report.files)
|
|
77
|
+
report.duplicates = duplicate_modules(report.files)
|
|
78
|
+
report.documentable, report.documented, report.coverage = coverage(report.symbols)
|
|
79
|
+
|
|
80
|
+
relations = load_call_graph(report.tools["code2flow"]["json"] or "")
|
|
81
|
+
report.explorer = settings.output / "explorer.html"
|
|
82
|
+
report.explorer.write_text(
|
|
83
|
+
render.explorer_page(
|
|
84
|
+
settings.name,
|
|
85
|
+
settings.target,
|
|
86
|
+
report.symbols,
|
|
87
|
+
relations,
|
|
88
|
+
report.coverage,
|
|
89
|
+
settings.editor_uri,
|
|
90
|
+
),
|
|
91
|
+
encoding="utf-8",
|
|
92
|
+
)
|
|
93
|
+
log(
|
|
94
|
+
f" {len(report.symbols)} symbols, {report.coverage}% documented "
|
|
95
|
+
f"({report.undocumented} without a docstring)"
|
|
96
|
+
)
|
|
97
|
+
for stem, count in report.duplicates.items():
|
|
98
|
+
log(
|
|
99
|
+
f" note: {count} files are named {stem}.py; "
|
|
100
|
+
f"their symbols share one namespace in the explorer"
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
report.index = settings.output / "index.html"
|
|
104
|
+
report.index.write_text(
|
|
105
|
+
render.index_page(settings.name, len(report.files), report.lines, _sections(report)),
|
|
106
|
+
encoding="utf-8",
|
|
107
|
+
)
|
|
108
|
+
return report
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _summary(name: str, result: dict[str, Any]) -> str:
|
|
112
|
+
"""One terminal line per tool that succeeded."""
|
|
113
|
+
if name == "pydeps":
|
|
114
|
+
return f"{result['modules']} modules, {result['imports']} imports"
|
|
115
|
+
if name == "pyreverse":
|
|
116
|
+
drawn = [kind for kind in ("classes", "packages") if result[kind]]
|
|
117
|
+
return ", ".join(drawn) + " rendered" if drawn else "nothing to draw"
|
|
118
|
+
if name == "code2flow":
|
|
119
|
+
return f"{result['functions']} functions, {result['calls']} calls"
|
|
120
|
+
if name == "tach":
|
|
121
|
+
cut = (
|
|
122
|
+
f" ({result['truncated']} modules beyond the limit)" if result["truncated"] else ""
|
|
123
|
+
)
|
|
124
|
+
return f"{len(result['deps'])} files linked{cut}"
|
|
125
|
+
return "done"
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _sections(report: Report) -> list[str]:
|
|
129
|
+
"""The landing page blocks, in the recommended reading order."""
|
|
130
|
+
tools = report.tools
|
|
131
|
+
sections = [
|
|
132
|
+
render.section(
|
|
133
|
+
title="Walkthrough tree",
|
|
134
|
+
what="Every module, class and function with its signature and docstring, "
|
|
135
|
+
"filterable from the keyboard. From any symbol you can jump to what "
|
|
136
|
+
"it calls and to what calls it. Open this one first.",
|
|
137
|
+
figures=[
|
|
138
|
+
(len(report.symbols), "symbols"),
|
|
139
|
+
(f"{report.coverage}%", "documented"),
|
|
140
|
+
(report.undocumented, "without a docstring"),
|
|
141
|
+
],
|
|
142
|
+
buttons=[("explorer.html", "Open the explorer")],
|
|
143
|
+
)
|
|
144
|
+
]
|
|
145
|
+
|
|
146
|
+
imports = tools["pydeps"]
|
|
147
|
+
sections.append(
|
|
148
|
+
render.section(
|
|
149
|
+
title="Imports between modules",
|
|
150
|
+
what="Who imports whom. The bird's-eye view: it shows the layers and "
|
|
151
|
+
"brings out the modules everyone pulls in."
|
|
152
|
+
if imports["svg"]
|
|
153
|
+
else "",
|
|
154
|
+
missing="" if imports["svg"] else imports["log"],
|
|
155
|
+
figures=[(imports["modules"], "modules"), (imports["imports"], "imports")]
|
|
156
|
+
if imports["svg"]
|
|
157
|
+
else [],
|
|
158
|
+
buttons=[("imports.svg", "Open the graph")] if imports["svg"] else [],
|
|
159
|
+
)
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
classes = tools["pyreverse"]
|
|
163
|
+
drawn = [
|
|
164
|
+
(path.name, label)
|
|
165
|
+
for path, label in (
|
|
166
|
+
(classes["classes"], "Classes and attributes"),
|
|
167
|
+
(classes["packages"], "Packages"),
|
|
168
|
+
)
|
|
169
|
+
if path
|
|
170
|
+
]
|
|
171
|
+
sections.append(
|
|
172
|
+
render.section(
|
|
173
|
+
title="Classes and inheritance",
|
|
174
|
+
what="Classes, their attributes and their inheritance links. Useful when "
|
|
175
|
+
"the code is object-oriented; empty when it is not."
|
|
176
|
+
if drawn
|
|
177
|
+
else "",
|
|
178
|
+
missing="" if drawn else classes["log"],
|
|
179
|
+
buttons=drawn,
|
|
180
|
+
)
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
boundaries = tools["tach"]
|
|
184
|
+
known = bool(boundaries["deps"] or boundaries["mermaid"])
|
|
185
|
+
sections.append(
|
|
186
|
+
render.section(
|
|
187
|
+
title="Module boundaries",
|
|
188
|
+
what="The file-by-file dependency map, and the cycles. Paste the Mermaid "
|
|
189
|
+
"source into a README: it will still be readable in ten years."
|
|
190
|
+
if known
|
|
191
|
+
else "",
|
|
192
|
+
missing="" if known else boundaries["log"],
|
|
193
|
+
figures=[(len(boundaries["deps"]), "files linked")] if boundaries["deps"] else [],
|
|
194
|
+
detail=render.cycles_block(report.cycles) if boundaries["deps"] else "",
|
|
195
|
+
buttons=[("tach.mmd", "Mermaid source")] if boundaries["mermaid"] else [],
|
|
196
|
+
)
|
|
197
|
+
)
|
|
198
|
+
return sections
|
pymap/py.typed
ADDED
|
File without changes
|
pymap/render.py
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""Assembling the HTML pages from templates.
|
|
2
|
+
|
|
3
|
+
Templates are real ``.html`` files (under ``pymap/templates/``), editable and
|
|
4
|
+
readable as-is. Values are injected in place of ``__PYMAP_X__`` tokens: unlike
|
|
5
|
+
``str.format``, no CSS or JavaScript brace has to be doubled.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import html
|
|
11
|
+
import json
|
|
12
|
+
import re
|
|
13
|
+
from collections.abc import Iterable, Sequence
|
|
14
|
+
from importlib import resources
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
TOKEN = re.compile(r"__PYMAP_[A-Z_]+__")
|
|
19
|
+
_CACHE: dict[str, str] = {}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def template(filename: str) -> str:
|
|
23
|
+
"""Contents of a template, read once and then kept in memory."""
|
|
24
|
+
if filename not in _CACHE:
|
|
25
|
+
_CACHE[filename] = (
|
|
26
|
+
resources.files("pymap.templates").joinpath(filename).read_text(encoding="utf-8")
|
|
27
|
+
)
|
|
28
|
+
return _CACHE[filename]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def fill(filename: str, **values: Any) -> str:
|
|
32
|
+
"""Substitute a template's tokens. A forgotten token is an error, not a hole.
|
|
33
|
+
|
|
34
|
+
Validation is done against the template, never against the result: injected
|
|
35
|
+
values legitimately contain ``__PYMAP_...__`` text (pymap can map itself).
|
|
36
|
+
Substitution is a single pass, so one value can never be re-read as the
|
|
37
|
+
token of the next.
|
|
38
|
+
"""
|
|
39
|
+
text = template(filename)
|
|
40
|
+
table = {f"__PYMAP_{key.upper()}__": str(value) for key, value in values.items()}
|
|
41
|
+
expected = set(TOKEN.findall(text))
|
|
42
|
+
if table.keys() - expected:
|
|
43
|
+
raise KeyError(f"{filename}: unknown tokens {sorted(table.keys() - expected)}")
|
|
44
|
+
if expected - table.keys():
|
|
45
|
+
raise KeyError(f"{filename}: unfilled tokens {sorted(expected - table.keys())}")
|
|
46
|
+
return TOKEN.sub(lambda match: table[match.group(0)], text)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def script_json(value: Any) -> str:
|
|
50
|
+
"""Serialise ``value`` as JSON that is safe inside a ``<script>`` block.
|
|
51
|
+
|
|
52
|
+
A docstring containing ``</script>`` would otherwise close the tag early and
|
|
53
|
+
break the whole page, so the characters that can start HTML markup are
|
|
54
|
+
escaped. ``\\u2028`` and ``\\u2029`` are escaped too: they are valid JSON
|
|
55
|
+
but are line terminators to older JavaScript parsers.
|
|
56
|
+
"""
|
|
57
|
+
text = json.dumps(value, ensure_ascii=False)
|
|
58
|
+
for char, escaped in (
|
|
59
|
+
("<", "\\u003c"),
|
|
60
|
+
(">", "\\u003e"),
|
|
61
|
+
("&", "\\u0026"),
|
|
62
|
+
("\u2028", "\\u2028"),
|
|
63
|
+
("\u2029", "\\u2029"),
|
|
64
|
+
):
|
|
65
|
+
text = text.replace(char, escaped)
|
|
66
|
+
return text
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# --- fragments ---------------------------------------------------------------
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def stats(pairs: Sequence[tuple[Any, str]]) -> str:
|
|
73
|
+
"""``[(value, label)]`` -> the row of headline figures."""
|
|
74
|
+
if not pairs:
|
|
75
|
+
return ""
|
|
76
|
+
return (
|
|
77
|
+
'<div class="stats">'
|
|
78
|
+
+ "".join(
|
|
79
|
+
f'<div class="stat"><b>{html.escape(str(value))}</b>'
|
|
80
|
+
f"<span>{html.escape(str(label))}</span></div>"
|
|
81
|
+
for value, label in pairs
|
|
82
|
+
)
|
|
83
|
+
+ "</div>"
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def links(pairs: Sequence[tuple[str, str]]) -> str:
|
|
88
|
+
"""``[(href, label)]`` -> the open buttons."""
|
|
89
|
+
return "".join(
|
|
90
|
+
f'<a class="view" href="{html.escape(str(href))}">{html.escape(str(label))}</a>'
|
|
91
|
+
for href, label in pairs
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def note(text: str) -> str:
|
|
96
|
+
return f'<p class="what">{html.escape(text)}</p>' if text else ""
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def cycles_block(cycles: Iterable[Sequence[str]]) -> str:
|
|
100
|
+
"""The red panel listing circular dependencies."""
|
|
101
|
+
cycles = list(cycles)
|
|
102
|
+
if not cycles:
|
|
103
|
+
return note("No circular dependency found.")
|
|
104
|
+
items = "".join(
|
|
105
|
+
f"<li><code>{html.escape(' -> '.join(Path(node).name for node in cycle))}</code></li>"
|
|
106
|
+
for cycle in cycles
|
|
107
|
+
)
|
|
108
|
+
plural = "y" if len(cycles) == 1 else "ies"
|
|
109
|
+
return (
|
|
110
|
+
f'<div class="cycles"><b>{len(cycles)} circular dependenc{plural}</b>'
|
|
111
|
+
f"<ul>{items}</ul></div>"
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def section(
|
|
116
|
+
title: str,
|
|
117
|
+
what: str = "",
|
|
118
|
+
missing: str = "",
|
|
119
|
+
figures: Sequence[tuple[Any, str]] = (),
|
|
120
|
+
detail: str = "",
|
|
121
|
+
buttons: Sequence[tuple[str, str]] = (),
|
|
122
|
+
) -> str:
|
|
123
|
+
"""One section of the landing page.
|
|
124
|
+
|
|
125
|
+
``what`` is plain text (escaped here); ``missing`` replaces the description
|
|
126
|
+
when the matching tool did not run.
|
|
127
|
+
"""
|
|
128
|
+
description = (
|
|
129
|
+
f'<span class="absent">{html.escape(missing)}</span>' if missing else html.escape(what)
|
|
130
|
+
)
|
|
131
|
+
return fill(
|
|
132
|
+
"section.html",
|
|
133
|
+
title=html.escape(title),
|
|
134
|
+
what=description,
|
|
135
|
+
stats=stats(figures),
|
|
136
|
+
detail=detail,
|
|
137
|
+
links=links(buttons),
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
# --- pages -------------------------------------------------------------------
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def index_page(name: str, file_count: int, line_count: int, sections: Iterable[str]) -> str:
|
|
145
|
+
return fill(
|
|
146
|
+
"index.html",
|
|
147
|
+
name=html.escape(name),
|
|
148
|
+
files=file_count,
|
|
149
|
+
lines=f"{line_count:,}",
|
|
150
|
+
body="\n".join(sections),
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def explorer_page(
|
|
155
|
+
name: str,
|
|
156
|
+
root: str | Path,
|
|
157
|
+
symbols: list[dict[str, Any]],
|
|
158
|
+
relations: dict[str, Any],
|
|
159
|
+
coverage: int,
|
|
160
|
+
editor_uri: str = "",
|
|
161
|
+
) -> str:
|
|
162
|
+
return fill(
|
|
163
|
+
"explorer.html",
|
|
164
|
+
name=html.escape(name),
|
|
165
|
+
coverage=coverage,
|
|
166
|
+
root=script_json(str(root)),
|
|
167
|
+
editor=script_json(editor_uri),
|
|
168
|
+
data=script_json(symbols),
|
|
169
|
+
relations=script_json(relations),
|
|
170
|
+
)
|
pymap/runner.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Running external tools, and detecting the ones that are missing.
|
|
2
|
+
|
|
3
|
+
pymap needs none of these tools to start: every missing step is reported and
|
|
4
|
+
then skipped. That is what makes it safe to install into a project without
|
|
5
|
+
dragging a dependency chain along.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import importlib.util
|
|
11
|
+
import shutil
|
|
12
|
+
import subprocess
|
|
13
|
+
import sys
|
|
14
|
+
from collections.abc import Sequence
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import NamedTuple
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Tool(NamedTuple):
|
|
20
|
+
"""An external tool pymap knows how to drive."""
|
|
21
|
+
|
|
22
|
+
name: str
|
|
23
|
+
module: str | None
|
|
24
|
+
install: str
|
|
25
|
+
role: str
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
#: ``module`` is the fallback: when the console script is not on PATH but the
|
|
29
|
+
#: package is installed in the current interpreter, ``python -m`` finds it.
|
|
30
|
+
TOOLS: dict[str, Tool] = {
|
|
31
|
+
"pydeps": Tool("pydeps", "pydeps", "pip install pydeps", "import graph"),
|
|
32
|
+
"pyreverse": Tool("pyreverse", None, "pip install pylint", "class diagrams"),
|
|
33
|
+
"code2flow": Tool("code2flow", "code2flow", "pip install code2flow", "call graph"),
|
|
34
|
+
"tach": Tool("tach", "tach", "pip install tach", "module boundaries"),
|
|
35
|
+
"dot": Tool(
|
|
36
|
+
"dot", None, "brew install graphviz (or apt install graphviz)", "graph rendering"
|
|
37
|
+
),
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _has_main_module(module: str) -> bool:
|
|
42
|
+
"""True when ``python -m module`` makes sense: not every package has a __main__."""
|
|
43
|
+
try:
|
|
44
|
+
return importlib.util.find_spec(f"{module}.__main__") is not None
|
|
45
|
+
except (ImportError, ValueError, AttributeError):
|
|
46
|
+
return False
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def resolve(name: str) -> list[str] | None:
|
|
50
|
+
"""How to launch ``name`` here, or None when it cannot be found.
|
|
51
|
+
|
|
52
|
+
Look next to the running interpreter first: when pymap is installed in a
|
|
53
|
+
project's virtualenv and invoked by its full path, the venv's ``bin/`` is
|
|
54
|
+
not on PATH even though the tools live there. PATH takes over next, and
|
|
55
|
+
``python -m`` is the last resort.
|
|
56
|
+
"""
|
|
57
|
+
sibling = Path(sys.executable).parent
|
|
58
|
+
for path in (shutil.which(name, path=str(sibling)), shutil.which(name)):
|
|
59
|
+
if path:
|
|
60
|
+
return [path]
|
|
61
|
+
tool = TOOLS.get(name)
|
|
62
|
+
if tool and tool.module and _has_main_module(tool.module):
|
|
63
|
+
return [sys.executable, "-m", tool.module]
|
|
64
|
+
return None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def available(name: str) -> bool:
|
|
68
|
+
return resolve(name) is not None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def missing() -> list[Tool]:
|
|
72
|
+
"""Tools that cannot be found, in declaration order."""
|
|
73
|
+
return [tool for name, tool in TOOLS.items() if not available(name)]
|
|
74
|
+
|
|
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.
|
|
80
|
+
|
|
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.
|
|
83
|
+
"""
|
|
84
|
+
try:
|
|
85
|
+
completed = subprocess.run(
|
|
86
|
+
cmd, cwd=cwd, capture_output=True, text=True, timeout=timeout
|
|
87
|
+
)
|
|
88
|
+
output = completed.stdout + completed.stderr
|
|
89
|
+
return completed.returncode == 0, output if full else output[-1500:]
|
|
90
|
+
except subprocess.TimeoutExpired:
|
|
91
|
+
return False, f"timed out after {timeout}s"
|
|
92
|
+
except (FileNotFoundError, NotADirectoryError, PermissionError) as exc:
|
|
93
|
+
return False, f"command unreachable: {exc}"
|
pymap/settings.py
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""Settings: what to analyse, where to write, what to skip.
|
|
2
|
+
|
|
3
|
+
A single place decides which files enter the map. Without that filter, running
|
|
4
|
+
pymap at a project root would swallow ``.venv/`` and ``node_modules/``.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import contextlib
|
|
10
|
+
import fnmatch
|
|
11
|
+
import os
|
|
12
|
+
from collections.abc import Iterable, Sequence
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
#: Directories and files never analysed: environments, caches, build artefacts.
|
|
18
|
+
EXCLUDED: tuple[str, ...] = tuple(
|
|
19
|
+
"""
|
|
20
|
+
.git .hg .svn __pycache__ .venv venv env .env node_modules site-packages
|
|
21
|
+
build dist *.egg-info .tox .nox .mypy_cache .pytest_cache .ruff_cache
|
|
22
|
+
.ipynb_checkpoints .eggs pymap-out
|
|
23
|
+
""".split()
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
#: URI templates to jump from a diagram to the line of code.
|
|
27
|
+
#: ``{f}`` is the absolute file path, ``{l}`` the line number.
|
|
28
|
+
EDITORS: dict[str, str] = {
|
|
29
|
+
"vscode": "vscode://file/{f}:{l}",
|
|
30
|
+
"vscodium": "vscodium://file/{f}:{l}",
|
|
31
|
+
"cursor": "cursor://file/{f}:{l}",
|
|
32
|
+
"windsurf": "windsurf://file/{f}:{l}",
|
|
33
|
+
"zed": "zed://file/{f}:{l}",
|
|
34
|
+
"pycharm": "pycharm://open?file={f}&line={l}",
|
|
35
|
+
"idea": "idea://open?file={f}&line={l}",
|
|
36
|
+
"sublime": "subl://open?url=file://{f}&line={l}",
|
|
37
|
+
"none": "",
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
#: Directory names that are never a project's main package.
|
|
41
|
+
NOT_A_PACKAGE = set("tests test docs doc examples scripts tools benchmarks migrations".split())
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def is_excluded(name: str, patterns: Sequence[str] = EXCLUDED) -> bool:
|
|
45
|
+
"""True when ``name`` matches one of the exclusion patterns."""
|
|
46
|
+
return any(fnmatch.fnmatch(name, pattern) for pattern in patterns)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def python_files(root: Path, excluded: Sequence[str] = EXCLUDED) -> list[Path]:
|
|
50
|
+
"""Sorted list of ``.py`` sources under ``root``, excluded branches pruned.
|
|
51
|
+
|
|
52
|
+
Pruning happens during the walk rather than after it, so a ``.venv`` holding
|
|
53
|
+
30,000 files is never traversed.
|
|
54
|
+
"""
|
|
55
|
+
found: list[Path] = []
|
|
56
|
+
for directory, subdirs, filenames in os.walk(root):
|
|
57
|
+
subdirs[:] = sorted(d for d in subdirs if not is_excluded(d, excluded))
|
|
58
|
+
for filename in sorted(filenames):
|
|
59
|
+
if filename.endswith(".py") and not is_excluded(filename, excluded):
|
|
60
|
+
found.append(Path(directory) / filename)
|
|
61
|
+
return found
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def count_lines(files: Iterable[Path]) -> int:
|
|
65
|
+
"""Total line count of the given files; unreadable ones count as zero."""
|
|
66
|
+
total = 0
|
|
67
|
+
for path in files:
|
|
68
|
+
with contextlib.suppress(OSError):
|
|
69
|
+
total += len(path.read_text(encoding="utf-8", errors="ignore").splitlines())
|
|
70
|
+
return total
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def read_pyproject(start: Path) -> tuple[dict[str, Any], Path | None]:
|
|
74
|
+
"""Return the contents of the nearest ``pyproject.toml`` and its path.
|
|
75
|
+
|
|
76
|
+
Walks up from ``start``. Without a TOML reader (Python < 3.11 and no
|
|
77
|
+
``tomli`` installed) it returns an empty mapping: configuration is optional
|
|
78
|
+
and pymap must still run.
|
|
79
|
+
"""
|
|
80
|
+
try:
|
|
81
|
+
import tomllib
|
|
82
|
+
except ModuleNotFoundError: # pragma: no cover - Python < 3.11
|
|
83
|
+
try:
|
|
84
|
+
import tomli as tomllib # type: ignore[no-redef]
|
|
85
|
+
except ModuleNotFoundError:
|
|
86
|
+
return {}, None
|
|
87
|
+
|
|
88
|
+
for directory in [start, *start.parents]:
|
|
89
|
+
config = directory / "pyproject.toml"
|
|
90
|
+
if config.is_file():
|
|
91
|
+
try:
|
|
92
|
+
return tomllib.loads(config.read_text(encoding="utf-8")), config
|
|
93
|
+
except (OSError, ValueError):
|
|
94
|
+
return {}, config
|
|
95
|
+
return {}, None
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def detect_target(start: Path) -> Path | None:
|
|
99
|
+
"""Guess which package to map when no target is given.
|
|
100
|
+
|
|
101
|
+
In order: ``[tool.pymap] target``, a ``src/`` layout, the package named
|
|
102
|
+
after the project, then the single top-level package.
|
|
103
|
+
"""
|
|
104
|
+
data, config = read_pyproject(start)
|
|
105
|
+
root = config.parent if config else start
|
|
106
|
+
|
|
107
|
+
declared = data.get("tool", {}).get("pymap", {}).get("target")
|
|
108
|
+
if declared:
|
|
109
|
+
path = (root / declared).resolve()
|
|
110
|
+
return path if path.is_dir() else None
|
|
111
|
+
|
|
112
|
+
def packages(directory: Path) -> list[Path]:
|
|
113
|
+
if not directory.is_dir():
|
|
114
|
+
return []
|
|
115
|
+
return sorted(
|
|
116
|
+
child
|
|
117
|
+
for child in directory.iterdir()
|
|
118
|
+
if child.is_dir()
|
|
119
|
+
and (child / "__init__.py").is_file()
|
|
120
|
+
and not is_excluded(child.name)
|
|
121
|
+
and child.name not in NOT_A_PACKAGE
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
under_src = packages(root / "src")
|
|
125
|
+
if len(under_src) == 1:
|
|
126
|
+
return under_src[0]
|
|
127
|
+
|
|
128
|
+
name = data.get("project", {}).get("name", "")
|
|
129
|
+
if name:
|
|
130
|
+
expected = root / name.replace("-", "_")
|
|
131
|
+
if (expected / "__init__.py").is_file():
|
|
132
|
+
return expected
|
|
133
|
+
|
|
134
|
+
top_level = packages(root)
|
|
135
|
+
if len(top_level) == 1:
|
|
136
|
+
return top_level[0]
|
|
137
|
+
return None
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@dataclass
|
|
141
|
+
class Settings:
|
|
142
|
+
"""Everything a run needs, resolved once and for all.
|
|
143
|
+
|
|
144
|
+
``target`` and ``output`` are declared as :class:`~pathlib.Path` but accept
|
|
145
|
+
plain strings too: both are coerced and made absolute on construction.
|
|
146
|
+
"""
|
|
147
|
+
|
|
148
|
+
target: Path
|
|
149
|
+
output: Path = Path("pymap-out")
|
|
150
|
+
excluded: Sequence[str] = EXCLUDED
|
|
151
|
+
editor: str = "vscode"
|
|
152
|
+
timeout: int = 300
|
|
153
|
+
open_in_browser: bool = False
|
|
154
|
+
_files: list[Path] | None = field(default=None, repr=False, compare=False)
|
|
155
|
+
|
|
156
|
+
def __post_init__(self) -> None:
|
|
157
|
+
self.target = Path(self.target).resolve()
|
|
158
|
+
self.output = Path(self.output).resolve()
|
|
159
|
+
self.excluded = tuple(self.excluded)
|
|
160
|
+
|
|
161
|
+
@property
|
|
162
|
+
def name(self) -> str:
|
|
163
|
+
return self.target.name
|
|
164
|
+
|
|
165
|
+
@property
|
|
166
|
+
def editor_uri(self) -> str:
|
|
167
|
+
"""URI template for the chosen editor; empty string when disabled."""
|
|
168
|
+
if self.editor in EDITORS:
|
|
169
|
+
return EDITORS[self.editor]
|
|
170
|
+
return self.editor if "{f}" in self.editor else ""
|
|
171
|
+
|
|
172
|
+
def files(self) -> list[Path]:
|
|
173
|
+
"""Analysable sources, computed once."""
|
|
174
|
+
if self._files is None:
|
|
175
|
+
self._files = python_files(self.target, self.excluded)
|
|
176
|
+
return self._files
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""HTML templates, shipped as package data.
|
|
2
|
+
|
|
3
|
+
This file is what makes ``templates`` a regular package rather than a namespace
|
|
4
|
+
one. Without it, ``importlib.resources.files("pymap.templates")`` raises
|
|
5
|
+
``TypeError`` on Python 3.9, where the fallback dereferences ``spec.origin`` --
|
|
6
|
+
which is ``None`` for a namespace package. Python 3.10 and later handle the
|
|
7
|
+
namespace case, so the failure only shows up on the oldest version we support.
|
|
8
|
+
"""
|