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/__init__.py +78 -0
- matview/cache.py +750 -0
- matview/canon.py +116 -0
- matview/cli.py +900 -0
- matview/codecs/__init__.py +159 -0
- matview/codecs/_arrow.py +104 -0
- matview/codecs/_arrow_ipc.py +89 -0
- matview/codecs/_arrow_pandas.py +76 -0
- matview/codecs/_arrow_parts.py +99 -0
- matview/codecs/_json.py +36 -0
- matview/codecs/_npy.py +53 -0
- matview/context.py +87 -0
- matview/errors.py +96 -0
- matview/fingerprint.py +99 -0
- matview/foreign.py +436 -0
- matview/graph.py +876 -0
- matview/policy.py +67 -0
- matview/py.typed +0 -0
- matview/registry.py +293 -0
- matview/store/__init__.py +6 -0
- matview/store/base.py +99 -0
- matview/store/sqlite.py +1188 -0
- matview/types.py +341 -0
- matview_cache-0.1.0.dist-info/METADATA +262 -0
- matview_cache-0.1.0.dist-info/RECORD +28 -0
- matview_cache-0.1.0.dist-info/WHEEL +4 -0
- matview_cache-0.1.0.dist-info/entry_points.txt +2 -0
- matview_cache-0.1.0.dist-info/licenses/LICENSE +21 -0
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())
|