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/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
|