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/foreign.py ADDED
@@ -0,0 +1,436 @@
1
+ """Foreign recipes: computations written in any language.
2
+
3
+ Unlike Python recipes, foreign recipes **declare their dependencies upfront** in a
4
+ manifest rather than discovering them at runtime. This is the privileged case, not a
5
+ degraded one: a declared graph can be cycle-checked and planned before anything
6
+ executes, whereas a Python recipe's graph is only knowable by running it. Dynamic
7
+ discovery is the escape hatch.
8
+
9
+ A manifest is data, not code, so registering a foreign recipe never requires its
10
+ language to be installed.
11
+
12
+ The invocation contract is one JSON job file passed as the **only** argument. Never
13
+ parameters on the command line, never positional input paths; both fall apart at a
14
+ dozen inputs or a nested params dict.
15
+
16
+ {
17
+ "params": {"dataset": "ie-ielex", "model": "GTR"},
18
+ "inputs": {"matrix": "/tmp/matview-8a1f/matrix.arrow"},
19
+ "output": "/tmp/matview-8a1f/out.json",
20
+ "deadline_s": 120
21
+ }
22
+
23
+ Rules the host enforces:
24
+
25
+ - stdout and stderr are logging only. The result is read from `output`, never from
26
+ stdout, so a stray `print` in an R script cannot corrupt a value.
27
+ - Nonzero exit, a missing output file, or an unparseable one is a compute failure and
28
+ is not cached. A poisoned artifact from a half-written output is much worse than a
29
+ recompute.
30
+ - The job file is left on disk on failure, so a failed job is reproducible by hand by
31
+ rerunning the same command.
32
+ - `deadline_s` is the remaining budget from the propagated deadline, and the host also
33
+ enforces it by killing the process.
34
+ """
35
+
36
+ from __future__ import annotations
37
+
38
+ import json
39
+ import logging
40
+ import shutil
41
+ import subprocess
42
+ import tempfile
43
+ import tomllib
44
+ from collections.abc import Mapping, Sequence
45
+ from pathlib import Path
46
+ from typing import TYPE_CHECKING, Any
47
+
48
+ from matview.canon import Scalar
49
+ from matview.errors import ForeignRecipeError
50
+ from matview.fingerprint import source_hash
51
+ from matview.registry import REGISTRY, ForeignInput, ForeignSpec, Recipe, Registry, register_foreign
52
+ from matview.types import OnMiss
53
+
54
+ if TYPE_CHECKING:
55
+ from matview.cache import Cache, _Walk
56
+ from matview.context import Context
57
+ from matview.policy import Policy
58
+ from matview.store.base import Store
59
+ from matview.types import DepEdge
60
+
61
+ __all__ = [
62
+ "check_declared_cycles",
63
+ "load_manifest",
64
+ "load_manifests",
65
+ "run_foreign",
66
+ "substitute_key",
67
+ ]
68
+
69
+ log = logging.getLogger("matview.foreign")
70
+
71
+ _PARAM_TYPES: dict[str, type | tuple[type, ...]] = {
72
+ "string": str,
73
+ "int": int,
74
+ "float": (int, float),
75
+ "bool": bool,
76
+ }
77
+
78
+
79
+ # --- manifests -------------------------------------------------------------
80
+
81
+
82
+ def load_manifests(
83
+ directory: str | Path,
84
+ *,
85
+ registry: Registry | None = None,
86
+ store: Store | None = None,
87
+ ) -> list[Recipe]:
88
+ """Register every `*.toml` manifest under `directory`, by directory scan.
89
+
90
+ Pass a `store` to have source hashes checked against what the cache last saw; a
91
+ changed source with an unchanged version is a warning, since the artifacts already
92
+ stored under that version were produced by different code.
93
+ """
94
+ target = REGISTRY if registry is None else registry
95
+ root = Path(directory)
96
+ if not root.is_dir():
97
+ raise ForeignRecipeError(f"{root} is not a directory")
98
+
99
+ recipes = [load_manifest(path, registry=target) for path in sorted(root.rglob("*.toml"))]
100
+ check_declared_cycles(target)
101
+ if store is not None:
102
+ warn_on_changed_sources(store, recipes)
103
+ return recipes
104
+
105
+
106
+ def load_manifest(path: str | Path, *, registry: Registry | None = None) -> Recipe:
107
+ """Read one manifest and register it exactly as if a decorator had."""
108
+ target = REGISTRY if registry is None else registry
109
+ manifest_path = Path(path)
110
+ try:
111
+ raw = tomllib.loads(manifest_path.read_text(encoding="utf-8"))
112
+ except (OSError, tomllib.TOMLDecodeError) as exc:
113
+ raise ForeignRecipeError(f"{manifest_path}: unreadable manifest: {exc}") from exc
114
+
115
+ name = _require(raw, "name", str, manifest_path)
116
+ version = _require(raw, "version", int, manifest_path)
117
+ codec = _require(raw, "codec", str, manifest_path)
118
+ command = tuple(_require(raw, "command", list, manifest_path))
119
+ if not command or not all(isinstance(part, str) for part in command):
120
+ raise ForeignRecipeError(f"{manifest_path}: command must be a non-empty list of strings")
121
+
122
+ index = tuple(raw.get("index", ()))
123
+ on_miss: OnMiss = raw.get("on_miss", "compute")
124
+ deterministic = bool(raw.get("deterministic", True))
125
+ max_age = raw.get("max_age")
126
+ source = raw.get("source")
127
+
128
+ params: dict[str, dict[str, Any]] = raw.get("params", {})
129
+ if not isinstance(params, dict):
130
+ raise ForeignRecipeError(f"{manifest_path}: [params] must be a table")
131
+ defaults: dict[str, Scalar] = {}
132
+ param_types: dict[str, str] = {}
133
+ for param, declared in params.items():
134
+ if not isinstance(declared, dict):
135
+ raise ForeignRecipeError(f"{manifest_path}: param {param!r} must be a table")
136
+ kind = declared.get("type", "string")
137
+ if kind not in _PARAM_TYPES:
138
+ raise ForeignRecipeError(
139
+ f"{manifest_path}: param {param!r} has unknown type {kind!r}; "
140
+ f"known types: {', '.join(sorted(_PARAM_TYPES))}"
141
+ )
142
+ param_types[param] = kind
143
+ if "default" in declared:
144
+ defaults[param] = declared["default"]
145
+ _check_type(manifest_path, param, declared["default"], kind)
146
+
147
+ inputs = tuple(
148
+ _read_input(manifest_path, entry, tuple(params)) for entry in raw.get("inputs", ())
149
+ )
150
+
151
+ resolved_source = None if source is None else (manifest_path.parent / source)
152
+ hashed = None
153
+ if resolved_source is not None:
154
+ try:
155
+ hashed = source_hash(resolved_source.read_bytes())
156
+ except OSError as exc:
157
+ raise ForeignRecipeError(
158
+ f"{manifest_path}: declared source {source!r} is unreadable: {exc}"
159
+ ) from exc
160
+
161
+ spec = ForeignSpec(
162
+ command=command,
163
+ inputs=inputs,
164
+ source=None if resolved_source is None else str(resolved_source),
165
+ manifest_path=str(manifest_path),
166
+ param_types=param_types,
167
+ workdir=str(manifest_path.parent),
168
+ )
169
+ return register_foreign(
170
+ target,
171
+ name=name,
172
+ version=version,
173
+ codec=codec,
174
+ index=index,
175
+ on_miss=on_miss,
176
+ deterministic=deterministic,
177
+ max_age=max_age,
178
+ params=tuple(params),
179
+ defaults=defaults,
180
+ spec=spec,
181
+ source_hash=hashed,
182
+ )
183
+
184
+
185
+ def _require(raw: Mapping[str, Any], field: str, kind: type, path: Path) -> Any:
186
+ if field not in raw:
187
+ raise ForeignRecipeError(f"{path}: manifest is missing required field {field!r}")
188
+ value = raw[field]
189
+ if kind is int and isinstance(value, bool):
190
+ raise ForeignRecipeError(f"{path}: {field} must be {kind.__name__}, got a bool")
191
+ if not isinstance(value, kind):
192
+ raise ForeignRecipeError(
193
+ f"{path}: {field} must be {kind.__name__}, got {type(value).__name__}"
194
+ )
195
+ return value
196
+
197
+
198
+ def _check_type(path: Path, param: str, value: Any, kind: str) -> None:
199
+ expected = _PARAM_TYPES[kind]
200
+ if kind != "bool" and isinstance(value, bool):
201
+ raise ForeignRecipeError(f"{path}: param {param!r} declares {kind}, got a bool")
202
+ if not isinstance(value, expected):
203
+ raise ForeignRecipeError(
204
+ f"{path}: param {param!r} declares {kind}, got {type(value).__name__}"
205
+ )
206
+
207
+
208
+ def _read_input(path: Path, entry: Any, params: tuple[str, ...]) -> ForeignInput:
209
+ if not isinstance(entry, dict):
210
+ raise ForeignRecipeError(f"{path}: each [[inputs]] entry must be a table")
211
+ for field in ("name", "recipe"):
212
+ if field not in entry:
213
+ raise ForeignRecipeError(f"{path}: an [[inputs]] entry is missing {field!r}")
214
+ key = entry.get("key", {})
215
+ if not isinstance(key, dict):
216
+ raise ForeignRecipeError(f"{path}: input {entry['name']!r} has a non-table key")
217
+ for template in key.values():
218
+ if isinstance(template, str):
219
+ for referenced in _referenced_params(template):
220
+ if referenced not in params:
221
+ raise ForeignRecipeError(
222
+ f"{path}: input {entry['name']!r} refers to ${{{referenced}}}, which "
223
+ f"is not a parameter of this recipe"
224
+ )
225
+ return ForeignInput(
226
+ name=str(entry["name"]),
227
+ recipe=str(entry["recipe"]),
228
+ key=key,
229
+ part=entry.get("part"),
230
+ )
231
+
232
+
233
+ def _referenced_params(template: str) -> list[str]:
234
+ out = []
235
+ rest = template
236
+ while "${" in rest:
237
+ _, _, rest = rest.partition("${")
238
+ name, sep, rest = rest.partition("}")
239
+ if sep:
240
+ out.append(name)
241
+ return out
242
+
243
+
244
+ def check_declared_cycles(registry: Registry) -> None:
245
+ """Cycle-check the declared graph before anything executes.
246
+
247
+ Only foreign recipes appear here, because only they declare their inputs. A cycle
248
+ that runs through a Python recipe is caught at runtime by the in-flight stack
249
+ instead.
250
+ """
251
+ edges = {
252
+ rec.name: [i.recipe for i in rec.foreign.inputs]
253
+ for rec in registry
254
+ if rec.foreign is not None
255
+ }
256
+ state: dict[str, int] = {}
257
+
258
+ def visit(name: str, path: list[str]) -> None:
259
+ if state.get(name) == 1:
260
+ raise ForeignRecipeError("declared dependency cycle: " + " -> ".join([*path, name]))
261
+ if state.get(name) == 2:
262
+ return
263
+ state[name] = 1
264
+ for nxt in edges.get(name, ()):
265
+ visit(nxt, [*path, name])
266
+ state[name] = 2
267
+
268
+ for name in edges:
269
+ visit(name, [])
270
+
271
+
272
+ def warn_on_changed_sources(store: Store, recipes: Sequence[Recipe]) -> list[str]:
273
+ """Warn when a source file changed without a version bump.
274
+
275
+ Because a foreign recipe is usually a single file rather than a package with an
276
+ import graph, hashing its source is reliable here in a way it is not for Python.
277
+ The hash is advisory and never fingerprinted: it cannot invalidate anything, it can
278
+ only tell you that the artifacts already stored under this version were produced by
279
+ different code.
280
+ """
281
+ changed = []
282
+ for rec in recipes:
283
+ if rec.source_hash is None:
284
+ continue
285
+ recorded = store.recipe_source_hash(rec.name, rec.version) # type: ignore[attr-defined]
286
+ if recorded is not None and recorded != rec.source_hash:
287
+ message = (
288
+ f"{rec.name} v{rec.version}: source changed but the version did not. "
289
+ f"Artifacts already stored under v{rec.version} came from different code."
290
+ )
291
+ changed.append(message)
292
+ log.warning("%s", message)
293
+ return changed
294
+
295
+
296
+ # --- invocation ------------------------------------------------------------
297
+
298
+
299
+ def run_foreign(
300
+ cache: Cache,
301
+ walk: _Walk,
302
+ rec: Recipe,
303
+ key: Mapping[str, Scalar],
304
+ ctx: Context,
305
+ collected: list[DepEdge],
306
+ policy: Policy,
307
+ ) -> Any:
308
+ """Resolve declared inputs, materialize them, invoke, and read the result."""
309
+ spec = rec.foreign
310
+ assert spec is not None
311
+
312
+ for param, kind in spec.param_types.items():
313
+ if param in key:
314
+ _check_type(Path(spec.manifest_path or rec.name), param, key[param], kind)
315
+
316
+ workdir = Path(tempfile.mkdtemp(prefix=f"matview-{rec.name}-"))
317
+ succeeded = False
318
+ try:
319
+ inputs = {
320
+ declared.name: _materialize(cache, ctx, rec, declared, key, workdir)
321
+ for declared in spec.inputs
322
+ }
323
+ output = workdir / "output"
324
+ job = {
325
+ "params": dict(key),
326
+ "inputs": {name: str(path) for name, path in inputs.items()},
327
+ "output": str(output),
328
+ "deadline_s": policy.remaining(),
329
+ }
330
+ job_file = workdir / "job.json"
331
+ job_file.write_text(json.dumps(job, indent=2, sort_keys=True), encoding="utf-8")
332
+
333
+ value = _invoke(rec, spec, job_file, output, policy)
334
+ succeeded = True
335
+ return value
336
+ finally:
337
+ # The job file is left on disk on failure, so a failed job is reproducible by
338
+ # hand by rerunning the same command.
339
+ if succeeded:
340
+ shutil.rmtree(workdir, ignore_errors=True)
341
+ else:
342
+ log.warning("%s: job left at %s for reproduction", rec.name, workdir)
343
+
344
+
345
+ def substitute_key(template: Mapping[str, Any], key: Mapping[str, Scalar]) -> dict[str, Any]:
346
+ """Resolve a declared input's key against the recipe's own key."""
347
+ return {field: _substitute(value, key) for field, value in template.items()}
348
+
349
+
350
+ def _materialize(
351
+ cache: Cache,
352
+ ctx: Context,
353
+ rec: Recipe,
354
+ declared: ForeignInput,
355
+ key: Mapping[str, Scalar],
356
+ workdir: Path,
357
+ ) -> Path:
358
+ resolved_key = substitute_key(declared.key, key)
359
+ data = ctx.get_bytes(declared.recipe, part=declared.part, **resolved_key)
360
+ if not isinstance(data, bytes):
361
+ raise ForeignRecipeError(
362
+ f"{rec.name}: input {declared.name!r} resolves to a partitioned artifact with "
363
+ f"{len(data)} parts. Name a part in the manifest; materializing a whole "
364
+ f"partitioned value as one file is not supported. See DECISIONS.md D30."
365
+ )
366
+ path = workdir / declared.name
367
+ path.write_bytes(data)
368
+ return path
369
+
370
+
371
+ def _substitute(template: Any, key: Mapping[str, Scalar]) -> Any:
372
+ """`"${name}"` substitutes the typed scalar; anything else interpolates to a string.
373
+
374
+ The distinction matters because these feed `key_hash`, where `14` and `"14"` are
375
+ different artifacts. See DECISIONS.md D13.
376
+ """
377
+ if not isinstance(template, str):
378
+ return template
379
+ referenced = _referenced_params(template)
380
+ if not referenced:
381
+ return template
382
+ for name in referenced:
383
+ if name not in key:
384
+ raise ForeignRecipeError(f"${{{name}}} is not among this recipe's parameters")
385
+ if template == f"${{{referenced[0]}}}" and len(referenced) == 1:
386
+ return key[referenced[0]]
387
+ out = template
388
+ for name in referenced:
389
+ out = out.replace(f"${{{name}}}", str(key[name]))
390
+ return out
391
+
392
+
393
+ def _invoke(rec: Recipe, spec: ForeignSpec, job_file: Path, output: Path, policy: Policy) -> Any:
394
+ from matview.codecs import get_codec
395
+ from matview.errors import DeadlineExceeded
396
+
397
+ command = [*spec.command, str(job_file)]
398
+ remaining = policy.remaining()
399
+ try:
400
+ completed = subprocess.run(
401
+ command,
402
+ cwd=spec.workdir,
403
+ capture_output=True,
404
+ timeout=remaining,
405
+ check=False,
406
+ )
407
+ except FileNotFoundError as exc:
408
+ raise ForeignRecipeError(f"{rec.name}: cannot run {command[0]!r}: {exc}") from exc
409
+ except subprocess.TimeoutExpired as exc:
410
+ raise DeadlineExceeded(rec.name, remaining or 0.0) from exc
411
+
412
+ # stdout and stderr are logging only, so a stray print cannot corrupt a value.
413
+ if completed.stdout:
414
+ log.info("%s stdout: %s", rec.name, completed.stdout.decode("utf-8", "replace").rstrip())
415
+ if completed.stderr:
416
+ log.info("%s stderr: %s", rec.name, completed.stderr.decode("utf-8", "replace").rstrip())
417
+
418
+ if completed.returncode != 0:
419
+ raise ForeignRecipeError(
420
+ f"{rec.name}: {' '.join(command)} exited {completed.returncode}. "
421
+ f"stderr: {completed.stderr.decode('utf-8', 'replace').strip()[:2000]}"
422
+ )
423
+ if not output.exists():
424
+ raise ForeignRecipeError(
425
+ f"{rec.name}: exited 0 but wrote no output file. The result is read from the "
426
+ f"output path, never from stdout."
427
+ )
428
+
429
+ # The output is decoded and then re-encoded canonically by the caller, so a foreign
430
+ # process cannot break byte-determinism. See DECISIONS.md D20.
431
+ try:
432
+ return get_codec(rec.codec).decode(output.read_bytes())
433
+ except Exception as exc:
434
+ raise ForeignRecipeError(
435
+ f"{rec.name}: output is not readable as codec {rec.codec!r}: {exc}"
436
+ ) from exc