matview-cache 0.1.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.
matview/cli.py ADDED
@@ -0,0 +1,900 @@
1
+ """`matview`, a read-only inspection tool for a cache database.
2
+
3
+ The commands divide by what they need, and the division is the whole design:
4
+
5
+ - **Metadata** — `info`, `ls`, `show`, `stats`, `verify` — works against any matview
6
+ database with nothing imported. Names, versions, digests, sizes, timestamps, validity
7
+ and dependency edges are all recorded in the file.
8
+ - **Key lookup by parameters** needs `--recipes`, and only for one reason: declared
9
+ defaults are filled in *before* a key is hashed, so `show sma symbol=AAPL` cannot know
10
+ the right `key_hash` unless something tells it what `interval` defaults to.
11
+ - **`plan`** needs `--recipes`, because a plan resolves recipes.
12
+ - **Graph queries** — `lineage`, `impact`, `cost` — are metadata too. They read the DAG
13
+ the cache recorded as it ran, so they need nothing imported either; see
14
+ `matview.graph`.
15
+
16
+ Decoding a value needs neither. The codec is recorded on the recipe row, so `cat --id`
17
+ decodes without importing anything — only a codec the *application* registered itself
18
+ needs `--recipes`. See DECISIONS.md D37.
19
+
20
+ Nothing here writes. `drop` and `gc` exist on `Cache`; they are deliberately not exposed
21
+ yet, and `impact --drop` shows what a drop would do — which is the useful half.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import argparse
27
+ import importlib
28
+ import importlib.util
29
+ import json
30
+ import os
31
+ import sys
32
+ from collections.abc import Callable, Mapping, Sequence
33
+ from datetime import UTC, datetime
34
+ from pathlib import Path
35
+ from typing import Any
36
+
37
+ from matview import __version__
38
+ from matview.errors import MatviewError
39
+ from matview.fingerprint import digest_bytes
40
+ from matview.graph import ref_to_dict
41
+ from matview.store.sqlite import SqliteStore, inspect_database
42
+ from matview.types import ArtifactRef
43
+
44
+ __all__ = ["main"]
45
+
46
+ PROGRAM = "matview"
47
+
48
+ #: Exit statuses. 2 is argparse's own for a usage error.
49
+ OK = 0
50
+ PROBLEM = 1
51
+
52
+
53
+ # --- helpers ---------------------------------------------------------------
54
+
55
+
56
+ def _iso(value: int | None) -> str:
57
+ if value is None:
58
+ return "-"
59
+ return datetime.fromtimestamp(value, tz=UTC).isoformat(timespec="seconds")
60
+
61
+
62
+ def _human_bytes(value: int | None) -> str:
63
+ if value is None:
64
+ return "-"
65
+ size = float(value)
66
+ for unit in ("B", "KiB", "MiB", "GiB"):
67
+ if size < 1024 or unit == "GiB":
68
+ return f"{size:.0f} {unit}" if unit == "B" else f"{size:.1f} {unit}"
69
+ size /= 1024
70
+ return f"{size:.1f} GiB" # pragma: no cover - unreachable, the loop returns first
71
+
72
+
73
+ def _parse_value(raw: str) -> Any:
74
+ """Parse a command-line key field as JSON, falling back to the literal string.
75
+
76
+ So `window=200` is the integer 200, `flag=true` is a boolean and `x=null` is null —
77
+ which matters, because `200` and `"200"` canonicalize to different keys and therefore
78
+ different artifacts. A value that is not valid JSON is taken as a string, and a string
79
+ that looks like a number can be forced with JSON quoting: `symbol='"200"'`.
80
+ """
81
+ try:
82
+ return json.loads(raw)
83
+ except json.JSONDecodeError:
84
+ return raw
85
+
86
+
87
+ def _parse_pairs(pairs: Sequence[str], *, what: str) -> dict[str, Any]:
88
+ out: dict[str, Any] = {}
89
+ for pair in pairs:
90
+ name, sep, raw = pair.partition("=")
91
+ if not sep or not name:
92
+ raise MatviewError(f"{what} must be given as field=value, got {pair!r}")
93
+ out[name] = _parse_value(raw)
94
+ return out
95
+
96
+
97
+ def _open(path: str) -> SqliteStore:
98
+ """Open the database read-only.
99
+
100
+ Not a precaution but a correctness requirement: a read-write open would create a
101
+ database on a mistyped path and then report it as empty, which is the opposite of
102
+ what an inspection tool is for.
103
+ """
104
+ try:
105
+ return SqliteStore(path, read_only=True)
106
+ except MatviewError as exc:
107
+ if not Path(path).exists():
108
+ raise
109
+ # The file is there but this build will not read it, which is the one case where
110
+ # `info` helps: it is version-blind. Suggesting it for a missing file would send
111
+ # the reader to a command that fails in the same way.
112
+ raise MatviewError(
113
+ f"{exc}\n\nTry `{PROGRAM} info --db {path}`, which reads any version."
114
+ ) from exc
115
+
116
+
117
+ def _load_recipes(specs: Sequence[str]) -> None:
118
+ """Import the modules that register the application's recipes.
119
+
120
+ Accepts a dotted module path or a path to a .py file, because a consumer's recipes
121
+ are as likely to live in a script as in an installed package.
122
+ """
123
+ for spec in specs:
124
+ if spec.endswith(".py") or os.sep in spec:
125
+ path = Path(spec).resolve()
126
+ if not path.exists():
127
+ raise MatviewError(f"--recipes {spec}: no such file")
128
+ # Registered under a prefixed name, not the file's bare stem: loading
129
+ # `recipes.py` must not shadow an installed `recipes` package for the rest of
130
+ # the process, and a recipe module's own imports may well need the real one.
131
+ name = f"_matview_recipes_{path.stem}"
132
+ module_spec = importlib.util.spec_from_file_location(name, path)
133
+ if module_spec is None or module_spec.loader is None: # pragma: no cover
134
+ raise MatviewError(f"--recipes {spec}: cannot be imported as a module")
135
+ module = importlib.util.module_from_spec(module_spec)
136
+ sys.modules[name] = module
137
+ module_spec.loader.exec_module(module)
138
+ else:
139
+ try:
140
+ importlib.import_module(spec)
141
+ except ImportError as exc:
142
+ raise MatviewError(f"--recipes {spec}: {exc}") from exc
143
+
144
+
145
+ def _table(rows: Sequence[Mapping[str, Any]], columns: Sequence[str], out: Any) -> None:
146
+ if not rows:
147
+ print("(nothing)", file=out)
148
+ return
149
+ widths = {c: max(len(c), *(len(str(row.get(c, ""))) for row in rows)) for c in columns}
150
+ print(" ".join(c.ljust(widths[c]) for c in columns), file=out)
151
+ print(" ".join("-" * widths[c] for c in columns), file=out)
152
+ for row in rows:
153
+ print(" ".join(str(row.get(c, "")).ljust(widths[c]) for c in columns), file=out)
154
+
155
+
156
+ def _emit(payload: Any, *, as_json: bool, out: Any, render: Callable[[], None]) -> None:
157
+ if as_json:
158
+ print(json.dumps(payload, indent=2, sort_keys=True, default=str), file=out)
159
+ else:
160
+ render()
161
+
162
+
163
+ def _ref_dict(ref: ArtifactRef) -> dict[str, Any]:
164
+ """The one JSON shape for an artifact, shared with `matview.graph`."""
165
+ return ref_to_dict(ref)
166
+
167
+
168
+ def _resolve_target(store: SqliteStore, args: argparse.Namespace) -> list[ArtifactRef]:
169
+ """Find the artifacts a `show`/`cat` invocation names, or explain why it found none."""
170
+ if args.id is not None:
171
+ ref = store.ref_by_id(args.id)
172
+ if ref is None:
173
+ raise MatviewError(f"no artifact with id {args.id}")
174
+ return [ref]
175
+
176
+ if args.key_hash is not None:
177
+ refs = store.refs_by_key_hash(args.key_hash, args.recipe)
178
+ if not refs:
179
+ raise MatviewError(f"no artifact with key hash {args.key_hash}")
180
+ return refs
181
+
182
+ if args.recipe is None:
183
+ raise MatviewError("give a recipe name, or --id, or --key-hash")
184
+
185
+ from matview.fingerprint import key_hash as hash_key
186
+
187
+ key = _parse_pairs(args.key, what="a key field")
188
+ if args.recipes:
189
+ from matview.registry import REGISTRY
190
+
191
+ key = REGISTRY.get(args.recipe).bind(key)
192
+ refs = store.refs_by_key_hash(hash_key(key), args.recipe)
193
+ if refs:
194
+ return refs
195
+
196
+ stored = store.list_refs(args.recipe, limit=1)
197
+ if stored and not args.recipes:
198
+ raise MatviewError(
199
+ f"no artifact of {args.recipe} matches that key, but {args.recipe} does have "
200
+ f"stored artifacts.\n\nA key is hashed *after* the recipe's declared defaults "
201
+ f"are filled in, so a partial key cannot match. Either pass every field, or "
202
+ f"pass --recipes MODULE so the defaults can be applied.\n\nTry "
203
+ f"`{PROGRAM} ls --recipe {args.recipe} --db {args.db}` to see the stored keys."
204
+ )
205
+ raise MatviewError(f"no artifact of {args.recipe} matches that key")
206
+
207
+
208
+ # --- commands --------------------------------------------------------------
209
+
210
+
211
+ def cmd_info(args: argparse.Namespace, out: Any) -> int:
212
+ """Describe the database, whatever version wrote it."""
213
+ info = inspect_database(args.db)
214
+
215
+ def render() -> None:
216
+ print(
217
+ f"{info['path']} ({_human_bytes(info['n_bytes'])}, {info['journal_mode']})", file=out
218
+ )
219
+ print(
220
+ f" schema_version {info['schema_version']} "
221
+ f"(this build implements {info['implemented_schema_version']})",
222
+ file=out,
223
+ )
224
+ print(
225
+ f" fingerprint_spec_version {info['fingerprint_spec_version']} "
226
+ f"(this build implements {info['implemented_fingerprint_spec_version']})",
227
+ file=out,
228
+ )
229
+ print(f" readable by this build {'yes' if info['readable'] else 'NO'}", file=out)
230
+ print("\n rows", file=out)
231
+ for table in info["tables"]:
232
+ print(f" {table:<14} {info['row_counts'][table]}", file=out)
233
+ if not info["readable"]:
234
+ print(
235
+ "\n Other commands will refuse this file: its hashes or its columns are "
236
+ "not the ones\n this build knows about. Nothing here has modified it.",
237
+ file=out,
238
+ )
239
+
240
+ _emit(info, as_json=args.json, out=out, render=render)
241
+ return OK if info["readable"] else PROBLEM
242
+
243
+
244
+ def cmd_ls(args: argparse.Namespace, out: Any) -> int:
245
+ """List artifacts."""
246
+ store = _open(args.db)
247
+ try:
248
+ valid = None if args.valid == args.invalid else args.valid
249
+ refs = store.list_refs(
250
+ args.recipe,
251
+ version=args.version,
252
+ part=args.part,
253
+ valid=valid,
254
+ fields={k: str(v) for k, v in _parse_pairs(args.where, what="--where").items()},
255
+ limit=args.limit,
256
+ )
257
+ payload = [_ref_dict(ref) for ref in refs]
258
+
259
+ def render() -> None:
260
+ _table(
261
+ [
262
+ {
263
+ "id": ref.id,
264
+ "recipe": ref.recipe,
265
+ "v": ref.version,
266
+ "part": "-" if ref.part is None else ref.part,
267
+ "valid": "yes" if ref.valid else "STALE",
268
+ "size": _human_bytes(ref.n_bytes),
269
+ "computed": _iso(ref.computed_at),
270
+ "key": ref.key_json,
271
+ }
272
+ for ref in refs
273
+ ],
274
+ ["id", "recipe", "v", "part", "valid", "size", "computed", "key"],
275
+ out,
276
+ )
277
+ if args.limit is not None and len(refs) == args.limit:
278
+ print(f"\n(stopped at --limit {args.limit})", file=out)
279
+
280
+ _emit(payload, as_json=args.json, out=out, render=render)
281
+ return OK
282
+ finally:
283
+ store.close()
284
+
285
+
286
+ def cmd_show(args: argparse.Namespace, out: Any) -> int:
287
+ """Show one artifact in full, with its dependencies and dependents."""
288
+ if args.recipes:
289
+ _load_recipes(args.recipes)
290
+ store = _open(args.db)
291
+ try:
292
+ refs = _resolve_target(store, args)
293
+ if args.part is not None:
294
+ refs = [ref for ref in refs if ref.part == args.part]
295
+ if not refs:
296
+ raise MatviewError(f"no part {args.part!r} among those artifacts")
297
+
298
+ payload = []
299
+ for ref in refs:
300
+ deps = store.deps_of(ref.id)
301
+ entry = _ref_dict(ref)
302
+ entry["dependencies"] = [
303
+ {
304
+ "recipe": edge.recipe,
305
+ "version": edge.version,
306
+ "part": edge.part,
307
+ "key": json.loads(edge.key_json) if edge.key_json else {},
308
+ "digest_at_compute_time": edge.digest,
309
+ "artifact_id": edge.artifact_id,
310
+ }
311
+ for edge in deps
312
+ ]
313
+ entry["dependents"] = store.dependents_of([ref.id])
314
+ payload.append(entry)
315
+
316
+ def render() -> None:
317
+ for entry in payload:
318
+ print(
319
+ f"artifact {entry['id']}: {entry['recipe']} v{entry['version']}"
320
+ + ("" if entry["part"] is None else f" part={entry['part']!r}"),
321
+ file=out,
322
+ )
323
+ print(f" key {json.dumps(entry['key'], sort_keys=True)}", file=out)
324
+ print(f" key_hash {entry['key_hash']}", file=out)
325
+ print(f" digest {entry['digest']}", file=out)
326
+ print(f" fingerprint {entry['fingerprint']}", file=out)
327
+ print(f" valid {'yes' if entry['valid'] else 'no (stale)'}", file=out)
328
+ print(f" size {_human_bytes(entry['n_bytes'])}", file=out)
329
+ print(f" computed_at {_iso(entry['computed_at'])}", file=out)
330
+ print(f" refreshed_at {_iso(entry['refreshed_at'])}", file=out)
331
+ print(f" compute_ms {entry['compute_ms']}", file=out)
332
+ if entry["dependencies"]:
333
+ print(" dependencies", file=out)
334
+ for dep in entry["dependencies"]:
335
+ part = "" if dep["part"] is None else f" part={dep['part']!r}"
336
+ print(
337
+ f" {dep['recipe']} v{dep['version']}{part} "
338
+ f"{json.dumps(dep['key'], sort_keys=True)} "
339
+ f"(id {dep['artifact_id']})",
340
+ file=out,
341
+ )
342
+ else:
343
+ print(" dependencies (none: this is a root)", file=out)
344
+ print(
345
+ f" dependents {entry['dependents'] or '(none)'}",
346
+ file=out,
347
+ )
348
+
349
+ _emit(
350
+ payload if len(payload) > 1 else payload[0], as_json=args.json, out=out, render=render
351
+ )
352
+ return OK
353
+ finally:
354
+ store.close()
355
+
356
+
357
+ def cmd_stats(args: argparse.Namespace, out: Any) -> int:
358
+ """Summarize the database."""
359
+ store = _open(args.db)
360
+ try:
361
+ summary = store.summary()
362
+ recipes = store.recipe_rows()
363
+ payload = {**summary, "by_recipe": recipes}
364
+
365
+ def render() -> None:
366
+ print(f"{summary['path']}", file=out)
367
+ print(
368
+ f" schema {summary['schema_version']}, "
369
+ f"fingerprint spec {summary['fingerprint_spec_version']}",
370
+ file=out,
371
+ )
372
+ print(
373
+ f" {summary['artifacts']} artifacts "
374
+ f"({summary['invalid']} stale), {_human_bytes(summary['n_bytes'])}, "
375
+ f"largest {_human_bytes(summary['largest_artifact_bytes'])}",
376
+ file=out,
377
+ )
378
+ print(
379
+ f" {summary['recipes']} recipes in "
380
+ f"{summary['recipe_versions']} versions, "
381
+ f"{summary['dep_edges']} dependency edges, "
382
+ f"{summary['pending_tasks']} pending tasks",
383
+ file=out,
384
+ )
385
+ print(
386
+ f" computed between {_iso(summary['oldest_computed_at'])} and "
387
+ f"{_iso(summary['newest_computed_at'])}, "
388
+ f"{summary['compute_ms']} ms total",
389
+ file=out,
390
+ )
391
+ print(file=out)
392
+ _table(
393
+ [
394
+ {
395
+ "recipe": row["name"],
396
+ "v": row["version"],
397
+ "codec": row["codec"],
398
+ "artifacts": row["artifacts"],
399
+ "stale": row["invalid"],
400
+ "size": _human_bytes(row["n_bytes"]),
401
+ }
402
+ for row in recipes
403
+ ],
404
+ ["recipe", "v", "codec", "artifacts", "stale", "size"],
405
+ out,
406
+ )
407
+
408
+ _emit(payload, as_json=args.json, out=out, render=render)
409
+ return OK
410
+ finally:
411
+ store.close()
412
+
413
+
414
+ def cmd_verify(args: argparse.Namespace, out: Any) -> int:
415
+ """Recompute every stored payload's digest and compare it to the recorded one.
416
+
417
+ This is the one corruption the library cannot detect by itself. A digest that no
418
+ longer matches its bytes does not error anywhere: it silently invalidates every
419
+ fingerprint above it, and the cache goes on looking healthy.
420
+ """
421
+ store = _open(args.db)
422
+ try:
423
+ checked = 0
424
+ mismatches: list[dict[str, Any]] = []
425
+ for artifact_id, recorded, data in store.iter_payloads(args.recipe):
426
+ checked += 1
427
+ actual = digest_bytes(data)
428
+ if actual != recorded:
429
+ ref = store.ref_by_id(artifact_id)
430
+ mismatches.append(
431
+ {
432
+ "id": artifact_id,
433
+ "recipe": None if ref is None else ref.recipe,
434
+ "version": None if ref is None else ref.version,
435
+ "part": None if ref is None else ref.part,
436
+ "key": None if ref is None else json.loads(ref.key_json),
437
+ "recorded_digest": recorded,
438
+ "actual_digest": actual,
439
+ }
440
+ )
441
+ if args.limit is not None and checked >= args.limit:
442
+ break
443
+
444
+ payload = {"checked": checked, "mismatches": mismatches}
445
+
446
+ def render() -> None:
447
+ print(f"checked {checked} artifact(s)", file=out)
448
+ if not mismatches:
449
+ print("all digests match their payloads", file=out)
450
+ return
451
+ print(f"\n{len(mismatches)} MISMATCH(ES):", file=out)
452
+ for bad in mismatches:
453
+ print(
454
+ f" artifact {bad['id']} ({bad['recipe']} v{bad['version']}): "
455
+ f"recorded {bad['recorded_digest'][:16]}…, "
456
+ f"actual {bad['actual_digest'][:16]}…",
457
+ file=out,
458
+ )
459
+
460
+ _emit(payload, as_json=args.json, out=out, render=render)
461
+ return PROBLEM if mismatches else OK
462
+ finally:
463
+ store.close()
464
+
465
+
466
+ def cmd_plan(args: argparse.Namespace, out: Any) -> int:
467
+ """Walk the graph without computing anything."""
468
+ _load_recipes(args.recipes)
469
+ from matview.cache import Cache
470
+ from matview.registry import REGISTRY
471
+
472
+ store = _open(args.db)
473
+ try:
474
+ cache = Cache(store=store, registry=REGISTRY)
475
+ report = cache.plan(
476
+ args.recipe,
477
+ part=args.part,
478
+ on_miss=args.on_miss,
479
+ force_deps=args.force_deps,
480
+ **_parse_pairs(args.key, what="a key field"),
481
+ )
482
+ payload = {
483
+ "current": report.is_current,
484
+ "absent": [
485
+ {
486
+ "recipe": node.recipe,
487
+ "version": node.version,
488
+ "key": dict(node.key),
489
+ "part": node.part,
490
+ "action": node.action,
491
+ "path": list(node.path),
492
+ }
493
+ for node in report.absent
494
+ ],
495
+ "stale": [_ref_dict(ref) for ref in report.stale],
496
+ "valid": [_ref_dict(ref) for ref in report.valid],
497
+ "nondeterministic": list(report.nondeterministic),
498
+ }
499
+ _emit(
500
+ payload, as_json=args.json, out=out, render=lambda: print(report.describe(), file=out)
501
+ )
502
+ return OK if report.is_current else PROBLEM
503
+ finally:
504
+ store.close()
505
+
506
+
507
+ def cmd_cat(args: argparse.Namespace, out: Any) -> int:
508
+ """Print an artifact's value, or its raw bytes.
509
+
510
+ Decoding needs no `--recipes`: the codec is recorded on the recipe row. Only a codec
511
+ the application registered itself does.
512
+ """
513
+ if args.recipes:
514
+ _load_recipes(args.recipes)
515
+ store = _open(args.db)
516
+ try:
517
+ refs = _resolve_target(store, args)
518
+ if args.part is not None:
519
+ refs = [ref for ref in refs if ref.part == args.part]
520
+ if len(refs) != 1:
521
+ raise MatviewError(
522
+ f"that names {len(refs)} artifacts; add --part, or use --id. "
523
+ f"Parts present: {', '.join(str(r.part) for r in refs)}"
524
+ )
525
+ artifact = store.fetch_by_id(refs[0].id)
526
+ assert artifact is not None
527
+
528
+ if args.raw:
529
+ buffer = getattr(out, "buffer", None)
530
+ if buffer is None:
531
+ out.write(artifact.data.decode("utf-8", "replace"))
532
+ else:
533
+ buffer.write(artifact.data)
534
+ return OK
535
+
536
+ from matview.codecs import CodecError, get_codec
537
+
538
+ try:
539
+ codec = get_codec(artifact.codec)
540
+ except CodecError as exc:
541
+ raise MatviewError(
542
+ f"{exc}\n\nThe recipe row records codec {artifact.codec!r}. If your "
543
+ f"application defines it, pass --recipes MODULE so it is registered; "
544
+ f"otherwise use --raw."
545
+ ) from exc
546
+ value = codec.decode(artifact.data)
547
+ if args.json:
548
+ print(json.dumps(value, indent=2, sort_keys=True, default=str), file=out)
549
+ else:
550
+ print(value, file=out)
551
+ return OK
552
+ finally:
553
+ store.close()
554
+
555
+
556
+ def _resolve_one(store: SqliteStore, args: argparse.Namespace) -> ArtifactRef:
557
+ """One artifact, addressed the way `show` addresses one.
558
+
559
+ A graph question is about a single node, so a key that names several parts is an
560
+ error here rather than a silent choice of the first one.
561
+ """
562
+ refs = _resolve_target(store, args)
563
+ if args.part is not None:
564
+ refs = [ref for ref in refs if ref.part == args.part]
565
+ if len(refs) != 1:
566
+ raise MatviewError(
567
+ f"that names {len(refs)} artifacts; add --part, or use --id. "
568
+ f"Parts present: {', '.join(str(r.part) for r in refs)}"
569
+ )
570
+ return refs[0]
571
+
572
+
573
+ def _format(args: argparse.Namespace) -> str:
574
+ """`--json` and `--format json` mean the same thing, so let either say it."""
575
+ return "json" if args.json else str(args.format)
576
+
577
+
578
+ def cmd_lineage(args: argparse.Namespace, out: Any) -> int:
579
+ """Show what an artifact was built from, transitively."""
580
+ if args.recipes:
581
+ _load_recipes(args.recipes)
582
+ store = _open(args.db)
583
+ try:
584
+ from matview.graph import lineage
585
+
586
+ ref = _resolve_one(store, args)
587
+ graph = lineage(store, ref.id, depth=args.depth)
588
+ print(graph.render(_format(args)), file=out)
589
+ return OK
590
+ finally:
591
+ store.close()
592
+
593
+
594
+ def cmd_impact(args: argparse.Namespace, out: Any) -> int:
595
+ """Show what a change, a version bump or a drop would reach. Reads only."""
596
+ store = _open(args.db)
597
+ try:
598
+ from matview.graph import (
599
+ impact_graph,
600
+ impact_of_bump,
601
+ impact_of_change,
602
+ impact_of_drop,
603
+ )
604
+
605
+ fmt = _format(args)
606
+ if fmt in ("dot", "mermaid") and not args.all:
607
+ raise MatviewError(
608
+ f"--format {fmt} draws the affected artifacts, so it needs --all.\n\n"
609
+ f"That set can be very large — one shared root in the recorded benchmark "
610
+ f"graph reaches 255,000 artifacts — which is why enumerating it is opt-in. "
611
+ f"Bound it with --depth or --limit."
612
+ )
613
+
614
+ if args.changed is not None:
615
+ report = impact_of_change(
616
+ store, args.changed, depth=args.depth, enumerate_all=args.all, limit=args.limit
617
+ )
618
+ root: int | None = args.changed
619
+ elif args.bump is not None:
620
+ report = impact_of_bump(store, args.bump, enumerate_all=args.all, limit=args.limit)
621
+ root = None
622
+ else:
623
+ report = impact_of_drop(
624
+ store, args.drop, version=args.version, enumerate_all=args.all, limit=args.limit
625
+ )
626
+ root = None
627
+
628
+ if fmt == "json":
629
+ print(json.dumps(report.to_json(), indent=2, sort_keys=True, default=str), file=out)
630
+ elif fmt == "tree":
631
+ print(report.describe(), file=out)
632
+ else:
633
+ # One document, so the output stays valid input for the tool that reads it.
634
+ # What each group holds is named in a leading comment; the counts are in the
635
+ # tree and JSON renderings, which is where a count belongs.
636
+ comment = "/* {} */" if fmt == "dot" else "%% {}"
637
+ print(comment.format(report.subject), file=out)
638
+ for group in report.groups:
639
+ print(comment.format(f"{group.label}: {group.total}"), file=out)
640
+ print(impact_graph(store, report, root).render(fmt), file=out)
641
+ return OK
642
+ finally:
643
+ store.close()
644
+
645
+
646
+ def cmd_cost(args: argparse.Namespace, out: Any) -> int:
647
+ """Report what something cost to compute, per artifact closure or per recipe."""
648
+ if args.recipes:
649
+ _load_recipes(args.recipes)
650
+ store = _open(args.db)
651
+ try:
652
+ from matview.graph import embodied_cost, recipe_costs
653
+
654
+ if args.by_recipe:
655
+ costs = recipe_costs(store)
656
+ payload = [
657
+ {
658
+ "recipe": c.recipe,
659
+ "version": c.version,
660
+ "codec": c.codec,
661
+ "artifacts": c.artifacts,
662
+ "values": c.values,
663
+ "compute_ms": c.compute_ms,
664
+ "mean_compute_ms": c.mean_compute_ms,
665
+ "n_bytes": c.n_bytes,
666
+ }
667
+ for c in costs
668
+ ]
669
+
670
+ def render() -> None:
671
+ _table(
672
+ [
673
+ {
674
+ "recipe": c.recipe,
675
+ "v": c.version,
676
+ "artifacts": c.artifacts,
677
+ "values": c.values,
678
+ "compute_ms": c.compute_ms,
679
+ "mean_ms": "-"
680
+ if c.mean_compute_ms is None
681
+ else f"{c.mean_compute_ms:.1f}",
682
+ "size": _human_bytes(c.n_bytes),
683
+ }
684
+ for c in costs
685
+ ],
686
+ ["recipe", "v", "artifacts", "values", "compute_ms", "mean_ms", "size"],
687
+ out,
688
+ )
689
+ print(
690
+ "\ncompute_ms is summed per value, not per artifact: every part of a "
691
+ "partitioned\nvalue records the cost of the one compute that produced "
692
+ "them all. `values` is that count.",
693
+ file=out,
694
+ )
695
+
696
+ _emit(payload, as_json=args.json, out=out, render=render)
697
+ return OK
698
+
699
+ ref = _resolve_one(store, args)
700
+ report = embodied_cost(
701
+ store, ref.id, depth=args.depth, with_critical_path=args.critical_path
702
+ )
703
+ _emit(
704
+ report.to_json(),
705
+ as_json=args.json,
706
+ out=out,
707
+ render=lambda: print(report.describe(), file=out),
708
+ )
709
+ return OK
710
+ finally:
711
+ store.close()
712
+
713
+
714
+ # --- argument parsing ------------------------------------------------------
715
+
716
+
717
+ def build_parser() -> argparse.ArgumentParser:
718
+ parser = argparse.ArgumentParser(
719
+ prog=PROGRAM,
720
+ description="Inspect a matview cache database. Nothing here writes to it.",
721
+ )
722
+ # Deliberately on the top-level parser only. Several subcommands already take a
723
+ # `--version` that filters by *recipe* version, which is the meaning that matters
724
+ # once you have a database in hand; `matview --version` reports the installed
725
+ # library, which is what a bug report needs.
726
+ parser.add_argument(
727
+ "--version",
728
+ action="version",
729
+ version=f"%(prog)s {__version__}",
730
+ help="print the installed matview version and exit",
731
+ )
732
+ subparsers = parser.add_subparsers(dest="command", required=True)
733
+
734
+ common = argparse.ArgumentParser(add_help=False)
735
+ common.add_argument(
736
+ "--db",
737
+ default=os.environ.get("MATVIEW_DB"),
738
+ help="path to the cache database (default: $MATVIEW_DB)",
739
+ )
740
+ common.add_argument("--json", action="store_true", help="machine-readable output")
741
+
742
+ recipes = argparse.ArgumentParser(add_help=False)
743
+ recipes.add_argument(
744
+ "--recipes",
745
+ action="append",
746
+ default=[],
747
+ metavar="MODULE",
748
+ help="import this module or .py file so its recipes are registered; repeatable",
749
+ )
750
+
751
+ target = argparse.ArgumentParser(add_help=False)
752
+ target.add_argument("recipe", nargs="?", help="recipe name")
753
+ target.add_argument("key", nargs="*", metavar="field=value", help="key fields, parsed as JSON")
754
+ target.add_argument("--id", type=int, help="address the artifact by row id instead")
755
+ target.add_argument("--key-hash", help="address the artifact by key hash instead")
756
+ target.add_argument("--part", help="select one part")
757
+
758
+ info = subparsers.add_parser(
759
+ "info", parents=[common], help="describe the database, whatever version wrote it"
760
+ )
761
+ info.set_defaults(func=cmd_info)
762
+
763
+ ls = subparsers.add_parser("ls", parents=[common], help="list artifacts")
764
+ ls.add_argument("--recipe", help="only this recipe")
765
+ ls.add_argument("--version", type=int, help="only this recipe version")
766
+ ls.add_argument("--part", help="only this part")
767
+ ls.add_argument("--valid", action="store_true", help="only current artifacts")
768
+ ls.add_argument("--invalid", action="store_true", help="only stale artifacts")
769
+ ls.add_argument(
770
+ "--where",
771
+ action="append",
772
+ default=[],
773
+ metavar="FIELD=VALUE",
774
+ help="filter on a promoted index field; repeatable",
775
+ )
776
+ ls.add_argument("--limit", type=int, help="stop after this many")
777
+ ls.set_defaults(func=cmd_ls)
778
+
779
+ show = subparsers.add_parser(
780
+ "show", parents=[common, recipes, target], help="show one artifact in full"
781
+ )
782
+ show.set_defaults(func=cmd_show)
783
+
784
+ stats = subparsers.add_parser("stats", parents=[common], help="summarize the database")
785
+ stats.set_defaults(func=cmd_stats)
786
+
787
+ verify = subparsers.add_parser(
788
+ "verify", parents=[common], help="check every payload against its recorded digest"
789
+ )
790
+ verify.add_argument("--recipe", help="only this recipe")
791
+ verify.add_argument("--limit", type=int, help="stop after this many")
792
+ verify.set_defaults(func=cmd_verify)
793
+
794
+ plan = subparsers.add_parser(
795
+ "plan", parents=[common, recipes], help="walk the graph without computing (needs --recipes)"
796
+ )
797
+ plan.add_argument("recipe", help="recipe name")
798
+ plan.add_argument("key", nargs="*", metavar="field=value", help="key fields, parsed as JSON")
799
+ plan.add_argument("--part", help="select one part")
800
+ plan.add_argument("--on-miss", choices=("compute", "enqueue", "raise"), default="compute")
801
+ plan.add_argument("--force-deps", action="store_true")
802
+ plan.set_defaults(func=cmd_plan)
803
+
804
+ fmt = argparse.ArgumentParser(add_help=False)
805
+ fmt.add_argument(
806
+ "--format",
807
+ choices=("tree", "json", "dot", "mermaid"),
808
+ default="tree",
809
+ help="how to render the graph (--json is the same as --format json)",
810
+ )
811
+
812
+ lineage = subparsers.add_parser(
813
+ "lineage",
814
+ parents=[common, recipes, target, fmt],
815
+ help="what an artifact was built from, transitively",
816
+ )
817
+ lineage.add_argument("--depth", type=int, help="stop after this many levels")
818
+ lineage.set_defaults(func=cmd_lineage)
819
+
820
+ impact = subparsers.add_parser(
821
+ "impact",
822
+ parents=[common, fmt],
823
+ help="what a change, a version bump or a drop would reach",
824
+ )
825
+ question = impact.add_mutually_exclusive_group(required=True)
826
+ question.add_argument(
827
+ "--changed", type=int, metavar="ID", help="what would go stale if this artifact changed"
828
+ )
829
+ question.add_argument(
830
+ "--bump", metavar="RECIPE", help="what happens if this recipe's version is bumped"
831
+ )
832
+ question.add_argument(
833
+ "--drop", metavar="RECIPE", help="what cache.drop() would delete and mark stale"
834
+ )
835
+ impact.add_argument("--version", type=int, help="with --drop, only this recipe version")
836
+ impact.add_argument(
837
+ "--all", action="store_true", help="enumerate the affected artifacts, not just count them"
838
+ )
839
+ impact.add_argument("--depth", type=int, help="with --changed, stop after this many levels")
840
+ impact.add_argument("--limit", type=int, help="with --all, list at most this many per group")
841
+ impact.set_defaults(func=cmd_impact)
842
+
843
+ cost = subparsers.add_parser(
844
+ "cost",
845
+ parents=[common, recipes, target],
846
+ help="what something cost to compute",
847
+ )
848
+ cost.add_argument(
849
+ "--by-recipe", action="store_true", help="roll up every recipe version instead"
850
+ )
851
+ cost.add_argument(
852
+ "--critical-path",
853
+ action="store_true",
854
+ help="also report the most expensive chain through the closure",
855
+ )
856
+ cost.add_argument("--depth", type=int, help="stop after this many levels")
857
+ cost.set_defaults(func=cmd_cost)
858
+
859
+ cat = subparsers.add_parser(
860
+ "cat", parents=[common, recipes, target], help="print an artifact's decoded value"
861
+ )
862
+ cat.add_argument("--raw", action="store_true", help="write the stored bytes instead")
863
+ cat.set_defaults(func=cmd_cat)
864
+
865
+ return parser
866
+
867
+
868
+ def main(argv: Sequence[str] | None = None, out: Any = None) -> int:
869
+ parser = build_parser()
870
+ args = parser.parse_args(argv)
871
+ stream = sys.stdout if out is None else out
872
+
873
+ if args.command == "plan" and not args.recipes:
874
+ parser.error(
875
+ "plan needs --recipes MODULE: a plan resolves recipes, so they must be registered"
876
+ )
877
+ if args.command == "cost":
878
+ addressed = args.id is not None or args.key_hash is not None or args.recipe is not None
879
+ if not args.by_recipe and not addressed:
880
+ parser.error(
881
+ "cost needs an artifact — --id, --key-hash, or a recipe and key — or --by-recipe"
882
+ )
883
+ if args.by_recipe and (addressed or args.critical_path or args.depth is not None):
884
+ parser.error("--by-recipe rolls up the whole database; it takes no artifact or depth")
885
+ if args.command == "impact" and args.version is not None and args.drop is None:
886
+ parser.error("--version selects a recipe version to drop, so it only goes with --drop")
887
+ if not args.db:
888
+ parser.error("no database given: pass --db PATH or set MATVIEW_DB")
889
+
890
+ try:
891
+ return int(args.func(args, stream))
892
+ except MatviewError as exc:
893
+ print(f"{PROGRAM}: {exc}", file=sys.stderr)
894
+ return PROBLEM
895
+ except BrokenPipeError: # pragma: no cover - `matview ls | head`
896
+ return OK
897
+
898
+
899
+ if __name__ == "__main__": # pragma: no cover
900
+ raise SystemExit(main())