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 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,8 @@
1
+ """Enables `python -m pymap`, useful when the console script is not on PATH."""
2
+
3
+ import sys
4
+
5
+ from pymap.cli import main
6
+
7
+ if __name__ == "__main__":
8
+ sys.exit(main())
@@ -0,0 +1 @@
1
+ """Static analysis: everything pymap works out on its own, without any tool."""
@@ -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
+ }
@@ -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())