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/__init__.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""pymap -- map a Python codebase in one command.
|
|
2
|
+
|
|
3
|
+
It drives the tools that actually work on Python and gathers their output into
|
|
4
|
+
a single page, plus a flow explorer that pymap builds itself:
|
|
5
|
+
|
|
6
|
+
pydeps import graph between modules
|
|
7
|
+
pyreverse class and package diagrams (ships with pylint)
|
|
8
|
+
code2flow call graph at function level
|
|
9
|
+
tach module graph + Mermaid, cycle detection
|
|
10
|
+
|
|
11
|
+
The core depends only on the standard library: missing tools are reported and
|
|
12
|
+
then skipped.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from typing import TYPE_CHECKING, Any
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING: # imported lazily at runtime by __getattr__ below
|
|
20
|
+
from pymap.mapper import Report, map_codebase
|
|
21
|
+
from pymap.settings import Settings
|
|
22
|
+
|
|
23
|
+
__version__ = "0.3.0"
|
|
24
|
+
__all__ = ["Report", "Settings", "__version__", "map_codebase"]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def __getattr__(name: str) -> Any:
|
|
28
|
+
# Lazy import, so `import pymap` stays instant when all you want is __version__.
|
|
29
|
+
if name in ("Report", "map_codebase"):
|
|
30
|
+
from pymap import mapper
|
|
31
|
+
|
|
32
|
+
return getattr(mapper, name)
|
|
33
|
+
if name == "Settings":
|
|
34
|
+
from pymap.settings import Settings
|
|
35
|
+
|
|
36
|
+
return Settings
|
|
37
|
+
raise AttributeError(f"module 'pymap' has no attribute {name!r}")
|
pymap/__main__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Static analysis: everything pymap works out on its own, without any tool."""
|
pymap/analysis/calls.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Reading the call graph produced by code2flow."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
Relations = dict[str, dict[str, list[str]]]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def load_call_graph(path: str | Path) -> Relations:
|
|
12
|
+
"""Read code2flow's JSON and return outgoing and incoming calls per symbol.
|
|
13
|
+
|
|
14
|
+
Result shape: ``{symbol: {"calls": [...], "called_by": [...]}}``. A missing
|
|
15
|
+
or unreadable file yields an empty graph: the explorer works without it, it
|
|
16
|
+
just loses navigation between functions.
|
|
17
|
+
"""
|
|
18
|
+
try:
|
|
19
|
+
graph = json.loads(Path(path).read_text(encoding="utf-8"))["graph"]
|
|
20
|
+
except (OSError, ValueError, KeyError):
|
|
21
|
+
return {}
|
|
22
|
+
|
|
23
|
+
names = {node_id: node["name"] for node_id, node in graph["nodes"].items()}
|
|
24
|
+
relations: dict[str, dict[str, set[str]]] = {}
|
|
25
|
+
for edge in graph["edges"]:
|
|
26
|
+
source, target = names.get(edge["source"]), names.get(edge["target"])
|
|
27
|
+
if not source or not target:
|
|
28
|
+
continue
|
|
29
|
+
relations.setdefault(source, {"calls": set(), "called_by": set()})["calls"].add(target)
|
|
30
|
+
relations.setdefault(target, {"calls": set(), "called_by": set()})["called_by"].add(
|
|
31
|
+
source
|
|
32
|
+
)
|
|
33
|
+
return {
|
|
34
|
+
name: {side: sorted(values) for side, values in sides.items()}
|
|
35
|
+
for name, sides in relations.items()
|
|
36
|
+
}
|
pymap/analysis/cycles.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Circular dependency detection in a module graph."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping, Sequence
|
|
6
|
+
|
|
7
|
+
#: Past this point the list stops being actionable; show the first cycles only.
|
|
8
|
+
MAX_CYCLES = 12
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def detect(deps: Mapping[str, Sequence[str]], limit: int = MAX_CYCLES) -> list[list[str]]:
|
|
12
|
+
"""Find circular dependencies with a depth-first walk.
|
|
13
|
+
|
|
14
|
+
``deps`` maps each node to the nodes it depends on. Returns closed paths
|
|
15
|
+
(the first node is repeated at the end), deduplicated by node set so the
|
|
16
|
+
same cycle is not reported once per entry point.
|
|
17
|
+
"""
|
|
18
|
+
found: list[list[str]] = []
|
|
19
|
+
state: dict[str, int] = {}
|
|
20
|
+
|
|
21
|
+
def visit(node: str, stack: list[str]) -> None:
|
|
22
|
+
if state.get(node) == 1:
|
|
23
|
+
if node in stack:
|
|
24
|
+
found.append([*stack[stack.index(node) :], node])
|
|
25
|
+
return
|
|
26
|
+
if state.get(node) == 2:
|
|
27
|
+
return
|
|
28
|
+
state[node] = 1
|
|
29
|
+
for child in deps.get(node, []):
|
|
30
|
+
visit(child, [*stack, node])
|
|
31
|
+
state[node] = 2
|
|
32
|
+
|
|
33
|
+
for node in list(deps):
|
|
34
|
+
visit(node, [])
|
|
35
|
+
|
|
36
|
+
unique: list[list[str]] = []
|
|
37
|
+
seen: set[frozenset[str]] = set()
|
|
38
|
+
for cycle in found:
|
|
39
|
+
key = frozenset(cycle)
|
|
40
|
+
if key not in seen:
|
|
41
|
+
seen.add(key)
|
|
42
|
+
unique.append(cycle)
|
|
43
|
+
return unique[:limit]
|
pymap/analysis/flow.py
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""Execution tree of a function: sequences, branches, loops, exits.
|
|
2
|
+
|
|
3
|
+
code2flow tells you WHO gets called. This module tells you IN WHAT ORDER, UNDER
|
|
4
|
+
WHICH CONDITION, and above all WHAT DATA flows in and out of each step.
|
|
5
|
+
|
|
6
|
+
Every step is a dict with a ``k`` (kind) key. The shapes are:
|
|
7
|
+
|
|
8
|
+
=========== ==========================================
|
|
9
|
+
kind payload
|
|
10
|
+
=========== ==========================================
|
|
11
|
+
``call`` ``name``, ``line``, ``args``, ``kw``, ``out``
|
|
12
|
+
``branch`` ``test``, ``line``, ``then``, ``alt``
|
|
13
|
+
``loop`` ``test``, ``line``, ``body``
|
|
14
|
+
``try`` ``line``, ``body``, ``fallback``
|
|
15
|
+
``return`` ``expr``, ``line``
|
|
16
|
+
``raise`` ``expr``, ``line``
|
|
17
|
+
=========== ==========================================
|
|
18
|
+
|
|
19
|
+
These key names are a contract with ``templates/explorer.html``; see
|
|
20
|
+
``tests/test_contract.py``, which fails if the two drift apart.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import ast
|
|
26
|
+
import builtins
|
|
27
|
+
from typing import Any
|
|
28
|
+
|
|
29
|
+
Step = dict[str, Any]
|
|
30
|
+
|
|
31
|
+
#: Calls too common to carry any signal in a diagram. Written as one string so
|
|
32
|
+
#: the list stays dense: a set literal of 50 short names formats to 50 lines.
|
|
33
|
+
TRIVIAL = set(dir(builtins)) | set(
|
|
34
|
+
"""
|
|
35
|
+
get set pop append extend keys values items join split strip lower upper
|
|
36
|
+
format encode decode setdefault copy add remove insert index count replace
|
|
37
|
+
read write close seek rstrip lstrip find group match search sub dumps loads
|
|
38
|
+
deepcopy warn debug info warning error exception super partial wraps
|
|
39
|
+
strftime now cast len print
|
|
40
|
+
""".split()
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
#: Steps kept per function, so diagrams stay readable.
|
|
44
|
+
BUDGET = 90
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def call_name(node: ast.expr) -> str | None:
|
|
48
|
+
"""Readable name of a call target, or None when the expression is too indirect."""
|
|
49
|
+
if isinstance(node, ast.Name):
|
|
50
|
+
return node.id
|
|
51
|
+
if isinstance(node, ast.Attribute):
|
|
52
|
+
return node.attr
|
|
53
|
+
if isinstance(node, ast.Call):
|
|
54
|
+
return call_name(node.func)
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def short(node: ast.AST | None, limit: int = 34) -> str:
|
|
59
|
+
"""Render an AST node as a short, readable one-liner."""
|
|
60
|
+
if node is None:
|
|
61
|
+
return ""
|
|
62
|
+
try:
|
|
63
|
+
text = " ".join(ast.unparse(node).split())
|
|
64
|
+
except Exception:
|
|
65
|
+
return "?"
|
|
66
|
+
return text if len(text) <= limit else text[: limit - 1] + "…"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def call_flow(func: ast.AST, budget: list[int] | None = None) -> list[Step]:
|
|
70
|
+
"""Build the execution tree of ``func``'s body.
|
|
71
|
+
|
|
72
|
+
``budget`` is a single-item list used as a shared mutable counter, so that
|
|
73
|
+
nested blocks draw from the same allowance as the top level.
|
|
74
|
+
"""
|
|
75
|
+
if budget is None:
|
|
76
|
+
budget = [BUDGET]
|
|
77
|
+
|
|
78
|
+
def calls(node: ast.AST, out: str | None = None) -> list[Step]:
|
|
79
|
+
steps = []
|
|
80
|
+
for sub in ast.walk(node):
|
|
81
|
+
if isinstance(sub, ast.Call):
|
|
82
|
+
name = call_name(sub.func)
|
|
83
|
+
if not name or name in TRIVIAL:
|
|
84
|
+
continue
|
|
85
|
+
steps.append(
|
|
86
|
+
{
|
|
87
|
+
"k": "call",
|
|
88
|
+
"name": name,
|
|
89
|
+
"line": getattr(sub, "lineno", 0),
|
|
90
|
+
"args": [short(a, 20) for a in sub.args][:4],
|
|
91
|
+
"kw": [kw.arg for kw in sub.keywords if kw.arg][:3],
|
|
92
|
+
"out": out,
|
|
93
|
+
}
|
|
94
|
+
)
|
|
95
|
+
return steps
|
|
96
|
+
|
|
97
|
+
def visit(body: list[ast.stmt]) -> list[Step]:
|
|
98
|
+
block: list[Step] = []
|
|
99
|
+
for node in body:
|
|
100
|
+
if budget[0] <= 0:
|
|
101
|
+
break
|
|
102
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
|
|
103
|
+
continue
|
|
104
|
+
if isinstance(node, ast.If):
|
|
105
|
+
budget[0] -= 1
|
|
106
|
+
block.append(
|
|
107
|
+
{
|
|
108
|
+
"k": "branch",
|
|
109
|
+
"test": short(node.test, 44),
|
|
110
|
+
"line": node.lineno,
|
|
111
|
+
"then": visit(node.body),
|
|
112
|
+
"alt": visit(node.orelse),
|
|
113
|
+
}
|
|
114
|
+
)
|
|
115
|
+
elif isinstance(node, (ast.For, ast.AsyncFor)):
|
|
116
|
+
budget[0] -= 1
|
|
117
|
+
block.append(
|
|
118
|
+
{
|
|
119
|
+
"k": "loop",
|
|
120
|
+
"test": f"for {short(node.target, 14)} in {short(node.iter, 22)}",
|
|
121
|
+
"line": node.lineno,
|
|
122
|
+
"body": visit(node.body),
|
|
123
|
+
}
|
|
124
|
+
)
|
|
125
|
+
elif isinstance(node, ast.While):
|
|
126
|
+
budget[0] -= 1
|
|
127
|
+
block.append(
|
|
128
|
+
{
|
|
129
|
+
"k": "loop",
|
|
130
|
+
"test": f"while {short(node.test, 28)}",
|
|
131
|
+
"line": node.lineno,
|
|
132
|
+
"body": visit(node.body),
|
|
133
|
+
}
|
|
134
|
+
)
|
|
135
|
+
elif isinstance(node, ast.Try):
|
|
136
|
+
budget[0] -= 1
|
|
137
|
+
fallback: list[Step] = []
|
|
138
|
+
for handler in node.handlers:
|
|
139
|
+
fallback += visit(handler.body)
|
|
140
|
+
block.append(
|
|
141
|
+
{
|
|
142
|
+
"k": "try",
|
|
143
|
+
"line": node.lineno,
|
|
144
|
+
"body": visit(node.body) + visit(node.orelse),
|
|
145
|
+
"fallback": fallback + visit(node.finalbody),
|
|
146
|
+
}
|
|
147
|
+
)
|
|
148
|
+
elif isinstance(node, (ast.With, ast.AsyncWith)):
|
|
149
|
+
for item in node.items:
|
|
150
|
+
block += calls(item.context_expr)
|
|
151
|
+
block += visit(node.body)
|
|
152
|
+
elif isinstance(node, ast.Assign):
|
|
153
|
+
block += calls(node.value, short(node.targets[0], 18))
|
|
154
|
+
elif isinstance(node, ast.AnnAssign) and node.value is not None:
|
|
155
|
+
block += calls(node.value, short(node.target, 18))
|
|
156
|
+
elif isinstance(node, ast.Return):
|
|
157
|
+
block += calls(node)
|
|
158
|
+
block.append(
|
|
159
|
+
{
|
|
160
|
+
"k": "return",
|
|
161
|
+
"expr": short(node.value, 36) or "None",
|
|
162
|
+
"line": node.lineno,
|
|
163
|
+
}
|
|
164
|
+
)
|
|
165
|
+
elif isinstance(node, ast.Raise):
|
|
166
|
+
block += calls(node)
|
|
167
|
+
block.append(
|
|
168
|
+
{"k": "raise", "expr": short(node.exc, 32) or "?", "line": node.lineno}
|
|
169
|
+
)
|
|
170
|
+
else:
|
|
171
|
+
block += calls(node)
|
|
172
|
+
budget[0] -= 1
|
|
173
|
+
return block
|
|
174
|
+
|
|
175
|
+
return visit(func.body)
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""Inventory of a codebase: modules, classes, functions, methods.
|
|
2
|
+
|
|
3
|
+
Everything goes through the standard library's AST: no code from the analysed
|
|
4
|
+
project is ever imported or executed, so there are no side effects and nothing
|
|
5
|
+
to install on the target's behalf.
|
|
6
|
+
|
|
7
|
+
Symbol keys are ``<module stem>::<qualified name>``, which is the naming scheme
|
|
8
|
+
code2flow uses for its graph nodes -- that shared shape is what lets the two
|
|
9
|
+
data sets be joined. It also means two files with the same basename share a
|
|
10
|
+
namespace; see ``duplicate_modules``.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import ast
|
|
16
|
+
from collections import Counter
|
|
17
|
+
from collections.abc import Iterable
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
from pymap.analysis.flow import call_flow, short
|
|
22
|
+
|
|
23
|
+
Symbol = dict[str, Any]
|
|
24
|
+
|
|
25
|
+
#: Parameters kept per signature in the input diagram.
|
|
26
|
+
MAX_PARAMS = 10
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def parameters(func: ast.FunctionDef | ast.AsyncFunctionDef) -> list[dict[str, str]]:
|
|
30
|
+
"""Incoming data: parameters, with their type when annotated."""
|
|
31
|
+
args = func.args
|
|
32
|
+
out = []
|
|
33
|
+
for arg in args.posonlyargs + args.args + args.kwonlyargs:
|
|
34
|
+
if arg.arg == "self":
|
|
35
|
+
continue
|
|
36
|
+
out.append({"n": arg.arg, "t": short(arg.annotation, 22) if arg.annotation else ""})
|
|
37
|
+
if args.vararg:
|
|
38
|
+
out.append({"n": "*" + args.vararg.arg, "t": ""})
|
|
39
|
+
if args.kwarg:
|
|
40
|
+
out.append({"n": "**" + args.kwarg.arg, "t": ""})
|
|
41
|
+
return out[:MAX_PARAMS]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def signature(node: ast.FunctionDef | ast.AsyncFunctionDef) -> str:
|
|
45
|
+
"""Rebuild a function's signature from its AST node."""
|
|
46
|
+
try:
|
|
47
|
+
args = node.args
|
|
48
|
+
parts: list[str] = []
|
|
49
|
+
positional = args.posonlyargs + args.args
|
|
50
|
+
defaults = [None] * (len(positional) - len(args.defaults)) + list(args.defaults)
|
|
51
|
+
for arg, default in zip(positional, defaults):
|
|
52
|
+
text = arg.arg
|
|
53
|
+
if arg.annotation:
|
|
54
|
+
text += f": {ast.unparse(arg.annotation)}"
|
|
55
|
+
if default is not None:
|
|
56
|
+
text += f"={ast.unparse(default)}"
|
|
57
|
+
parts.append(text)
|
|
58
|
+
if args.posonlyargs and arg is args.posonlyargs[-1]:
|
|
59
|
+
parts.append("/")
|
|
60
|
+
if args.vararg:
|
|
61
|
+
parts.append("*" + args.vararg.arg)
|
|
62
|
+
elif args.kwonlyargs:
|
|
63
|
+
parts.append("*")
|
|
64
|
+
for arg, default in zip(args.kwonlyargs, args.kw_defaults):
|
|
65
|
+
text = arg.arg
|
|
66
|
+
if arg.annotation:
|
|
67
|
+
text += f": {ast.unparse(arg.annotation)}"
|
|
68
|
+
if default is not None:
|
|
69
|
+
text += f"={ast.unparse(default)}"
|
|
70
|
+
parts.append(text)
|
|
71
|
+
if args.kwarg:
|
|
72
|
+
parts.append("**" + args.kwarg.arg)
|
|
73
|
+
returns = f" -> {ast.unparse(node.returns)}" if node.returns else ""
|
|
74
|
+
return f"({', '.join(parts)}){returns}"
|
|
75
|
+
except Exception:
|
|
76
|
+
return "(...)"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _descend(node: ast.AST, module: str, relative: str, prefix: str = "") -> list[Symbol]:
|
|
80
|
+
"""Classes and functions declared directly under ``node``, depth first."""
|
|
81
|
+
symbols: list[Symbol] = []
|
|
82
|
+
for child in getattr(node, "body", []):
|
|
83
|
+
if isinstance(child, ast.ClassDef):
|
|
84
|
+
qualified = f"{prefix}{child.name}"
|
|
85
|
+
bases = [ast.unparse(base) for base in child.bases]
|
|
86
|
+
symbols.append(
|
|
87
|
+
{
|
|
88
|
+
"key": f"{module}::{qualified}",
|
|
89
|
+
"name": child.name,
|
|
90
|
+
"kind": "class",
|
|
91
|
+
"module": module,
|
|
92
|
+
"file": relative,
|
|
93
|
+
"line": child.lineno,
|
|
94
|
+
"sig": f"({', '.join(bases)})" if bases else "",
|
|
95
|
+
"doc": ast.get_docstring(child) or "",
|
|
96
|
+
"parent": prefix.rstrip("."),
|
|
97
|
+
}
|
|
98
|
+
)
|
|
99
|
+
elif isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
100
|
+
qualified = f"{prefix}{child.name}"
|
|
101
|
+
symbols.append(
|
|
102
|
+
{
|
|
103
|
+
"key": f"{module}::{qualified}",
|
|
104
|
+
"name": child.name,
|
|
105
|
+
"kind": "method" if prefix else "function",
|
|
106
|
+
"module": module,
|
|
107
|
+
"file": relative,
|
|
108
|
+
"line": child.lineno,
|
|
109
|
+
"sig": signature(child),
|
|
110
|
+
"decorators": [ast.unparse(d) for d in child.decorator_list],
|
|
111
|
+
"flow": call_flow(child),
|
|
112
|
+
"params": parameters(child),
|
|
113
|
+
"doc": ast.get_docstring(child) or "",
|
|
114
|
+
"parent": prefix.rstrip("."),
|
|
115
|
+
}
|
|
116
|
+
)
|
|
117
|
+
else:
|
|
118
|
+
continue
|
|
119
|
+
symbols += _descend(child, module, relative, qualified + ".")
|
|
120
|
+
return symbols
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def extract(root: Path, files: Iterable[Path]) -> list[Symbol]:
|
|
124
|
+
"""Walk each file's AST and collect modules, classes and functions.
|
|
125
|
+
|
|
126
|
+
``root`` is used to compute the displayed relative paths; ``files`` is the
|
|
127
|
+
already filtered list of sources to read (see :mod:`pymap.settings`). Files
|
|
128
|
+
that fail to parse are skipped silently rather than aborting the run.
|
|
129
|
+
"""
|
|
130
|
+
symbols: list[Symbol] = []
|
|
131
|
+
for path in sorted(files):
|
|
132
|
+
relative = path.relative_to(root).as_posix()
|
|
133
|
+
module = path.stem
|
|
134
|
+
try:
|
|
135
|
+
tree = ast.parse(path.read_text(encoding="utf-8", errors="ignore"))
|
|
136
|
+
except (SyntaxError, ValueError, OSError):
|
|
137
|
+
continue
|
|
138
|
+
|
|
139
|
+
symbols.append(
|
|
140
|
+
{
|
|
141
|
+
"key": f"{module}::(module)",
|
|
142
|
+
"name": module,
|
|
143
|
+
"kind": "module",
|
|
144
|
+
"module": module,
|
|
145
|
+
"file": relative,
|
|
146
|
+
"line": 1,
|
|
147
|
+
"sig": "",
|
|
148
|
+
"doc": ast.get_docstring(tree) or "",
|
|
149
|
+
"parent": "",
|
|
150
|
+
}
|
|
151
|
+
)
|
|
152
|
+
symbols += _descend(tree, module, relative)
|
|
153
|
+
return symbols
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def coverage(symbols: list[Symbol]) -> tuple[int, int, int]:
|
|
157
|
+
"""(documentable, documented, percentage) -- modules do not count."""
|
|
158
|
+
documentable = [s for s in symbols if s["kind"] != "module"]
|
|
159
|
+
documented = sum(1 for s in documentable if s["doc"])
|
|
160
|
+
percent = round(100 * documented / len(documentable)) if documentable else 0
|
|
161
|
+
return len(documentable), documented, percent
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def duplicate_modules(files: Iterable[Path]) -> dict[str, int]:
|
|
165
|
+
"""Basenames shared by several files, mapped to how many files share them.
|
|
166
|
+
|
|
167
|
+
Symbol keys are built from the file stem, so ``app/models.py`` and
|
|
168
|
+
``blog/models.py`` land in the same namespace and their symbols merge in the
|
|
169
|
+
explorer. Callers surface this so the merge is visible rather than silent.
|
|
170
|
+
|
|
171
|
+
``__init__.py`` is left out: every package has one, so reporting it would
|
|
172
|
+
fire on any well-structured project and teach readers to ignore the notice.
|
|
173
|
+
"""
|
|
174
|
+
counts = Counter(path.stem for path in files if path.name != "__init__.py")
|
|
175
|
+
return {stem: n for stem, n in sorted(counts.items()) if n > 1}
|
pymap/cli.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""Command-line interface.
|
|
2
|
+
|
|
3
|
+
pymap # guess the current project's package
|
|
4
|
+
pymap src/mypkg -o map/ --open
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
import sys
|
|
11
|
+
import webbrowser
|
|
12
|
+
from collections.abc import Sequence
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any, Callable
|
|
15
|
+
|
|
16
|
+
from pymap import __version__
|
|
17
|
+
from pymap.mapper import map_codebase
|
|
18
|
+
from pymap.runner import missing
|
|
19
|
+
from pymap.settings import EDITORS, EXCLUDED, Settings, detect_target, read_pyproject
|
|
20
|
+
|
|
21
|
+
DEFAULTS = {"output": "pymap-out", "editor": "vscode", "timeout": 300}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
25
|
+
parser = argparse.ArgumentParser(
|
|
26
|
+
prog="pymap",
|
|
27
|
+
description="Map a Python codebase in one command.",
|
|
28
|
+
epilog="Options can also live in [tool.pymap] in pyproject.toml.",
|
|
29
|
+
)
|
|
30
|
+
parser.add_argument(
|
|
31
|
+
"target", nargs="?", help="package directory to analyse (guessed when omitted)"
|
|
32
|
+
)
|
|
33
|
+
parser.add_argument(
|
|
34
|
+
"-o", "--out", dest="output", help="output directory (default: pymap-out)"
|
|
35
|
+
)
|
|
36
|
+
parser.add_argument(
|
|
37
|
+
"--exclude",
|
|
38
|
+
action="append",
|
|
39
|
+
metavar="PATTERN",
|
|
40
|
+
help="directory or file pattern to skip (repeatable)",
|
|
41
|
+
)
|
|
42
|
+
parser.add_argument(
|
|
43
|
+
"--editor",
|
|
44
|
+
metavar="NAME",
|
|
45
|
+
help="editor for code links: "
|
|
46
|
+
+ ", ".join(EDITORS)
|
|
47
|
+
+ ", or a URI template containing {f} and {l}",
|
|
48
|
+
)
|
|
49
|
+
parser.add_argument(
|
|
50
|
+
"--timeout", type=int, metavar="S", help="max seconds per external tool (default: 300)"
|
|
51
|
+
)
|
|
52
|
+
parser.add_argument(
|
|
53
|
+
"--open",
|
|
54
|
+
dest="open_in_browser",
|
|
55
|
+
action="store_true",
|
|
56
|
+
help="open the map in a browser when done",
|
|
57
|
+
)
|
|
58
|
+
parser.add_argument("--version", action="version", version=f"pymap {__version__}")
|
|
59
|
+
return parser
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _resolve_path(value: str, base: Path) -> Path:
|
|
63
|
+
path = Path(value).expanduser()
|
|
64
|
+
return path if path.is_absolute() else base / path
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def build_settings(args: argparse.Namespace, cwd: Path | None = None) -> Settings:
|
|
68
|
+
"""Merge command-line arguments, pyproject.toml and defaults.
|
|
69
|
+
|
|
70
|
+
The command line always wins. Relative paths coming from a pyproject.toml
|
|
71
|
+
are resolved against that file; those from the command line against the
|
|
72
|
+
current directory.
|
|
73
|
+
"""
|
|
74
|
+
current = Path(cwd or Path.cwd()).resolve()
|
|
75
|
+
data, config = read_pyproject(current)
|
|
76
|
+
from_file: dict[str, Any] = data.get("tool", {}).get("pymap", {})
|
|
77
|
+
root = config.parent if config else current
|
|
78
|
+
|
|
79
|
+
if args.target:
|
|
80
|
+
target = _resolve_path(args.target, current)
|
|
81
|
+
else:
|
|
82
|
+
guessed = detect_target(current)
|
|
83
|
+
if guessed is None:
|
|
84
|
+
raise SystemExit(
|
|
85
|
+
"No Python package found.\n"
|
|
86
|
+
" pymap looks for src/<package>/, the package named after the "
|
|
87
|
+
"project,\n"
|
|
88
|
+
' or [tool.pymap] target = "..." in pyproject.toml.\n'
|
|
89
|
+
" Point at it explicitly: pymap path/to/my_package"
|
|
90
|
+
)
|
|
91
|
+
target = guessed
|
|
92
|
+
|
|
93
|
+
if args.output:
|
|
94
|
+
output = _resolve_path(args.output, current)
|
|
95
|
+
else:
|
|
96
|
+
output = _resolve_path(from_file.get("output", DEFAULTS["output"]), root)
|
|
97
|
+
|
|
98
|
+
editor = args.editor or from_file.get("editor", DEFAULTS["editor"])
|
|
99
|
+
if editor not in EDITORS and "{f}" not in editor:
|
|
100
|
+
raise SystemExit(
|
|
101
|
+
f"Unknown editor: {editor}\n"
|
|
102
|
+
f" known: {', '.join(EDITORS)}\n"
|
|
103
|
+
" or a URI template, e.g. 'myeditor://{f}:{l}'"
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
return Settings(
|
|
107
|
+
target=target,
|
|
108
|
+
output=output,
|
|
109
|
+
excluded=EXCLUDED + tuple(from_file.get("exclude", ())) + tuple(args.exclude or ()),
|
|
110
|
+
editor=editor,
|
|
111
|
+
timeout=args.timeout or from_file.get("timeout", DEFAULTS["timeout"]),
|
|
112
|
+
open_in_browser=args.open_in_browser,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def warn_missing_tools(log: Callable[[str], Any] = print) -> None:
|
|
117
|
+
"""Report missing tools without blocking: every step knows how to skip itself."""
|
|
118
|
+
absent = missing()
|
|
119
|
+
if not absent:
|
|
120
|
+
return
|
|
121
|
+
log("Missing tools (the matching steps will be skipped):")
|
|
122
|
+
for tool in absent:
|
|
123
|
+
log(f" {tool.name:12} {tool.role:24} {tool.install}")
|
|
124
|
+
log("")
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
128
|
+
args = build_parser().parse_args(argv)
|
|
129
|
+
settings = build_settings(args)
|
|
130
|
+
warn_missing_tools()
|
|
131
|
+
try:
|
|
132
|
+
report = map_codebase(settings, log=print)
|
|
133
|
+
except (NotADirectoryError, FileNotFoundError) as exc:
|
|
134
|
+
print(exc, file=sys.stderr)
|
|
135
|
+
return 1
|
|
136
|
+
print(f"\nMap ready: {report.index}")
|
|
137
|
+
if settings.open_in_browser and report.index:
|
|
138
|
+
webbrowser.open(report.index.as_uri())
|
|
139
|
+
return 0
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
if __name__ == "__main__":
|
|
143
|
+
sys.exit(main())
|