scopia 1.0.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.
scopia/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """scopia — see the shape of a change before reading its lines."""
2
+
3
+ __version__ = "1.0.0"
scopia/cli.py ADDED
@@ -0,0 +1,363 @@
1
+ """Command line entry point.
2
+
3
+ Zero-config by design: bare `scopia` reviews the working tree against HEAD. Every
4
+ flag has a working default, and there is no init step or config file.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import argparse
10
+ import json
11
+ import sys
12
+ from pathlib import Path
13
+
14
+ from . import __version__
15
+ from .graph.build import (
16
+ DEFAULT_HOPS,
17
+ DEFAULT_MAX_NODES,
18
+ DEFAULT_UTILITY_FAN_IN,
19
+ build_graph,
20
+ )
21
+ from .graph.model import Graph
22
+ from .vcs.git import GitError, Repo, parse_revisions
23
+
24
+ GATE_MIN_FILES = 3
25
+
26
+
27
+ def build_parser() -> argparse.ArgumentParser:
28
+ parser = argparse.ArgumentParser(
29
+ prog="scopia",
30
+ description="Draw one graph of what a diff changed, so a reviewer sees the shape "
31
+ "before reading the lines.",
32
+ epilog="Examples:\n"
33
+ " scopia review the working tree against HEAD\n"
34
+ " scopia HEAD~3..HEAD review a range\n"
35
+ " scopia main.. --html r.html write a self-contained report\n"
36
+ " scopia --watch keep the graph current while an agent writes\n"
37
+ " scopia --watch --html the same, as a live page in your browser\n",
38
+ formatter_class=argparse.RawDescriptionHelpFormatter,
39
+ )
40
+ parser.add_argument(
41
+ "revisions",
42
+ nargs="?",
43
+ help="revision or range (default: working tree vs HEAD)",
44
+ )
45
+ parser.add_argument(
46
+ "--html",
47
+ metavar="PATH",
48
+ nargs="?",
49
+ const="",
50
+ default=None,
51
+ help="write a self-contained HTML report; with --watch, serve it live instead "
52
+ "(PATH, if given, is also rewritten on each update)",
53
+ )
54
+ parser.add_argument(
55
+ "--watch",
56
+ action="store_true",
57
+ help="follow the working tree and redraw as files settle; Ctrl-C to quit",
58
+ )
59
+ parser.add_argument(
60
+ "--lsp",
61
+ action="store_true",
62
+ help="sharpen name-matched edges with a language server when one is installed "
63
+ "(Python: jedi-language-server). Falls back silently to static analysis.",
64
+ )
65
+ parser.add_argument(
66
+ "--no-open",
67
+ action="store_true",
68
+ help="with --watch --html, print the URL without opening a browser",
69
+ )
70
+ parser.add_argument(
71
+ "--format",
72
+ choices=("text", "mermaid", "json"),
73
+ default="text",
74
+ help="stdout format (default: text)",
75
+ )
76
+ parser.add_argument(
77
+ "--hops",
78
+ type=int,
79
+ default=DEFAULT_HOPS,
80
+ help=f"neighbour hops to expand (default: {DEFAULT_HOPS})",
81
+ )
82
+ parser.add_argument(
83
+ "--max-nodes",
84
+ type=int,
85
+ default=DEFAULT_MAX_NODES,
86
+ help=f"cap on drawn nodes (default: {DEFAULT_MAX_NODES})",
87
+ )
88
+ parser.add_argument(
89
+ "--to-entry",
90
+ action="store_true",
91
+ help="climb callers until each chain reaches an entry point — the route, job "
92
+ "or command that triggers the changed code",
93
+ )
94
+ parser.add_argument(
95
+ "--utility",
96
+ type=int,
97
+ default=DEFAULT_UTILITY_FAN_IN,
98
+ metavar="N",
99
+ help="hide unchanged helpers called from more than N places "
100
+ f"(default: {DEFAULT_UTILITY_FAN_IN}; 0 keeps everything)",
101
+ )
102
+ parser.add_argument(
103
+ "--exclude",
104
+ action="append",
105
+ default=[],
106
+ metavar="GLOB",
107
+ help="skip paths matching GLOB (repeatable); vendor and node_modules "
108
+ "are skipped already",
109
+ )
110
+ parser.add_argument("--jobs", type=int, default=None, help="parser processes when indexing")
111
+ parser.add_argument(
112
+ "--always",
113
+ action="store_true",
114
+ help="draw even when the diff is too small to be worth a graph",
115
+ )
116
+ parser.add_argument("--no-color", action="store_true", help="disable ANSI colour")
117
+ parser.add_argument("--version", action="version", version=f"scopia {__version__}")
118
+ return parser
119
+
120
+
121
+ def main(argv: list[str] | None = None) -> int:
122
+ args = list(sys.argv[1:] if argv is None else argv)
123
+ # `scopia review <range>` is the documented long form; `scopia <range>` is the
124
+ # same thing with less typing.
125
+ if args and args[0] == "review":
126
+ args = args[1:]
127
+
128
+ opts = build_parser().parse_args(args)
129
+
130
+ if opts.watch:
131
+ problem = _watch_conflict(opts)
132
+ if problem:
133
+ print(f"scopia: {problem}", file=sys.stderr)
134
+ return 2
135
+ elif opts.html == "":
136
+ print(
137
+ "scopia: --html needs a PATH to write to (or add --watch to serve it live).",
138
+ file=sys.stderr,
139
+ )
140
+ return 2
141
+
142
+ try:
143
+ repo = Repo.discover(Path.cwd())
144
+ except GitError as exc:
145
+ print(f"scopia: {exc}", file=sys.stderr)
146
+ print("scopia needs to run inside a git repository.", file=sys.stderr)
147
+ return 2
148
+
149
+ if opts.watch:
150
+ try:
151
+ return _watch(repo, opts)
152
+ except KeyboardInterrupt:
153
+ # An impatient second Ctrl-C lands while the first is still tearing down.
154
+ # Quitting is what was asked for either way; a stack trace just makes a
155
+ # normal exit look like a crash.
156
+ return 0
157
+
158
+ try:
159
+ graph = build_graph(
160
+ repo,
161
+ parse_revisions(opts.revisions),
162
+ hops=opts.hops,
163
+ max_nodes=opts.max_nodes,
164
+ jobs=opts.jobs,
165
+ utility_fan_in=opts.utility,
166
+ exclude=tuple(opts.exclude),
167
+ to_entry=opts.to_entry,
168
+ )
169
+ except GitError as exc:
170
+ print(f"scopia: {exc}", file=sys.stderr)
171
+ hint = _revision_hint(repo, opts.revisions, str(exc))
172
+ if hint:
173
+ print(hint, file=sys.stderr)
174
+ return 2
175
+
176
+ if not graph.nodes:
177
+ print("No changes in supported files.")
178
+ return 0
179
+
180
+ if opts.lsp:
181
+ graph = _refine(repo, graph)
182
+
183
+ if not opts.always and _below_gate(graph):
184
+ print(_gate_message(graph))
185
+ return 0
186
+
187
+ if opts.html:
188
+ _write_html(graph, Path(opts.html))
189
+
190
+ if opts.format == "mermaid":
191
+ from .render import mermaid
192
+
193
+ print(mermaid.render(graph))
194
+ elif opts.format == "json":
195
+ print(json.dumps(graph.to_dict(), indent=2))
196
+ else:
197
+ from .render import text
198
+
199
+ sys.stdout.write(text.render(graph, color=False if opts.no_color else None))
200
+ if opts.html:
201
+ print(f"\nHTML report: {opts.html}")
202
+
203
+ return 0
204
+
205
+
206
+ def _watch_conflict(opts: argparse.Namespace) -> str:
207
+ if opts.revisions:
208
+ return "--watch follows the working tree against HEAD; drop the revision."
209
+ if opts.format != "text":
210
+ return "--watch draws the text report or the live page; --format does not apply."
211
+ return ""
212
+
213
+
214
+ def _graceful_signals() -> None:
215
+ """Turn SIGTERM and SIGHUP (a closed terminal) into the same clean exit as Ctrl-C.
216
+
217
+ Left alone they kill the process without running `finally` blocks, which strands any
218
+ language server as an orphan — and not every server exits when its stdin closes.
219
+ """
220
+ import signal
221
+
222
+ def stop(_signum, _frame):
223
+ raise KeyboardInterrupt
224
+
225
+ for name in ("SIGTERM", "SIGHUP"):
226
+ if hasattr(signal, name):
227
+ signal.signal(getattr(signal, name), stop)
228
+
229
+
230
+ def _watch(repo: Repo, opts: argparse.Namespace) -> int:
231
+ from .watch.loop import Watcher
232
+
233
+ _graceful_signals()
234
+
235
+ # The graph starts blank and fills in as work lands. Diffing against HEAD would
236
+ # open with everything already uncommitted — in a dirty tree that is hundreds of
237
+ # nodes, and the file just written is lost among them.
238
+ session_start = repo.working_tree_shas()
239
+
240
+ def changed_since_start() -> frozenset[str]:
241
+ now = repo.working_tree_shas()
242
+ moved = {path for path, sha in now.items() if session_start.get(path) != sha}
243
+ moved |= {path for path in session_start if path not in now} # deleted
244
+ return frozenset(moved)
245
+
246
+ def build() -> Graph:
247
+ # Watch has no gate: a small change is exactly what you want to see arrive.
248
+ return build_graph(
249
+ repo,
250
+ parse_revisions(None),
251
+ hops=opts.hops,
252
+ max_nodes=opts.max_nodes,
253
+ jobs=1,
254
+ utility_fan_in=opts.utility,
255
+ exclude=tuple(opts.exclude),
256
+ to_entry=opts.to_entry,
257
+ only_paths=changed_since_start(),
258
+ )
259
+
260
+ watcher = Watcher(repo, build)
261
+ upgrader = _upgrader(repo, opts)
262
+ if opts.html is None:
263
+ from .render import text
264
+ from .watch import terminal
265
+
266
+ color = False if opts.no_color else text.supports_color()
267
+ return terminal.run(watcher, color=color, upgrader=upgrader)
268
+
269
+ from .watch import live
270
+ from .watch.server import LiveServer
271
+
272
+ server = LiveServer()
273
+ server.start()
274
+ print(f"scopia watching {repo.root}\nlive page: {server.url} (Ctrl-C to quit)")
275
+ if not opts.no_open:
276
+ import webbrowser
277
+
278
+ webbrowser.open(server.url)
279
+ return live.run(
280
+ watcher,
281
+ server,
282
+ upgrader=upgrader,
283
+ also_write=Path(opts.html) if opts.html else None,
284
+ )
285
+
286
+
287
+ def _upgrader(repo: Repo, opts: argparse.Namespace):
288
+ if not opts.lsp:
289
+ return None
290
+ from .lsp.resolve import AsyncUpgrader
291
+ from .lsp.session import Sessions
292
+
293
+ return AsyncUpgrader(Sessions(repo.root), repo.root)
294
+
295
+
296
+ def _revision_hint(repo: Repo, spec: str | None, error: str) -> str:
297
+ """Explain a revision git rejected, in terms of what the user typed.
298
+
299
+ `HEAD~1..HEAD` on a repo with one commit is the common case — the documented way to
300
+ review a commit fails on the very first one, and raw git output does not say why.
301
+ """
302
+ if "unknown revision" not in error and "ambiguous argument" not in error:
303
+ return ""
304
+ try:
305
+ count = int(repo.commit_count())
306
+ except (GitError, ValueError):
307
+ return ""
308
+ if count <= 1 and spec and "~" in spec:
309
+ return (
310
+ f"This repository has {count} commit(s), so '{spec}' has no parent to "
311
+ "compare against.\nReview the working tree with just 'scopia', or a single "
312
+ "commit with 'scopia <sha>'."
313
+ )
314
+ return "Check the revision exists: git log --oneline -5"
315
+
316
+
317
+ def _refine(repo: Repo, graph: Graph) -> Graph:
318
+ from .lsp.resolve import upgrade
319
+ from .lsp.session import Sessions
320
+
321
+ sessions = Sessions(repo.root)
322
+ try:
323
+ refined, stats = upgrade(graph, sessions, repo.root)
324
+ finally:
325
+ sessions.close() # never leave a server running behind a one-shot command
326
+ print(f"scopia: {stats.summary()} ({stats.seconds:.1f}s)", file=sys.stderr)
327
+ for note in stats.notes:
328
+ print(f"scopia: {note}", file=sys.stderr)
329
+ return refined
330
+
331
+
332
+ def _below_gate(graph: Graph) -> bool:
333
+ """A small, self-contained change is faster to read as a plain diff.
334
+
335
+ Drawing one anyway would be the tool asserting its own relevance rather than earning it.
336
+ """
337
+ touched = graph.touched
338
+ files = {n.path for n in touched}
339
+ touched_uids = {n.uid for n in touched}
340
+ linking = any(e.src in touched_uids and e.dst in touched_uids for e in graph.edges)
341
+ return len(files) < GATE_MIN_FILES and not linking
342
+
343
+
344
+ def _gate_message(graph: Graph) -> str:
345
+ touched = graph.touched
346
+ names = ", ".join(sorted(n.label for n in touched)[:4])
347
+ return (
348
+ f"{len(touched)} symbol(s) touched in "
349
+ f"{len({n.path for n in touched})} file(s), with no connections between them"
350
+ f"{': ' + names if names else ''}.\n"
351
+ "Too small to be worth a graph — read the diff. Use --always to draw it anyway."
352
+ )
353
+
354
+
355
+ def _write_html(graph: Graph, path: Path) -> None:
356
+ from .render import html as html_render
357
+
358
+ path.parent.mkdir(parents=True, exist_ok=True)
359
+ path.write_text(html_render.render(graph), "utf-8")
360
+
361
+
362
+ if __name__ == "__main__": # pragma: no cover
363
+ raise SystemExit(main())
File without changes