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/__init__.py +3 -0
- codeterrain/_guard.py +243 -0
- codeterrain/_pytest_dump_plugin.py +253 -0
- codeterrain/_routes_runner.py +119 -0
- codeterrain/budget.py +128 -0
- codeterrain/cache.py +618 -0
- codeterrain/calls_join.py +416 -0
- codeterrain/calls_static.py +1099 -0
- codeterrain/cli.py +1447 -0
- codeterrain/code_static.py +2411 -0
- codeterrain/config.py +331 -0
- codeterrain/executables.py +130 -0
- codeterrain/fixtures_order.py +77 -0
- codeterrain/fixtures_static.py +501 -0
- codeterrain/hookspecs.py +25 -0
- codeterrain/impact.py +725 -0
- codeterrain/languages.py +293 -0
- codeterrain/plugins/__init__.py +0 -0
- codeterrain/plugins/fastapi_routes.py +193 -0
- codeterrain/plugins/manifest_join.py +107 -0
- codeterrain/protocol.py +138 -0
- codeterrain/routes_view.py +61 -0
- codeterrain/sandbox.py +565 -0
- codeterrain/views.py +198 -0
- codeterrain-0.3.0.dist-info/METADATA +295 -0
- codeterrain-0.3.0.dist-info/RECORD +30 -0
- codeterrain-0.3.0.dist-info/WHEEL +4 -0
- codeterrain-0.3.0.dist-info/entry_points.txt +2 -0
- codeterrain-0.3.0.dist-info/licenses/LICENSE +201 -0
- codeterrain-0.3.0.dist-info/licenses/NOTICE +4 -0
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())
|