codeterrain 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.
codeterrain/cli.py ADDED
@@ -0,0 +1,1447 @@
1
+ """The ``terrain`` command line."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import collections
7
+ import json
8
+ import os
9
+ import re
10
+ import sqlite3
11
+ import sys
12
+ import time
13
+ from dataclasses import dataclass
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ from codeterrain import (
18
+ __version__,
19
+ calls_join,
20
+ calls_static,
21
+ code_static,
22
+ fixtures_order,
23
+ fixtures_static,
24
+ impact,
25
+ languages,
26
+ protocol,
27
+ routes_view,
28
+ views,
29
+ )
30
+ from codeterrain.budget import (
31
+ DEFAULT_BUDGET,
32
+ cut_lines,
33
+ field_safe,
34
+ render_table,
35
+ terminal_safe,
36
+ )
37
+ from codeterrain.cache import Cache, CacheBusy, age_text, checkouts
38
+ from codeterrain.config import (
39
+ Config,
40
+ ConfigError,
41
+ load_config,
42
+ resolve_root,
43
+ user_config_path,
44
+ )
45
+ from codeterrain.executables import distrust
46
+ from codeterrain.plugins.manifest_join import ManifestError, route_key
47
+ from codeterrain.sandbox import SandboxError, probe_versions
48
+
49
+ PYTHON_ENV = "CODETERRAIN_PYTHON"
50
+ #: ``terrain refresh languages``: every installed language pack.
51
+ LANGUAGES = "languages"
52
+
53
+
54
+ class UsageError(Exception):
55
+ """A bad argument value; reported as ``terrain: <message>`` with exit status 2."""
56
+
57
+
58
+ @dataclass
59
+ class Context:
60
+ config: Config
61
+ cache: Cache
62
+ args: argparse.Namespace
63
+
64
+ @property
65
+ def python(self) -> str:
66
+ return self.args.python or os.environ.get(PYTHON_ENV) or sys.executable
67
+
68
+
69
+ def _common() -> argparse.ArgumentParser:
70
+ common = argparse.ArgumentParser(add_help=False)
71
+ common.add_argument("--root", help="repository root (default: current directory)")
72
+ common.add_argument("--config", help="TOML file with a [tool.codeterrain] table")
73
+ common.add_argument("--cache-dir", help="cache location (or $CODETERRAIN_CACHE_DIR)")
74
+ common.add_argument("--python", help="interpreter for the import and pytest tier")
75
+ common.add_argument(
76
+ "--budget",
77
+ type=int,
78
+ default=DEFAULT_BUDGET,
79
+ help="cut text output at about N tokens (default %(default)s; 0 = no cut)",
80
+ )
81
+ common.add_argument("--json", action="store_true", help="print the full data as JSON")
82
+ common.add_argument("--fresh", action="store_true", help="rebuild before answering")
83
+ return common
84
+
85
+
86
+ def build_parser() -> argparse.ArgumentParser:
87
+ common = _common()
88
+ parser = argparse.ArgumentParser(
89
+ prog="terrain",
90
+ description="Answers about your code in under a second, from a cache (no LLM, no network).",
91
+ )
92
+ parser.add_argument("--version", action="version", version=f"terrain {__version__}")
93
+ sub = parser.add_subparsers(dest="command", required=True, metavar="COMMAND")
94
+
95
+ p = sub.add_parser("fixtures", parents=[common], help="list pytest fixtures")
96
+ p.add_argument("substring", nargs="?", default="", help="name contains this")
97
+ p.add_argument("--autouse", action="store_true", help="only autouse fixtures")
98
+ p.add_argument("--scope", help="only this scope (session, package, module, class, function)")
99
+ p.set_defaults(run=cmd_fixtures)
100
+
101
+ p = sub.add_parser("fixture", parents=[common], help="one fixture and who requests it")
102
+ p.add_argument("name")
103
+ p.set_defaults(run=cmd_fixture)
104
+
105
+ p = sub.add_parser("doctor", parents=[common], help="resolved config, cache and versions")
106
+ p.set_defaults(run=cmd_doctor)
107
+
108
+ p = sub.add_parser("setup-order", parents=[common], help="fixture setup order of a test")
109
+ p.add_argument("nodeid", help="pytest node id; a function, class or file matches its tests")
110
+ p.set_defaults(run=cmd_setup_order)
111
+
112
+ p = sub.add_parser("routes", parents=[common], help="list routes of the configured apps")
113
+ p.add_argument("substring", nargs="?", default="", help="path or handler contains this")
114
+ p.add_argument("--app", help="only apps whose spec contains this")
115
+ p.add_argument("--access", help="only this access class from the route manifest")
116
+ p.set_defaults(run=cmd_routes)
117
+
118
+ p = sub.add_parser("route", parents=[common], help="one route in full")
119
+ p.add_argument("method")
120
+ p.add_argument("path")
121
+ p.set_defaults(run=cmd_route)
122
+
123
+ p = sub.add_parser("calls", parents=[common], help="HTTP calls in JavaScript and TypeScript")
124
+ p.add_argument("substring", nargs="?", default="", help="path or file contains this")
125
+ p.add_argument("--method", help="only this HTTP method")
126
+ p.add_argument("--unresolved", action="store_true", help="only calls whose path is not static")
127
+ p.set_defaults(run=cmd_calls)
128
+
129
+ p = sub.add_parser(
130
+ "callers", parents=[common], help="front-end calls of a route (client_repos joined)"
131
+ )
132
+ p.add_argument("method", nargs="?", help="with PATH: only the calls of this route")
133
+ p.add_argument("path", nargs="?")
134
+ p.add_argument("--unmatched", action="store_true", help="only calls that hit no route")
135
+ p.set_defaults(run=cmd_callers)
136
+
137
+ p = sub.add_parser(
138
+ "def", parents=[common],
139
+ help="where a Python name, or a name of an installed language pack, is defined",
140
+ )
141
+ p.add_argument(
142
+ "name", nargs="?", help="a bare, qualified (Class.method) or full dotted name"
143
+ )
144
+ p.add_argument(
145
+ "--kind",
146
+ help="only definitions of this kind (class, function, module, provider, ...); without a "
147
+ "name, every one of them",
148
+ )
149
+ p.set_defaults(run=cmd_def)
150
+
151
+ p = sub.add_parser(
152
+ "refs", parents=[common], help="call sites of a Python name, their shape, and patches"
153
+ )
154
+ p.add_argument("name", help="a bare, qualified (Class.method) or full dotted name")
155
+ side = p.add_mutually_exclusive_group()
156
+ side.add_argument("--tests", action="store_true", help="only call sites in tests")
157
+ side.add_argument("--code", action="store_true", help="only call sites outside tests")
158
+ p.add_argument(
159
+ "--by-name", action="store_true",
160
+ help="also list calls of the same name on a receiver whose type is unknown",
161
+ )
162
+ p.set_defaults(run=cmd_refs)
163
+
164
+ p = sub.add_parser(
165
+ "importers", parents=[common],
166
+ help="where a Python module, or a name from it, is imported",
167
+ )
168
+ p.add_argument("module", help="a full dotted module name, or its last parts")
169
+ side = p.add_mutually_exclusive_group()
170
+ side.add_argument("--tests", action="store_true", help="only imports in tests")
171
+ side.add_argument("--code", action="store_true", help="only imports outside tests")
172
+ p.set_defaults(run=cmd_importers)
173
+
174
+ p = sub.add_parser("imports", parents=[common], help="what a Python module imports")
175
+ p.add_argument("module", help="a full dotted module name, or its last parts")
176
+ p.set_defaults(run=cmd_imports)
177
+
178
+ p = sub.add_parser(
179
+ "impact", parents=[common],
180
+ help="the tests that reach a Python name, through its callers and fixtures",
181
+ )
182
+ p.add_argument("name", help="a bare, qualified (Class.method) or full dotted name")
183
+ p.add_argument(
184
+ "--depth", type=int, default=impact.DEFAULT_DEPTH,
185
+ help=f"levels of callers to follow (default {impact.DEFAULT_DEPTH}, "
186
+ f"at most {impact.MAX_DEPTH})",
187
+ )
188
+ p.set_defaults(run=cmd_impact)
189
+
190
+ p = sub.add_parser("refresh", parents=[common], help="rebuild cached views")
191
+ p.add_argument(
192
+ "view", nargs="?", choices=[*views.ALL_VIEWS, code_static.VIEW, LANGUAGES],
193
+ help="default: all",
194
+ )
195
+ p.set_defaults(run=cmd_refresh)
196
+ return parser
197
+
198
+
199
+ #: The methods ``callers METHOD PATH`` takes; anything else in that place is a misread command.
200
+ HTTP_METHODS = frozenset(
201
+ {"GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS", "TRACE", "CONNECT", "ANY",
202
+ "WEBSOCKET"}
203
+ )
204
+ _WINDOWS = os.name == "nt"
205
+ #: The last line of a text answer cut to its budget (``budget.cut_lines``).
206
+ _CUT_NOTICE = re.compile(r"\+\d+ more[:;]")
207
+
208
+
209
+ def _out(text: str, err: bool = False) -> None:
210
+ """Print plain text with terminal controls escaped; ``--json`` output is escaped by json.
211
+
212
+ When a cut answer goes to a pipe, its ``+N more`` line is repeated on stderr: ``| grep`` would
213
+ otherwise drop it, and the answer would read as complete."""
214
+ safe = terminal_safe(text)
215
+ print(safe, file=sys.stderr if err else sys.stdout)
216
+ last = safe.rsplit("\n", 1)[-1]
217
+ if not err and _CUT_NOTICE.match(last) and not _isatty(sys.stdout):
218
+ print(f"terrain: output cut: {last}", file=sys.stderr)
219
+
220
+
221
+ def _isatty(stream: Any) -> bool:
222
+ try:
223
+ return stream.isatty()
224
+ except (AttributeError, ValueError):
225
+ return False
226
+
227
+
228
+ def _shell_path_note(value: str | None) -> list[str]:
229
+ """A note when ``value`` looks like a path Git Bash rewrote: ``/me`` reaches terrain as
230
+ ``C:/Program Files/Git/me`` in an MSYS shell, and then matches nothing."""
231
+ if not value or not _WINDOWS or not re.match(r"[A-Za-z]:[/\\]", value):
232
+ return []
233
+ return [
234
+ f"note: {field_safe(value)!r} looks like a path the shell rewrote (Git Bash turns '/me' "
235
+ "into 'C:/Program Files/Git/me'); set MSYS_NO_PATHCONV=1 or write '//me'"
236
+ ]
237
+
238
+
239
+ def _fields(value: Any) -> Any:
240
+ """``value`` with every string escaped by ``field_safe``: repository text (names, paths, node
241
+ ids, error lines) is made safe one field at a time, before it is joined into lines."""
242
+ if isinstance(value, str):
243
+ return field_safe(value)
244
+ if isinstance(value, list):
245
+ return [_fields(v) for v in value]
246
+ if isinstance(value, dict):
247
+ return {k: _fields(v) for k, v in value.items()}
248
+ return value
249
+
250
+
251
+ #: The checkout the running command answers from, for the JSON envelope; set by ``main``.
252
+ _answer_root = ""
253
+
254
+
255
+ def _emit_json(view: str, built_at: str, rows: Any, **extra: Any) -> None:
256
+ print(
257
+ json.dumps(
258
+ {"view": view, "built_at": built_at, "root": _answer_root, "rows": rows, **extra},
259
+ indent=1,
260
+ )
261
+ )
262
+
263
+
264
+ def _warn_unparsed(data: dict[str, Any], as_json: bool) -> None:
265
+ if data["errors"] and not as_json:
266
+ _note_plain(
267
+ f"warning: {len(data['errors'])} files could not be parsed "
268
+ "(run with --json for details)"
269
+ )
270
+
271
+
272
+ def _fixtures_record(ctx: Context) -> Any:
273
+ record, _ = fixtures_static.build(ctx.cache, ctx.config, force=ctx.args.fresh)
274
+ _warn_unparsed(record.data, ctx.args.json)
275
+ return record
276
+
277
+
278
+ def _deps(fx: dict[str, Any]) -> str:
279
+ return ",".join(fx["argnames"])
280
+
281
+
282
+ def _unlisted_text(trees: list[str]) -> str:
283
+ named = ", ".join(_fields(trees))
284
+ return (
285
+ f"test_paths leaves out {named}, which hold{'s' if len(trees) == 1 else ''} a conftest.py "
286
+ "or test modules: their fixtures are not seen; add them to test_paths"
287
+ )
288
+
289
+
290
+ def _note_unlisted(ctx: Context) -> list[str]:
291
+ """Name the test trees the fixture view does not read (a same-named fixture from another tree
292
+ would otherwise be taken for theirs), on stderr; returns them for ``--json``."""
293
+ trees = fixtures_static.unlisted_test_trees(ctx.config)
294
+ if trees and not ctx.args.json:
295
+ _note(_unlisted_text(trees))
296
+ return trees
297
+
298
+
299
+ def cmd_fixtures(ctx: Context) -> int:
300
+ args = ctx.args
301
+ try:
302
+ scope = fixtures_static.normalise_scope(args.scope) if args.scope else None
303
+ except ValueError as exc:
304
+ raise UsageError(str(exc)) from exc
305
+ record = _fixtures_record(ctx)
306
+ rows = fixtures_static.select(record.data, args.substring, args.autouse, scope)
307
+ unlisted = _note_unlisted(ctx)
308
+ if args.json:
309
+ _emit_json(
310
+ "fixtures", record.built_at, rows, errors=record.data["errors"],
311
+ unlisted_test_paths=unlisted,
312
+ )
313
+ return 0
314
+ if not rows:
315
+ empty = not record.data["fixtures"]
316
+ _out(
317
+ "no fixtures found in test_paths: " + ", ".join(_fields(_path_status(ctx.config)))
318
+ if empty
319
+ else "no fixtures match"
320
+ )
321
+ return 0
322
+ table = render_table(
323
+ ["name", "scope", "autouse", "deps", "file:line", "overrides"],
324
+ [
325
+ [
326
+ fx["name"],
327
+ fx["scope"],
328
+ "yes" if fx["autouse"] else "",
329
+ _deps(fx),
330
+ f"{fx['file']}:{fx['line']}",
331
+ fx["overrides"],
332
+ ]
333
+ for fx in rows
334
+ ],
335
+ args.budget,
336
+ "a name substring, --scope or --autouse",
337
+ )
338
+ _out(table)
339
+ return 0
340
+
341
+
342
+ def cmd_calls(ctx: Context) -> int:
343
+ args = ctx.args
344
+ record, _ = calls_static.build(
345
+ ctx.cache, ctx.config, force=args.fresh, readers=_call_readers()
346
+ )
347
+ _warn_unparsed(record.data, args.json)
348
+ rows = calls_static.select(record.data, args.substring, args.method, args.unresolved)
349
+ if args.json:
350
+ _emit_json("calls", record.built_at, rows, errors=record.data["errors"])
351
+ return 0
352
+ if not rows:
353
+ _out("no calls match" if record.data["calls"] else "no HTTP calls found")
354
+ return 0
355
+ table = render_table(
356
+ ["method", "path", "file:line", "style"],
357
+ [
358
+ [c["method"], c["path"] or f"? {c['raw']}", f"{c['file']}:{c['line']}", c["style"]]
359
+ for c in rows
360
+ ],
361
+ args.budget,
362
+ "a path substring, --method or --unresolved",
363
+ )
364
+ _out(table)
365
+ return 0
366
+
367
+
368
+ def _code_index(ctx: Context) -> tuple[str, code_static.Index]:
369
+ """The code index, brought up to date; (when it last changed, the index)."""
370
+ db = code_static.update(ctx.cache, ctx.config, force=ctx.args.fresh)
371
+ found = code_static.summary(db)
372
+ _warn_unparsed(found, ctx.args.json)
373
+ return found["built_at"], code_static.Index(db)
374
+
375
+
376
+ def _no_definition(index: code_static.Index, name: str) -> int:
377
+ close = index.similar(name)
378
+ hint = f"; similar: {', '.join(_fields(close))}" if close else ""
379
+ _out(f"no definition of {field_safe(name)}{hint}", err=True)
380
+ if index.modules_named(name, 1):
381
+ shown = field_safe(name)
382
+ _note(f"{shown} is a module: terrain importers {shown} lists where it is imported")
383
+ return 1
384
+
385
+
386
+ def _count(n: int, word: str) -> str:
387
+ return f"{n} {word}{'' if n == 1 else 's'}"
388
+
389
+
390
+ def _modules(index: code_static.Index, name: str) -> list[tuple[str, str]]:
391
+ found = index.modules_named(name)
392
+ if not found:
393
+ _out(f"no module named {field_safe(name)}", err=True)
394
+ return found
395
+
396
+
397
+ def _import_rows(rows: list[dict[str, Any]], first: str) -> list[list[str]]:
398
+ return [
399
+ [f"{r[first]}:{r['line']}" if first == "file" else str(r["line"]),
400
+ r["scope"] or "(module)", r["imports"], r["as"]]
401
+ for r in _fields(rows)
402
+ ]
403
+
404
+
405
+ def cmd_importers(ctx: Context) -> int:
406
+ args = ctx.args
407
+ built_at, index = _code_index(ctx)
408
+ modules = _modules(index, args.module)
409
+ if not modules:
410
+ return 1
411
+ rows, cut = [], False
412
+ for module, _ in modules:
413
+ found = index.importers(module)
414
+ cut = cut or len(found) >= code_static.MAX_IMPORTS
415
+ rows += [
416
+ {**r, "module": module} for r in found
417
+ if (not args.tests or r["test"]) and (not args.code or not r["test"])
418
+ ]
419
+ if args.json:
420
+ _emit_json(
421
+ "importers", built_at, rows, modules=[m for m, _ in modules], truncated=cut
422
+ )
423
+ return 0
424
+ files = {r["file"] for r in rows}
425
+ in_tests = len({r["file"] for r in rows if r["test"]})
426
+ lines = [f"module {field_safe(m)} {field_safe(rel)}" for m, rel in modules]
427
+ lines.append(
428
+ f"{_count(len(rows), 'import')} in {_count(len(files), 'file')} "
429
+ f"({len(files) - in_tests} in code, "
430
+ f"{in_tests} in tests)" + (f"; cut at {code_static.MAX_IMPORTS} a module" if cut else "")
431
+ )
432
+ _out("\n".join(lines))
433
+ if rows:
434
+ _out(render_table(
435
+ ["file:line", "scope", "imports", "as"], _import_rows(rows, "file"), args.budget,
436
+ "--tests, --code or a full module name",
437
+ ))
438
+ return 0
439
+
440
+
441
+ def cmd_imports(ctx: Context) -> int:
442
+ built_at, index = _code_index(ctx)
443
+ modules = _modules(index, ctx.args.module)
444
+ if not modules:
445
+ return 1
446
+ rows = [{**r, "module": m} for m, rel in modules for r in index.imports_of(rel)]
447
+ if ctx.args.json:
448
+ _emit_json("imports", built_at, rows, modules=[m for m, _ in modules])
449
+ return 0
450
+ for module, rel in modules:
451
+ mine = [r for r in rows if r["module"] == module]
452
+ _out(f"module {field_safe(module)} {field_safe(rel)}: {_count(len(mine), 'import')}")
453
+ if mine:
454
+ _out(render_table(
455
+ ["line", "scope", "imports", "as"], _import_rows(mine, "line"), ctx.args.budget,
456
+ "--json",
457
+ ))
458
+ return 0
459
+
460
+
461
+ def _note_more_matches(
462
+ index: code_static.Index, name: str, found: list[str], kind: str | None = None
463
+ ) -> None:
464
+ if len(found) >= code_static.MAX_MATCHES or index.find_cut:
465
+ total = index.count_matches(name, kind)
466
+ if total > len(found):
467
+ _note(
468
+ f"{total} definitions match {field_safe(_one_line(name, 200))}; the first "
469
+ f"{len(found)} are used: give a qualified or full dotted name"
470
+ )
471
+
472
+
473
+ def _call_readers() -> list[languages.Language]:
474
+ """The installed language packs that read front-end calls; a refused pack is reported."""
475
+ found, problems = languages.discover()
476
+ for problem in problems:
477
+ _note(f"warning: {field_safe(problem)}")
478
+ return [lang for lang in found if lang.read_calls is not None]
479
+
480
+
481
+ def _languages(ctx: Context) -> list[tuple[languages.Language, dict[str, Any]]]:
482
+ """Each installed language pack with its view, brought up to date; a refused pack is
483
+ reported and left out."""
484
+ found, problems = languages.discover()
485
+ for problem in problems:
486
+ _note(f"warning: {field_safe(problem)}")
487
+ out = []
488
+ for language in (lang for lang in found if lang.read is not None):
489
+ data = languages.build(ctx.cache, language, force=ctx.args.fresh).data
490
+ _warn_unparsed(data, ctx.args.json)
491
+ out.append((language, data))
492
+ return out
493
+
494
+
495
+ def _language_defs(
496
+ ctx: Context, name: str, kind: str | None = None
497
+ ) -> list[tuple[languages.Language, dict[str, Any], dict[str, Any]]]:
498
+ """The definitions of ``name`` (of ``kind`` if given) in every language pack: (pack, its
499
+ view, definition)."""
500
+ return [
501
+ (language, data, d)
502
+ for language, data in _languages(ctx)
503
+ for d in languages.find(data, name, kind)
504
+ ]
505
+
506
+
507
+ #: Definitions ``terrain def --kind`` lists without a name.
508
+ MAX_KIND_ROWS = 1_000
509
+
510
+
511
+ def cmd_def(ctx: Context) -> int:
512
+ name, kind = ctx.args.name, ctx.args.kind
513
+ if name is None and not kind:
514
+ _out("terrain def: give a name, --kind, or both", err=True)
515
+ return 2
516
+ built_at, index = _code_index(ctx)
517
+ if name is None:
518
+ found = index.of_kind(kind, MAX_KIND_ROWS + 1)
519
+ others = [
520
+ (language, data, d)
521
+ for language, data in _languages(ctx)
522
+ for d in data["defs"] if d["kind"] == kind
523
+ ]
524
+ else:
525
+ # the kind filters before the match limit, so other kinds never crowd out the one asked
526
+ found = index.find(name, kind=kind or None)
527
+ others = _language_defs(ctx, name, kind or None)
528
+ if not found and not others and not kind:
529
+ return _no_definition(index, name)
530
+ if found:
531
+ _note_more_matches(index, name, found, kind or None)
532
+ rows = []
533
+ for full in found:
534
+ definition = index.definition(full)
535
+ assert definition is not None
536
+ rel, d = definition
537
+ rows.append({**d, "qualname": d["name"], "name": full, "file": rel})
538
+ rows += [
539
+ {**d, "qualname": d["name"], "sig": "", "language": language.name}
540
+ for language, _, d in others
541
+ ]
542
+ if kind:
543
+ rows = [r for r in rows if r["kind"] == kind]
544
+ if len(rows) > MAX_KIND_ROWS:
545
+ _note(f"more than {MAX_KIND_ROWS} definitions of kind {field_safe(kind)}; the first "
546
+ f"{MAX_KIND_ROWS} are shown: add a name")
547
+ rows = rows[:MAX_KIND_ROWS]
548
+ if not rows:
549
+ known = sorted(
550
+ {*index.kinds(), *(d["kind"] for _, data in _languages(ctx) for d in data["defs"])}
551
+ )
552
+ what = f" named {field_safe(name)}" if name is not None else ""
553
+ _out(
554
+ f"no definition of kind {field_safe(kind)}{what}; kinds here: "
555
+ f"{', '.join(_fields(known)) or 'none'}",
556
+ err=True,
557
+ )
558
+ return 1
559
+ if ctx.args.json:
560
+ _emit_json("def", built_at, rows)
561
+ return 0
562
+ table = render_table(
563
+ ["kind", "name", "signature", "file:line", "doc"],
564
+ [[r["kind"], r["name"], r["sig"], f"{r['file']}:{r['line']}", r["doc"]] for r in rows],
565
+ ctx.args.budget,
566
+ "a qualified or full dotted name",
567
+ )
568
+ _out(table)
569
+ return 0
570
+
571
+
572
+ def _language_refs(
573
+ ctx: Context, built_at: str,
574
+ found: list[tuple[languages.Language, dict[str, Any], dict[str, Any]]],
575
+ ) -> int:
576
+ """``terrain refs`` for a name only a language pack defines."""
577
+ targets = sorted({d["name"] for _, _, d in found})
578
+ seen: set[str] = set()
579
+ rows = []
580
+ for language, data, _ in found:
581
+ if language.name not in seen:
582
+ seen.add(language.name)
583
+ rows += [{**r, "language": language.name} for r in languages.references(data, targets)]
584
+ if ctx.args.json:
585
+ _emit_json(
586
+ "refs", built_at, rows, targets=targets, patches=[], uses=[], candidates=[],
587
+ candidate_count=0, counts={"refs": len(rows)}, truncated=False,
588
+ )
589
+ return 0
590
+ lines = [f"{d['kind']} {d['name']} {d['file']}:{d['line']}" for _, _, d in found]
591
+ lines.append(f"{len(rows)} references")
592
+ lines = _fields(lines)
593
+ if rows:
594
+ lines.append(
595
+ render_table(
596
+ ["file:line", "in", "name"],
597
+ [[f"{r['file']}:{r['line']}", r["scope"] or "-", r["name"]] for r in rows],
598
+ ctx.args.budget, "--json",
599
+ )
600
+ )
601
+ _out("\n".join(lines))
602
+ return 0
603
+
604
+
605
+ _USE_HOW = {
606
+ "arg": "passed to {}", "assign": "assigned to {}", "augment": "combined into {}",
607
+ "member": "in a {} literal",
608
+ "default": "default of {}", "return": "returned", "compare": "compared", "read": "read",
609
+ }
610
+
611
+
612
+ def _use_text(use: dict[str, Any]) -> str:
613
+ """How a value use reads the name: ``passed to db.put``, ``assigned to TABLE``."""
614
+ return _USE_HOW.get(use["how"], "{}").format(use["within"] or "(…)")
615
+
616
+
617
+ def _via(row: dict[str, Any], note: str) -> str:
618
+ """``note``, with the variable a followed row was reached through."""
619
+ if "via" not in row:
620
+ return note
621
+ return f"via {row['via']}; {note}" if note else f"via {row['via']}"
622
+
623
+
624
+ def cmd_refs(ctx: Context) -> int:
625
+ args = ctx.args
626
+ built_at, index = _code_index(ctx)
627
+ targets = index.find(args.name)
628
+ if not targets:
629
+ others = _language_defs(ctx, args.name)
630
+ if others:
631
+ return _language_refs(ctx, built_at, others)
632
+ return _no_definition(index, args.name)
633
+ _note_more_matches(index, args.name, targets)
634
+ def keep(row: dict[str, Any]) -> bool:
635
+ return (not args.tests or row["test"]) and (not args.code or not row["test"])
636
+
637
+ found = code_static.references(index, targets, keep, follow=True)
638
+ calls, patches, uses = found["calls"], found["patches"], found["uses"]
639
+ candidates, counts = found["candidates"], found["counts"]
640
+ if args.json:
641
+ _emit_json(
642
+ "refs", built_at, calls, targets=targets, patches=patches, uses=uses,
643
+ candidates=candidates if args.by_name else [], candidate_count=counts["candidates"],
644
+ counts=counts, truncated=found["truncated"],
645
+ )
646
+ return 0
647
+ lines = []
648
+ for full in targets:
649
+ definition = index.definition(full)
650
+ assert definition is not None
651
+ rel, d = definition
652
+ lines.append(f"{d['kind']} {full}{d['sig']} {rel}:{d['line']}")
653
+ in_tests = counts["calls_in_tests"]
654
+ summary = (
655
+ f"{counts['calls']} calls ({counts['calls'] - in_tests} in code, {in_tests} in tests), "
656
+ f"{counts['problems']} with a shape problem, {counts['uses']} used as a value, "
657
+ f"{counts['patches']} patched"
658
+ )
659
+ if counts["unchecked"]:
660
+ summary += f", {counts['unchecked']} not checked for shape (signatures too large)"
661
+ if counts["candidates"] and not args.by_name:
662
+ summary += f", {counts['candidates']} more by name only (--by-name)"
663
+ if found["truncated"]:
664
+ summary += (
665
+ f"; cut at {code_static.MAX_LISTED} sites of a kind, "
666
+ f"{code_static.MAX_LISTED_BYTES} bytes of sites, "
667
+ f"{code_static.MAX_ALIASES} renamed imports or {code_static.MAX_EXAMINED} rows read"
668
+ )
669
+ lines.append(summary)
670
+ lines = _fields(lines)
671
+ rows = [
672
+ [f"{r['file']}:{r['line']}", "test" if r["test"] else "", r["scope"] or "-", r["shape"],
673
+ _via(r, "; ".join(r["problems"]))]
674
+ for r in calls
675
+ ]
676
+ rows += [
677
+ [f"{u['file']}:{u['line']}", "test" if u["test"] else "", u["scope"] or "-",
678
+ _use_text(u), _via(u, "")]
679
+ for u in uses
680
+ ]
681
+ rows += [
682
+ [f"{p['file']}:{p['line']}", "test" if p["test"] else "", p["scope"] or "-",
683
+ f"{p['how'].removesuffix('(obj)')}({p['target']})",
684
+ _via(p, "patched: the real one does not run here")]
685
+ for p in patches
686
+ ]
687
+ if args.by_name:
688
+ rows += [
689
+ [f"{c['file']}:{c['line']}", "test" if c["test"] else "", c["scope"] or "-",
690
+ c["shape"], f"by name only: {c['callee']}"]
691
+ for c in candidates
692
+ ]
693
+ if rows:
694
+ lines.append(
695
+ render_table(
696
+ ["file:line", "", "in", "shape", "note"], rows, args.budget,
697
+ "--tests, --code or --json",
698
+ )
699
+ )
700
+ _out("\n".join(lines))
701
+ return 0
702
+
703
+
704
+ def cmd_impact(ctx: Context) -> int:
705
+ args = ctx.args
706
+ built_at, index = _code_index(ctx)
707
+ targets = index.find(args.name)
708
+ if not targets:
709
+ return _no_definition(index, args.name)
710
+ _note_more_matches(index, args.name, targets)
711
+ fixtures = fixtures_static.build(ctx.cache, ctx.config, force=args.fresh)[0].data
712
+ found = impact.impact(index, fixtures, targets, args.depth)
713
+ tests, patched, counts = found["tests"], found["patched"], found["counts"]
714
+ if args.json:
715
+ _emit_json(
716
+ "impact", built_at, tests, targets=targets, patched=patched, counts=counts,
717
+ depth=found["depth"], truncated=found["truncated"],
718
+ )
719
+ return 0
720
+ direct = sum(1 for t in tests if t["depth"] == 1)
721
+ summary = (
722
+ f"{len(tests)} {'test reaches' if len(tests) == 1 else 'tests reach'} "
723
+ f"{', '.join(targets)} ({direct} directly), through "
724
+ f"{counts['functions']} callers within {found['depth']} levels"
725
+ )
726
+ if patched:
727
+ summary += (
728
+ f"; {len(patched)} {'test patches' if len(patched) == 1 else 'tests patch'} it"
729
+ " (the real one does not run there)"
730
+ )
731
+ unfollowed = [
732
+ f"{counts['beyond_depth']} callers deeper than --depth" if counts["beyond_depth"] else "",
733
+ f"{counts['module_level']} calls at module level" if counts["module_level"] else "",
734
+ f"{counts['values']} places using it as a value" if counts["values"] else "",
735
+ f"{counts['by_name']} calls by name only" if counts["by_name"] else "",
736
+ ]
737
+ if any(unfollowed):
738
+ summary += "; not followed: " + ", ".join(u for u in unfollowed if u)
739
+ if found["truncated"]:
740
+ summary += (
741
+ f"; cut at {impact.MAX_FOLLOWED} callers, {impact.MAX_FIXTURES} fixtures or "
742
+ f"{code_static.MAX_EXAMINED} rows read"
743
+ )
744
+ lines = _fields([summary])
745
+ rows = [
746
+ [t["test"], str(t["depth"]), t["how"], " <- ".join(t["via"])] for t in tests
747
+ ]
748
+ rows += [[p["test"], "-", f"patches it at {p['file']}:{p['line']}", p["target"]]
749
+ for p in patched]
750
+ if rows:
751
+ lines.append(
752
+ render_table(
753
+ ["test", "depth", "how", "via"], rows, args.budget, "--depth or --json",
754
+ )
755
+ )
756
+ _out("\n".join(lines))
757
+ return 0
758
+
759
+
760
+ def _definition_lines(fx: dict[str, Any]) -> list[str]:
761
+ params = {0: "none", -1: "yes"}.get(fx["params"], f"{fx['params']} values")
762
+ lines = [
763
+ f"name: {fx['name']}",
764
+ f"scope: {fx['scope']}",
765
+ f"autouse: {'yes' if fx['autouse'] else 'no'}",
766
+ f"params: {params}",
767
+ f"deps: {_deps(fx) or '-'}",
768
+ f"defined: {fx['file']}:{fx['line']}",
769
+ f"overrides: {fx['overrides'] or '-'}",
770
+ ]
771
+ if fx["func"] != fx["name"]:
772
+ lines.append(f"function: {fx['func']}")
773
+ if fx["cls"]:
774
+ lines.append(f"class: {fx['cls']}")
775
+ if fx["dyn"]:
776
+ lines.append(f"getfixturevalue: {','.join(fx['dyn'])}")
777
+ return lines
778
+
779
+
780
+ def cmd_fixture(ctx: Context) -> int:
781
+ record = _fixtures_record(ctx)
782
+ found = [fx for fx in record.data["fixtures"] if fx["name"] == ctx.args.name]
783
+ unlisted = _note_unlisted(ctx)
784
+ if not found:
785
+ names = {fx["name"] for fx in record.data["fixtures"]}
786
+ close = sorted(n for n in names if ctx.args.name in n)
787
+ hint = f"; similar: {', '.join(_fields(close[:10]))}" if close else ""
788
+ _out(f"no fixture named {field_safe(ctx.args.name)}{hint}", err=True)
789
+ if unlisted and ctx.args.json:
790
+ # No JSON follows a miss, and the fixture may be in a tree that is not read.
791
+ _note(_unlisted_text(unlisted))
792
+ return 1
793
+ deps = fixtures_static.dependents(record.data, ctx.args.name)
794
+ if ctx.args.json:
795
+ _emit_json(
796
+ "fixture", record.built_at, found, dependents=deps, unlisted_test_paths=unlisted
797
+ )
798
+ return 0
799
+ found, deps = _fields(found), _fields(deps)
800
+ lines: list[str] = []
801
+ for fx in found:
802
+ lines.extend(_definition_lines(fx))
803
+ lines.append("")
804
+ lines.append(f"dependents ({len(deps)}):")
805
+ keep = len(lines)
806
+ lines.extend(f"{d['kind']} {d['name']} {d['file']}:{d['line']} {d['via']}" for d in deps)
807
+ _out(cut_lines(lines, ctx.args.budget, "--budget N or --json", keep=keep))
808
+ return 0
809
+
810
+
811
+ def _view_ages(ctx: Context) -> list[str]:
812
+ lines = ["view built age stale-files"]
813
+ for name in views.ALL_VIEWS:
814
+ record = ctx.cache.read_view(name)
815
+ if record is None:
816
+ lines.append(f"{name:<9} never built")
817
+ continue
818
+ stale = views.stale_files(ctx.cache, ctx.config, record)
819
+ line = f"{name:<9} {record.built_at} {age_text(record.built_at):<5} {stale}"
820
+ if _key_changed(ctx, name, record):
821
+ line += " needs refresh (config or environment changed)"
822
+ lines.append(line)
823
+ return lines
824
+
825
+
826
+ def _key_changed(ctx: Context, view: str, record: Any) -> bool:
827
+ """A slow view built under another config, interpreter or version; doctor never builds."""
828
+ if view not in views.SLOW_VIEWS:
829
+ return False
830
+ return record.key != views.view_key(ctx.cache, ctx.config, ctx.python, view)
831
+
832
+
833
+ def _path_status(config: Config) -> list[str]:
834
+ return [p if (config.root / p).exists() else f"{p} (missing)" for p in config.test_paths]
835
+
836
+
837
+ def _one_line(text: str, limit: int = 200) -> str:
838
+ flat = " ".join(text.split())
839
+ return flat if len(flat) <= limit else flat[: limit - 3] + "..."
840
+
841
+
842
+ def _app_status(ctx: Context) -> list[str]:
843
+ """One line per configured app from the cached route walk; doctor itself imports nothing."""
844
+ record = ctx.cache.read_view(routes_view.VIEW)
845
+ doc = record.data if record is not None else None
846
+ changed = record is not None and _key_changed(ctx, routes_view.VIEW, record)
847
+ if doc is not None:
848
+ try:
849
+ protocol.check(doc, protocol.ROUTES_SCHEMA)
850
+ except protocol.ProtocolError:
851
+ doc = None
852
+ if changed:
853
+ doc = None
854
+ lines = []
855
+ for app in ctx.config.apps:
856
+ if changed:
857
+ lines.append(f" {app}: needs refresh (config or environment changed)")
858
+ continue
859
+ if doc is None:
860
+ lines.append(f" {app}: not imported yet (run: terrain refresh routes)")
861
+ continue
862
+ failure = next((e for e in doc["errors"] if e["app"] == app), None)
863
+ if failure is not None:
864
+ cause = (failure.get("chain") or failure["error"].splitlines())[-1]
865
+ lines.append(f" {app}: import failed: {_one_line(cause)}")
866
+ else:
867
+ count = sum(1 for r in doc["routes"] if r["app"] == app)
868
+ lines.append(f" {app}: ok, {count} routes")
869
+ if doc is not None:
870
+ lines.extend(f" {n}" for n in doc.get("notes", []) if str(n).startswith("warning:"))
871
+ return lines
872
+
873
+
874
+ def _python_hint(ctx: Context) -> list[str]:
875
+ """Point at the target's venv when the import tier would run in codeterrain's interpreter."""
876
+ venv = ctx.config.root / ".venv"
877
+ own = os.path.realpath(ctx.python) == os.path.realpath(sys.executable)
878
+ if not own or not venv.is_dir():
879
+ return []
880
+ exe = venv / ("Scripts/python.exe" if os.name == "nt" else "bin/python")
881
+ return [
882
+ "hint: the import tier runs in codeterrain's own interpreter but the target has a .venv; "
883
+ "that interpreter belongs to the target repository, so if it is a venv you created, "
884
+ f"pass --python {exe.as_posix()}"
885
+ ]
886
+
887
+
888
+ def _checkouts_status(root: Path) -> list[str]:
889
+ """``doctor``'s line on the other worktrees of the repository, when there are any."""
890
+ found = checkouts(root)
891
+ if found is None or not found.others:
892
+ return []
893
+ listed = ", ".join(field_safe(p.as_posix()) for p in found.others[:10])
894
+ more = f" and {len(found.others) - 10} more" if len(found.others) > 10 else ""
895
+ kind = "a worktree" if found.linked else "the main checkout"
896
+ return [f"checkout: {kind}; other checkouts: {listed}{more}"]
897
+
898
+
899
+ def cmd_doctor(ctx: Context) -> int:
900
+ config = ctx.config
901
+ lines = [
902
+ f"codeterrain {__version__}",
903
+ f"root: {config.root.as_posix()}",
904
+ *_checkouts_status(config.root),
905
+ f"config: {config.source}",
906
+ f"user config: {config.user_config or '-'}",
907
+ f"apps: {len(config.apps) or '-'}",
908
+ "test_paths: " + ", ".join(_path_status(config)),
909
+ *(
910
+ [f"warning: {_unlisted_text(trees)}"]
911
+ if (trees := fixtures_static.unlisted_test_trees(config)) else []
912
+ ),
913
+ f"region: {config.region}",
914
+ f"import_env: {', '.join(sorted(config.import_env)) or '-'}",
915
+ f"pass_env: {', '.join(config.pass_env) or '-'}",
916
+ f"respect_addopts: {'yes' if config.respect_addopts else 'no'}",
917
+ f"route_manifest: {config.route_manifest.as_posix() if config.route_manifest else '-'}",
918
+ f"plugins: {', '.join(config.plugins) or '-'}",
919
+ *_pack_status(),
920
+ f"cache dir: {ctx.cache.dir.as_posix()}",
921
+ f"target python: {ctx.python}",
922
+ ]
923
+ try:
924
+ found = probe_versions(ctx.python, config, ctx.cache.dir)
925
+ shown = ", ".join(f"{k} {found.get(k) or 'not installed'}" for k in found)
926
+ lines.append(f"versions: {shown}")
927
+ except SandboxError as exc:
928
+ lines.append(f"versions: unavailable ({_one_line(str(exc))})")
929
+ lines.extend(_python_hint(ctx))
930
+ lines[5:5] = _app_status(ctx)
931
+ lines.extend(_view_ages(ctx))
932
+ # every entry is one line that may carry repository text (apps, paths, import errors)
933
+ _out("\n".join(field_safe(line) for line in lines))
934
+ return 0
935
+
936
+
937
+ def _pack_status() -> list[str]:
938
+ found, problems = languages.discover()
939
+ shown = ", ".join(f"{lang.name} {lang.version}" for lang in found)
940
+ return [f"language packs: {shown or '-'}", *(f" refused: {p}" for p in problems)]
941
+
942
+
943
+ def _note_plain(message: str) -> None:
944
+ _out(message, err=True)
945
+
946
+
947
+ #: Every error type codeterrain raises for a run that cannot be completed.
948
+ FAILURES = (
949
+ code_static.CorruptIndex,
950
+ sqlite3.Error,
951
+ fixtures_order.ExtractError,
952
+ protocol.ProtocolError,
953
+ ManifestError,
954
+ SandboxError,
955
+ CacheBusy,
956
+ )
957
+
958
+
959
+ def _note(message: str) -> None:
960
+ _out(f"terrain: {message}", err=True)
961
+
962
+
963
+ def _slow(ctx: Context, view: str) -> views.Served:
964
+ cache_dir, python, config = ctx.cache.dir, ctx.python, ctx.config
965
+ if view == fixtures_order.VIEW:
966
+ schema = protocol.ORDER_SCHEMA
967
+
968
+ def build() -> dict[str, Any]:
969
+ return fixtures_order.extract(config, cache_dir, python)
970
+
971
+ else:
972
+ schema = protocol.ROUTES_SCHEMA
973
+
974
+ def build() -> dict[str, Any]:
975
+ return routes_view.extract(config, cache_dir, python)
976
+
977
+ return views.serve_slow(ctx.cache, config, view, schema, build, python, ctx.args.fresh, _note)
978
+
979
+
980
+ def _stamp(served: views.Served) -> list[str]:
981
+ if not served.stale:
982
+ return []
983
+ return [f"stale: {served.stale} files changed since {served.record.built_at}"]
984
+
985
+
986
+ def cmd_setup_order(ctx: Context) -> int:
987
+ served = _slow(ctx, fixtures_order.VIEW)
988
+ doc = served.record.data
989
+ matched = fixtures_order.match_nodes(doc, ctx.args.nodeid)
990
+ if not matched:
991
+ _out(f"no collected test matches {field_safe(ctx.args.nodeid)}", err=True)
992
+ if doc["errors"]:
993
+ _out(
994
+ f"note: {doc['errors']} collection errors in the extraction run; "
995
+ "the test may be in a file that failed to import",
996
+ err=True,
997
+ )
998
+ return 1
999
+ found = fixtures_order.groups(doc, matched)
1000
+ if ctx.args.json:
1001
+ _emit_json(
1002
+ "order",
1003
+ served.record.built_at,
1004
+ found,
1005
+ stale_files=served.stale,
1006
+ collection_errors=doc["errors"],
1007
+ )
1008
+ return 0
1009
+ lines: list[str] = []
1010
+ for group in _fields(found):
1011
+ more = len(group["nodes"]) - 1
1012
+ lines.append(group["nodes"][0] + (f" (+{more} more with this setup)" if more else ""))
1013
+ for fx in group["fixtures"]:
1014
+ param = " [params]" if fx.get("params") else ""
1015
+ letter = fixtures_order.scope_letter(fx["scope"])
1016
+ lines.append(f"{letter} {fx['name']}{param} {fx['file']}:{fx['line']}")
1017
+ if doc["errors"]:
1018
+ lines.append(f"note: {doc['errors']} collection errors in the extraction run")
1019
+ body = cut_lines(lines, ctx.args.budget, "a more specific node id")
1020
+ _out("\n".join([*_stamp(served), body]))
1021
+ return 0
1022
+
1023
+
1024
+ def _warn_failed_apps(ctx: Context, doc: dict[str, Any]) -> None:
1025
+ missing = False
1026
+ for failure in doc["errors"]:
1027
+ last = (failure.get("chain") or failure["error"].strip().splitlines())[-1]
1028
+ app = field_safe(failure["app"])
1029
+ _note(f"app {app} failed to import: {field_safe(last)} (--json has the traceback)")
1030
+ missing = missing or "ModuleNotFoundError" in last
1031
+ own = os.path.realpath(ctx.python) == os.path.realpath(sys.executable)
1032
+ if missing and own:
1033
+ _note_plain(
1034
+ "hint: the apps were imported with codeterrain's own interpreter, which lacks the "
1035
+ f"project's packages; pass --python or set {PYTHON_ENV} to the project's interpreter"
1036
+ )
1037
+ for line in _python_hint(ctx):
1038
+ _note_plain(line)
1039
+
1040
+
1041
+ def _joined(ctx: Context) -> tuple[views.Served, Any]:
1042
+ served = _slow(ctx, routes_view.VIEW)
1043
+ _warn_failed_apps(ctx, served.record.data)
1044
+ return served, routes_view.joined(served.record.data, ctx.config)
1045
+
1046
+
1047
+ #: Names looked up when a route text matches no route (``Index.similar`` stops at 200).
1048
+ MAX_NAMES_READ = 200
1049
+
1050
+
1051
+ def routes_reading(
1052
+ index: code_static.Index, text: str, routes: list[dict[str, Any]]
1053
+ ) -> tuple[list[tuple[dict[str, Any], str]], bool]:
1054
+ """The routes whose handler reads or calls a name whose last part contains ``text``
1055
+ (``legal`` finds the handler that reads ``LEGAL_PRIVACY_VERSION``), each with the name; the
1056
+ handler's own body only, not the helpers it calls. The flag says the search stopped early:
1057
+ more names than ``MAX_NAMES_READ``, or more sites than one ``references`` budget reads."""
1058
+ handlers: dict[tuple[str, str], list[dict[str, Any]]] = {}
1059
+ for route in routes:
1060
+ qualname = _handler_qualname(route, index.src_package)
1061
+ if qualname:
1062
+ handlers.setdefault((route["file"], qualname), []).append(route)
1063
+ # a callable instance is named by its class; its body is `__call__`
1064
+ handlers.setdefault((route["file"], f"{qualname}.__call__"), []).append(route)
1065
+ found: list[tuple[dict[str, Any], str]] = []
1066
+ seen: set[tuple[int, str]] = set()
1067
+ names = index.similar(text, limit=MAX_NAMES_READ + 1)
1068
+ truncated = len(names) > MAX_NAMES_READ
1069
+ budget = [code_static.MAX_EXAMINED] # shared by every name
1070
+ for full in names[:MAX_NAMES_READ]:
1071
+ refs = code_static.references(index, [full], budget=budget)
1072
+ truncated = truncated or refs["truncated"] or budget[0] <= 0
1073
+ for site in [*refs["calls"], *refs["uses"]]:
1074
+ for route in handlers.get((site["file"], _body_scope(site["scope"])), []):
1075
+ if (id(route), full) not in seen:
1076
+ seen.add((id(route), full))
1077
+ found.append((route, full))
1078
+ return found, truncated
1079
+
1080
+
1081
+ #: Scopes the code index gives code that runs in the body of the function around it.
1082
+ _BODY_MARKERS = (".<listcomp>", ".<setcomp>", ".<dictcomp>", ".<genexpr>")
1083
+
1084
+
1085
+ def _body_scope(scope: str) -> str:
1086
+ """The function whose body holds code in ``scope``: a comprehension or generator
1087
+ expression is part of it (``get_versions.<listcomp>`` is ``get_versions``); a lambda or a
1088
+ nested function is a function of its own."""
1089
+ while scope.endswith(_BODY_MARKERS):
1090
+ scope = scope.rsplit(".", 1)[0]
1091
+ return scope
1092
+
1093
+
1094
+ def _handler_qualname(route: dict[str, Any], src_package: bool) -> str:
1095
+ """The handler's qualified name within its file (``Controller.version``). Its module is
1096
+ named as it was imported, which in a nested project is a tail of the name from the
1097
+ repository root (``routes`` for ``apps/web/routes.py``), so the longest such tail that
1098
+ prefixes the handler is the module; "" when none does."""
1099
+ parts = code_static.module_name(route["file"], src_package).split(".")
1100
+ for start in range(len(parts)):
1101
+ module = ".".join(parts[start:])
1102
+ if route["handler"].startswith(f"{module}."):
1103
+ # `create_app.<locals>.version` at run time is `create_app.version` in the index
1104
+ return route["handler"][len(module) + 1:].replace(".<locals>.", ".")
1105
+ return ""
1106
+
1107
+
1108
+ def _reads_by_route(
1109
+ found: list[tuple[dict[str, Any], str]],
1110
+ ) -> list[tuple[dict[str, Any], list[str]]]:
1111
+ """One entry per route as listed (the same route served by two apps is one), with the full
1112
+ names its handler reads."""
1113
+ reads: dict[tuple[str, ...], tuple[dict[str, Any], list[str]]] = {}
1114
+ for route, name in found:
1115
+ key = (route["method"], route["path"], route["handler"], route["file"], str(route["line"]))
1116
+ names = reads.setdefault(key, (route, []))[1]
1117
+ if name not in names:
1118
+ names.append(name)
1119
+ return list(reads.values())
1120
+
1121
+
1122
+ def _routes_reading_table(
1123
+ text: str, found: list[tuple[dict[str, Any], str]], truncated: bool, budget: int
1124
+ ) -> list[str]:
1125
+ shown = field_safe(_one_line(text, 200))
1126
+ partial = (
1127
+ [f"the search stopped early (more than {MAX_NAMES_READ} names or too many uses): "
1128
+ "give a longer name substring"]
1129
+ if truncated else []
1130
+ )
1131
+ if not found:
1132
+ return [f"no route handler reads a name containing {shown}", *partial]
1133
+ return [
1134
+ f"routes whose handler reads a name containing {shown}:",
1135
+ render_table(
1136
+ ["method", "path", "handler", "reads", "file:line"],
1137
+ [[r["method"], r["path"], r["handler"],
1138
+ ", ".join(name.rsplit(".", 1)[-1] for name in names), f"{r['file']}:{r['line']}"]
1139
+ for r, names in _reads_by_route(found)],
1140
+ budget,
1141
+ "a longer name substring",
1142
+ ),
1143
+ *partial,
1144
+ ]
1145
+
1146
+
1147
+ def cmd_routes(ctx: Context) -> int:
1148
+ args = ctx.args
1149
+ served, result = _joined(ctx)
1150
+ rows = routes_view.select(result.rows, args.substring, args.app, args.access)
1151
+ # No path or handler holds the text: the routes whose handler reads a name holding it.
1152
+ reading: tuple[list[tuple[dict[str, Any], str]], bool] | None = None
1153
+ if not rows and args.substring and not _shell_path_note(args.substring):
1154
+ _, index = _code_index(ctx)
1155
+ filtered = routes_view.select(result.rows, "", args.app, args.access)
1156
+ reading = routes_reading(index, args.substring, filtered)
1157
+ if args.json:
1158
+ extra: dict[str, Any] = {}
1159
+ if reading is not None:
1160
+ extra["handlers_reading"] = [
1161
+ {**route, "reads": names} for route, names in _reads_by_route(reading[0])
1162
+ ]
1163
+ extra["handlers_reading_truncated"] = reading[1]
1164
+ _emit_json(
1165
+ "routes",
1166
+ served.record.built_at,
1167
+ rows,
1168
+ stale_files=served.stale,
1169
+ errors=served.record.data["errors"],
1170
+ routes_without_manifest_row=len(result.routes_without_row),
1171
+ manifest_rows_without_route=len(result.rows_without_route),
1172
+ **extra,
1173
+ )
1174
+ return 0
1175
+ if not rows:
1176
+ lines = [*_stamp(served), "no routes match", *_shell_path_note(args.substring)]
1177
+ if reading is not None:
1178
+ lines += _routes_reading_table(args.substring, *reading, args.budget)
1179
+ _out("\n".join(lines))
1180
+ return 0
1181
+
1182
+ def omitted(shown: int) -> str:
1183
+ counts = collections.Counter(r["app"] for r in rows[shown:])
1184
+ return ", ".join(f"{field_safe(app)} {n}" for app, n in counts.most_common())
1185
+
1186
+ table = render_table(
1187
+ ["method", "path", "app", "access", "handler", "file:line"],
1188
+ [
1189
+ [
1190
+ r["method"],
1191
+ r["path"],
1192
+ r["app"],
1193
+ r["access"] or "",
1194
+ r["handler"],
1195
+ f"{r['file']}:{r['line']}",
1196
+ ]
1197
+ for r in rows
1198
+ ],
1199
+ args.budget,
1200
+ "a path/handler substring, --app or --access (--budget 0 for every row)",
1201
+ omitted=omitted,
1202
+ )
1203
+ _out("\n".join([*_stamp(served), table]))
1204
+ return 0
1205
+
1206
+
1207
+ def cmd_callers(ctx: Context) -> int:
1208
+ args = ctx.args
1209
+ if (args.method is None) != (args.path is None):
1210
+ if args.method is not None and args.method.upper() not in HTTP_METHODS:
1211
+ # The callers of a Python name are `refs`'s answer: give it rather than an error.
1212
+ _note(
1213
+ "callers is for routes (METHOD PATH); answering as "
1214
+ f"terrain refs {field_safe(args.method)}"
1215
+ )
1216
+ args.name, args.tests, args.code, args.by_name = args.method, False, False, False
1217
+ return cmd_refs(ctx)
1218
+ raise UsageError("callers takes METHOD and PATH together, or neither")
1219
+ if not ctx.config.client_repos:
1220
+ raise UsageError(
1221
+ "no client_repos configured; list the front-end checkouts in --config or the user "
1222
+ "config"
1223
+ )
1224
+ served = _slow(ctx, routes_view.VIEW)
1225
+ _warn_failed_apps(ctx, served.record.data)
1226
+ routes = served.record.data["routes"]
1227
+ clients, backend_state = calls_join.load_with_states(
1228
+ ctx.config.client_repos, ctx.config.root, args.cache_dir, args.fresh, _call_readers()
1229
+ )
1230
+ for client in clients:
1231
+ if client.error:
1232
+ _note(f"{client.name}: skipped ({_one_line(client.error)})")
1233
+ for name, state in [(ctx.config.root.name, backend_state)] + [
1234
+ (c.name, c.state) for c in clients
1235
+ ]:
1236
+ warning = calls_join.state_warning(name, state)
1237
+ if warning and not args.json:
1238
+ _note_plain(f"warning: {warning}")
1239
+ for client in clients:
1240
+ if client.errors and not args.json:
1241
+ _note_plain(
1242
+ f"warning: {field_safe(client.name)}: {len(client.errors)} files could not be "
1243
+ "parsed and their calls are missing (run with --json for details)"
1244
+ )
1245
+ every = calls_join.join(clients, routes)
1246
+ rows = every
1247
+ if args.method is not None:
1248
+ key = route_key(args.method, args.path)
1249
+ targets = [r for r in routes if route_key(r["method"], r["path"]) == key]
1250
+ if not targets:
1251
+ _out(f"no route {field_safe(key[0])} {field_safe(args.path)}", err=True)
1252
+ for line in _shell_path_note(args.path):
1253
+ _out(line, err=True)
1254
+ return 1
1255
+ rows = [
1256
+ r for r in rows
1257
+ if r["route"] and route_key(r["route"]["method"], r["route"]["path"]) == key
1258
+ ]
1259
+ if args.unmatched:
1260
+ rows = [r for r in rows if r["resolved"] and r["route"] is None]
1261
+ if args.json:
1262
+ _emit_json(
1263
+ "callers", served.record.built_at, rows, stale_files=served.stale,
1264
+ backend_state=backend_state,
1265
+ clients=[
1266
+ {
1267
+ "repo": c.name, "root": c.root.as_posix(), "error": c.error,
1268
+ "state": c.state, "errors": c.errors,
1269
+ }
1270
+ for c in clients
1271
+ ],
1272
+ )
1273
+ return 0
1274
+ matched = sum(1 for r in every if r["route"])
1275
+ unresolved = sum(1 for r in every if not r["resolved"])
1276
+ summary = (
1277
+ f"{len(every)} calls in {len(clients)} front ends: {matched} hit a route, "
1278
+ f"{len(every) - matched - unresolved} hit none, {unresolved} have no static path"
1279
+ )
1280
+ if not rows:
1281
+ _out("\n".join([*_stamp(served), summary, "no calls match"]))
1282
+ return 0
1283
+ table = render_table(
1284
+ ["repo", "method", "call path", "route", "file:line"],
1285
+ [
1286
+ [
1287
+ r["repo"],
1288
+ r["method"],
1289
+ r["path"] or f"? {r['raw']}",
1290
+ f"{r['route']['method']} {r['route']['path']}" if r["route"] else "-",
1291
+ f"{r['file']}:{r['line']}",
1292
+ ]
1293
+ for r in rows
1294
+ ],
1295
+ args.budget,
1296
+ "METHOD PATH or --unmatched",
1297
+ )
1298
+ _out("\n".join([*_stamp(served), summary, table]))
1299
+ return 0
1300
+
1301
+
1302
+ def cmd_route(ctx: Context) -> int:
1303
+ served, result = _joined(ctx)
1304
+ method, path = ctx.args.method.upper(), ctx.args.path
1305
+ rows = [r for r in result.rows if r["method"] == method and r["path"] == path]
1306
+ if not rows:
1307
+ close = sorted({f"{r['method']} {r['path']}" for r in result.rows if path in r["path"]})
1308
+ hint = f"; similar: {', '.join(_fields(close[:8]))}" if close else ""
1309
+ _out(f"no route {field_safe(method)} {field_safe(path)}{hint}", err=True)
1310
+ return 1
1311
+ if ctx.args.json:
1312
+ _emit_json("route", served.record.built_at, rows, stale_files=served.stale)
1313
+ return 0
1314
+ lines = _stamp(served)
1315
+ for r in _fields(rows):
1316
+ lines += [
1317
+ f"route: {r['method']} {r['path']}",
1318
+ f"app: {r['app']}",
1319
+ f"access: {r['access'] or '-'}",
1320
+ f"handler: {r['handler']}",
1321
+ f"defined: {r['file']}:{r['line']}",
1322
+ f"dependencies: {', '.join(r['dependencies']) or '-'}",
1323
+ f"response_model: {r['response_model'] or '-'}",
1324
+ "",
1325
+ ]
1326
+ _out(cut_lines(lines, ctx.args.budget, "--json").rstrip())
1327
+ return 0
1328
+
1329
+
1330
+ def cmd_refresh(ctx: Context) -> int:
1331
+ wanted = (
1332
+ [ctx.args.view] if ctx.args.view
1333
+ else [*views.ALL_VIEWS, code_static.VIEW, LANGUAGES]
1334
+ )
1335
+ ctx.args.fresh = True
1336
+ failed = 0
1337
+ for name in wanted:
1338
+ started = time.perf_counter()
1339
+ try:
1340
+ summary = _refresh_one(ctx, name)
1341
+ except FAILURES as exc:
1342
+ _note(f"{name} failed: {_one_line(str(exc), 400)}")
1343
+ failed += 1
1344
+ continue
1345
+ if summary is not None:
1346
+ _out(f"{name}: {summary} in {time.perf_counter() - started:.2f}s")
1347
+ return 1 if failed else 0
1348
+
1349
+
1350
+ def _refresh_one(ctx: Context, name: str) -> str | None:
1351
+ if name == LANGUAGES:
1352
+ packs = _languages(ctx)
1353
+ if not packs:
1354
+ return "no language packs installed"
1355
+ return ", ".join(
1356
+ f"{lang.name} {len(data['defs'])} definitions, {len(data['refs'])} references"
1357
+ for lang, data in packs
1358
+ )
1359
+ if name == code_static.VIEW:
1360
+ found = code_static.summary(code_static.update(ctx.cache, ctx.config, force=True))
1361
+ _warn_unparsed(found, False)
1362
+ return f"{found['files']} files, {found['defs']} definitions"
1363
+ if name == "fixtures":
1364
+ data = fixtures_static.build(ctx.cache, ctx.config, force=True)[0].data
1365
+ _warn_unparsed(data, False)
1366
+ return f"{len(data['fixtures'])} fixtures, {len(data['tests'])} tests"
1367
+ if name == calls_static.VIEW:
1368
+ data = calls_static.build(ctx.cache, ctx.config, force=True, readers=_call_readers())[
1369
+ 0
1370
+ ].data
1371
+ _warn_unparsed(data, False)
1372
+ resolved = sum(1 for c in data["calls"] if c["resolved"])
1373
+ return f"{len(data['calls'])} calls, {resolved} with a static path"
1374
+ if name == fixtures_order.VIEW:
1375
+ doc = _slow(ctx, name).record.data
1376
+ return f"{len(doc['nodes'])} tests, {len(doc['closures'])} distinct setups"
1377
+ if not ctx.config.apps:
1378
+ _out("routes: skipped (no apps configured)")
1379
+ return None
1380
+ doc = _slow(ctx, name).record.data
1381
+ _warn_failed_apps(ctx, doc)
1382
+ return f"{len(doc['routes'])} routes from {len(ctx.config.apps)} apps"
1383
+
1384
+
1385
+ def _checkout_text(root: Path) -> str:
1386
+ """Which checkout ``root`` is, when its repository has others, else ""."""
1387
+ found = checkouts(root)
1388
+ if found is None or not found.others:
1389
+ return ""
1390
+ n = len(found.others)
1391
+ return (
1392
+ f"answered from {field_safe(found.top.as_posix())} "
1393
+ f"({'a worktree' if found.linked else 'the main checkout'}); the repository has {n} "
1394
+ f"other checkout{'' if n == 1 else 's'}: run terrain from the one you are editing"
1395
+ )
1396
+
1397
+
1398
+ def _note_checkout(root: Path, explicit: bool) -> None:
1399
+ """Name the checkout an answer came from when the repository has others: an agent editing one
1400
+ worktree that asks from another gets an answer that misses its changes, and this says why.
1401
+ With ``--root``, also say when the current directory is a different checkout of it."""
1402
+ text = _checkout_text(root)
1403
+ if not text:
1404
+ return
1405
+ _note(text)
1406
+ if explicit:
1407
+ here, there = checkouts(Path(os.path.realpath(os.getcwd()))), checkouts(root)
1408
+ if here is not None and there is not None and here.top in there.others:
1409
+ _note(
1410
+ f"warning: --root is {field_safe(there.top.as_posix())}, but the current "
1411
+ f"directory is in another checkout of it, {field_safe(here.top.as_posix())}"
1412
+ )
1413
+
1414
+
1415
+ def main(argv: list[str] | None = None) -> int:
1416
+ args = build_parser().parse_args(argv)
1417
+ if hasattr(sys.stdout, "reconfigure"):
1418
+ sys.stdout.reconfigure(encoding="utf-8", errors="backslashreplace")
1419
+ sys.stderr.reconfigure(encoding="utf-8", errors="backslashreplace")
1420
+ try:
1421
+ root = resolve_root(args.root)
1422
+ config = load_config(root, args.config)
1423
+ distrust([root, *(repo.root for repo in config.client_repos)])
1424
+ if config.ignored:
1425
+ _note(
1426
+ f"warning: ignored {', '.join(config.ignored)} from the repository's "
1427
+ f"{config.source.split()[0]}; set them with --config or in "
1428
+ f"{user_config_path().as_posix()}"
1429
+ )
1430
+ ctx = Context(config, Cache(root, args.cache_dir), args)
1431
+ global _answer_root
1432
+ _answer_root = root.as_posix()
1433
+ status = args.run(ctx)
1434
+ if args.run is not cmd_doctor:
1435
+ _note_checkout(root, args.root is not None)
1436
+ return status
1437
+ except (ConfigError, UsageError) as exc:
1438
+ # The message can quote repository text (a TOML key, a path): one field, one line.
1439
+ _out(f"terrain: {field_safe(str(exc))}", err=True)
1440
+ return 2
1441
+ except FAILURES as exc:
1442
+ _out(f"terrain: {field_safe(_one_line(str(exc), 400))}", err=True)
1443
+ return 1
1444
+
1445
+
1446
+ if __name__ == "__main__":
1447
+ raise SystemExit(main())