nnnotes 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.
Files changed (79) hide show
  1. nnnotes/__init__.py +7 -0
  2. nnnotes/__main__.py +4 -0
  3. nnnotes/acb.py +190 -0
  4. nnnotes/addressables.py +413 -0
  5. nnnotes/adv.py +243 -0
  6. nnnotes/advcommand.py +29 -0
  7. nnnotes/advmedia.py +178 -0
  8. nnnotes/advscene.py +113 -0
  9. nnnotes/advui.py +1154 -0
  10. nnnotes/advvideo.py +684 -0
  11. nnnotes/atoms/__init__.py +151 -0
  12. nnnotes/atoms/astc.py +52 -0
  13. nnnotes/atoms/gltf.py +290 -0
  14. nnnotes/atoms/mesh.py +289 -0
  15. nnnotes/atoms/png.py +28 -0
  16. nnnotes/atoms/reader.py +269 -0
  17. nnnotes/atoms/sniff.py +198 -0
  18. nnnotes/atoms/sprite.py +236 -0
  19. nnnotes/atoms/texture.py +80 -0
  20. nnnotes/cache.py +289 -0
  21. nnnotes/catalog.py +373 -0
  22. nnnotes/catalogdb.py +457 -0
  23. nnnotes/census.py +276 -0
  24. nnnotes/cli.py +744 -0
  25. nnnotes/cli_assets.py +1539 -0
  26. nnnotes/config.py +238 -0
  27. nnnotes/contract.py +497 -0
  28. nnnotes/cri.py +371 -0
  29. nnnotes/crikey.py +71 -0
  30. nnnotes/crilips.py +265 -0
  31. nnnotes/cristages.py +558 -0
  32. nnnotes/deckdata.py +462 -0
  33. nnnotes/export.py +1462 -0
  34. nnnotes/gameapi.py +318 -0
  35. nnnotes/jsonio.py +98 -0
  36. nnnotes/languages.py +44 -0
  37. nnnotes/layout.py +596 -0
  38. nnnotes/link.py +418 -0
  39. nnnotes/live.py +115 -0
  40. nnnotes/live2d.py +235 -0
  41. nnnotes/liveaudio.py +331 -0
  42. nnnotes/livenotes.py +291 -0
  43. nnnotes/liveoptions.py +250 -0
  44. nnnotes/livescene.py +450 -0
  45. nnnotes/liveui.py +328 -0
  46. nnnotes/master.py +251 -0
  47. nnnotes/modelstages.py +424 -0
  48. nnnotes/motion.py +131 -0
  49. nnnotes/objexport.py +1564 -0
  50. nnnotes/orchestrate.py +630 -0
  51. nnnotes/plan.py +218 -0
  52. nnnotes/player.py +392 -0
  53. nnnotes/room.py +391 -0
  54. nnnotes/score.py +1139 -0
  55. nnnotes/shader.py +217 -0
  56. nnnotes/spot.py +201 -0
  57. nnnotes/stages.py +285 -0
  58. nnnotes/store.py +518 -0
  59. nnnotes/story.py +142 -0
  60. nnnotes/storyfonts.py +1258 -0
  61. nnnotes/storyhost.py +1190 -0
  62. nnnotes/storysite.py +662 -0
  63. nnnotes/storyvoices.py +102 -0
  64. nnnotes/textstyle.py +355 -0
  65. nnnotes/tmpfont.py +636 -0
  66. nnnotes/typetrees/6000.3.12f1.json +19 -0
  67. nnnotes/unity.py +368 -0
  68. nnnotes/viewrules.json +441 -0
  69. nnnotes/views.py +966 -0
  70. nnnotes/voices.py +1363 -0
  71. nnnotes/web.py +1624 -0
  72. nnnotes/webaudio.py +14 -0
  73. nnnotes/webmodel.py +397 -0
  74. nnnotes-0.1.0.dist-info/METADATA +200 -0
  75. nnnotes-0.1.0.dist-info/RECORD +79 -0
  76. nnnotes-0.1.0.dist-info/WHEEL +5 -0
  77. nnnotes-0.1.0.dist-info/entry_points.txt +2 -0
  78. nnnotes-0.1.0.dist-info/licenses/LICENSE +21 -0
  79. nnnotes-0.1.0.dist-info/top_level.txt +1 -0
nnnotes/orchestrate.py ADDED
@@ -0,0 +1,630 @@
1
+ """The local orchestrator: the stages of a pipeline over their subjects, with a pool of worker processes.
2
+
3
+ Stages run in the order given; a stage's tasks are described once the stages before it are done (Env: the facts of
4
+ the caller plus the keys of the tasks already done), so every input is known when a task is described. Per stage:
5
+ a task whose result is in the store is a hit; the others run in parallel, then the next stage starts.
6
+
7
+ The pool: up to `workers` spawned processes (no more than a stage has tasks to run), long-lived (each imports
8
+ what its stages need once), one task at a time each. Tasks start in order of decreasing estimated CPU (longest
9
+ first), a task only when the estimated peak memory of the running tasks plus its own fits `memory` less
10
+ `worker_bytes` per worker (what the processes hold before any task); a task estimated above that budget runs alone
11
+ and is reported. A worker is replaced after `recycle_tasks` tasks, or when its resident memory after a task is
12
+ above `recycle_rss`; the workers are stopped together at the end. `workers=0` runs the tasks in this process, one
13
+ after another.
14
+
15
+ Failures: a failed object is an item of its task's result, cached with it. An exception out of a stage, an input
16
+ that cannot be read, or a worker that dies fails the task: nothing of it is cached, the run records it
17
+ (failures(), the run log) and goes on; a task that reads the result of a failed task fails as `upstream.failed`.
18
+ A ConfigError (a setting or tool the run needs) stops the run: no new task starts, and run() raises it once the
19
+ running tasks have ended (their results stay valid).
20
+ Stage results do not depend on the number of workers, their order or their timing: a run with one worker and a run
21
+ with many leave identical cas/ and ac/ trees and the same run manifest.
22
+ """
23
+ from __future__ import annotations
24
+
25
+ import multiprocessing as mp
26
+ import os
27
+ import sys
28
+ import time
29
+ import traceback
30
+ from collections import Counter
31
+ from dataclasses import dataclass, field
32
+ from multiprocessing.connection import wait
33
+
34
+ from . import contract
35
+ from .config import ConfigError
36
+ from .contract import Task
37
+ from .stages import Env, Pending, Stage, describe, execute
38
+
39
+ WORKER_BYTES = 70 << 20 # resident memory of an idle worker process (interpreter and imports), per worker
40
+ # failure codes of tasks (failed items carry the codes of their stages)
41
+ FAILURE_CODES = ("task.error", "task.input", "task.io", "task.memory", "task.conflict", "task.incompatible",
42
+ "task.worker_died", "task.describe", "upstream.failed", "upstream.missing", "config")
43
+
44
+
45
+ def _log(m: str) -> None:
46
+ print(f"[{time.strftime('%H:%M:%S')}] {m}", file=sys.stderr, flush=True)
47
+
48
+
49
+ # ---------------------------------------------------------------- process memory
50
+ def rss() -> int | None:
51
+ """This process's resident memory in bytes (None where the platform does not tell)."""
52
+ try:
53
+ with open("/proc/self/statm") as f:
54
+ return int(f.read().split()[1]) * os.sysconf("SC_PAGE_SIZE")
55
+ except (OSError, ValueError, AttributeError):
56
+ return None
57
+
58
+
59
+ def reset_peak() -> None:
60
+ """Restart the peak-memory counter of this process (Linux; elsewhere nothing)."""
61
+ try:
62
+ with open("/proc/self/clear_refs", "w") as f:
63
+ f.write("5")
64
+ except OSError:
65
+ pass
66
+
67
+
68
+ def peak_rss() -> int | None:
69
+ """The peak resident memory of this process since reset_peak (Linux), or None."""
70
+ try:
71
+ with open("/proc/self/status") as f:
72
+ for line in f:
73
+ if line.startswith("VmHWM:"):
74
+ return int(line.split()[1]) * 1024
75
+ except (OSError, ValueError):
76
+ pass
77
+ return None
78
+
79
+
80
+ # ---------------------------------------------------------------- one task, here or in a worker
81
+ def failure(task: str, stage: str, code: str, message: str, inp: str | None = None, obj: str | None = None) -> dict:
82
+ """A failures/1 entry."""
83
+ return {"task": task, "stage": stage, "input": inp, "object": obj, "code": code, "message": message}
84
+
85
+
86
+ def _error_code(e: BaseException) -> str:
87
+ from .contract import IncompatibleTask
88
+ from .store import InputMissing, StoreConflict
89
+ if isinstance(e, IncompatibleTask):
90
+ return "task.incompatible"
91
+ if isinstance(e, InputMissing):
92
+ return "task.input"
93
+ if isinstance(e, StoreConflict):
94
+ return "task.conflict"
95
+ if isinstance(e, MemoryError):
96
+ return "task.memory"
97
+ if isinstance(e, OSError):
98
+ return "task.io"
99
+ return "task.error"
100
+
101
+
102
+ def run_one(task: Task, store, stages: dict, force: bool) -> dict:
103
+ """Execute one task and measure it: the reply a worker sends (status, result status, cost, or error). The
104
+ cost's peak memory is the process's, with the resident memory at the task's start (what the process held from
105
+ its imports and earlier tasks) beside it."""
106
+ start = rss()
107
+ reset_peak()
108
+ try:
109
+ ex = execute(task, store, stages, force=force)
110
+ except ConfigError as e:
111
+ return {"ok": False, "code": "config", "message": str(e), "trace": "", "rss": rss()}
112
+ except Exception as e:
113
+ return {"ok": False, "code": _error_code(e), "message": f"{type(e).__name__}: {e}"[:2000],
114
+ "trace": "".join(traceback.format_exception(type(e), e, e.__traceback__))[-4000:], "rss": rss()}
115
+ reply = {"ok": True, "status": ex.status, "resultStatus": ex.result_status, "rss": rss()}
116
+ if ex.cost is not None:
117
+ cost = dict(ex.cost, peakRssBytes=peak_rss(), startRssBytes=start)
118
+ store.write_cost(task.key, cost)
119
+ reply["cost"] = cost
120
+ return reply
121
+
122
+
123
+ def _worker_main(conn, store, stages: dict, initializer, initargs) -> None:
124
+ if initializer is not None:
125
+ initializer(store, *initargs)
126
+ while True:
127
+ try:
128
+ msg = conn.recv()
129
+ except EOFError:
130
+ return
131
+ if msg is None:
132
+ return
133
+ doc, force = msg
134
+ try:
135
+ task = Task.from_json(doc)
136
+ except Exception as e:
137
+ conn.send({"ok": False, "code": "task.incompatible", "message": str(e), "trace": "", "rss": rss()})
138
+ continue
139
+ conn.send(run_one(task, store, stages, force))
140
+
141
+
142
+ class _Worker:
143
+ def __init__(self, ctx, n: int, store, stages, initializer, initargs):
144
+ self.n = n
145
+ self.conn, child = ctx.Pipe()
146
+ self.proc = ctx.Process(target=_worker_main, args=(child, store, stages, initializer, initargs),
147
+ name=f"nnnotes-worker-{n}", daemon=True)
148
+ self.proc.start()
149
+ child.close()
150
+ self.task: Task | None = None
151
+ self.done = 0
152
+
153
+ def send(self, task: Task, force: bool) -> None:
154
+ self.task = task
155
+ self.conn.send((task.to_json(), force))
156
+
157
+ def ask_stop(self) -> None:
158
+ try:
159
+ self.conn.send(None)
160
+ except OSError:
161
+ pass
162
+
163
+ def join(self, deadline: float) -> None:
164
+ """Wait for the process to end until `deadline` (time.monotonic()), then kill it."""
165
+ self.proc.join(max(0.0, deadline - time.monotonic()))
166
+ if self.proc.is_alive():
167
+ self.proc.kill()
168
+ self.proc.join()
169
+ self.conn.close()
170
+
171
+ def stop(self, wait_s: float = 10.0) -> None:
172
+ self.ask_stop()
173
+ self.join(time.monotonic() + wait_s)
174
+
175
+
176
+ # ---------------------------------------------------------------- scheduling
177
+ class Scheduler:
178
+ """The start order of a stage's tasks: decreasing estimated CPU (then id), admitted while the estimated peak
179
+ memory of the running tasks plus the next one fits `budget` (bytes; None: no limit). A task estimated above the
180
+ budget waits until nothing runs, then runs alone; no task behind it in the order starts before it."""
181
+
182
+ def __init__(self, tasks, budget: int | None):
183
+ self.queue = sorted(tasks, key=lambda t: (-_cpu(t), t.id))
184
+ self.budget = budget
185
+ self.running: dict[str, int] = {}
186
+ self.oversize: list[str] = []
187
+
188
+ def used(self) -> int:
189
+ return sum(self.running.values())
190
+
191
+ def next(self) -> Task | None:
192
+ used = self.used()
193
+ for i, t in enumerate(self.queue):
194
+ need = _peak(t)
195
+ if self.budget is None or used + need <= self.budget:
196
+ return self._take(i)
197
+ if need > self.budget:
198
+ if self.running:
199
+ return None
200
+ self.oversize.append(t.id)
201
+ return self._take(i)
202
+ return None
203
+
204
+ def _take(self, i: int) -> Task:
205
+ t = self.queue.pop(i)
206
+ self.running[t.id] = _peak(t)
207
+ return t
208
+
209
+ def finished(self, tid: str) -> None:
210
+ self.running.pop(tid, None)
211
+
212
+
213
+ def _cpu(t: Task) -> float:
214
+ return t.cost.cpu_seconds if t.cost is not None else 0.0
215
+
216
+
217
+ def _peak(t: Task) -> int:
218
+ return t.cost.peak_bytes if t.cost is not None else 0
219
+
220
+
221
+ # ---------------------------------------------------------------- the run
222
+ @dataclass
223
+ class Run:
224
+ """What an orchestrated run did: per task id its key and status (hit, ran, failed), the failures, the log.
225
+ The reports (summary, failures, coverage) come from one reading of every result (scan), kept while the tasks
226
+ stay the same."""
227
+ store: object
228
+ context: dict
229
+ selection: list
230
+ params: dict
231
+ stages: dict
232
+ tasks: dict = field(default_factory=dict) # task id -> {"id", "key", "status"}
233
+ failures_: list = field(default_factory=list)
234
+ log: list = field(default_factory=list)
235
+ oversize: list = field(default_factory=list)
236
+ _scan: tuple | None = field(default=None, init=False, repr=False, compare=False)
237
+
238
+ def results(self):
239
+ """(task id, result document) of every task with a result, by task id."""
240
+ for tid in sorted(self.tasks):
241
+ t = self.tasks[tid]
242
+ if t["status"] != "failed":
243
+ yield tid, self.store.result(t["key"])
244
+
245
+ def scan(self, visit=None, census=None) -> None:
246
+ """Read every result once, by task id, for the reports; `visit(task id, result)` sees each as well (what
247
+ else the caller needs of the results, without reading them again). With `census()` (called after the
248
+ reading: the census totals of the coverage report), the coverage document is made at once and the
249
+ per-object state it needs (about 0.2 GB for a full catalog) is not kept."""
250
+ digest = Digest()
251
+ for tid, doc in self.results():
252
+ digest.add(tid, doc)
253
+ if visit is not None:
254
+ visit(tid, doc)
255
+ if census is not None:
256
+ digest.settle(census())
257
+ self._scan = (self._tasks_now(), digest)
258
+
259
+ def _tasks_now(self) -> tuple:
260
+ return tuple((tid, t["key"], t["status"]) for tid, t in sorted(self.tasks.items()))
261
+
262
+ def _digest(self) -> Digest:
263
+ if self._scan is None or self._scan[0] != self._tasks_now():
264
+ self.scan()
265
+ return self._scan[1]
266
+
267
+ def failures(self) -> list[dict]:
268
+ """Task failures and failed items, sorted (task, object)."""
269
+ out = list(self.failures_) + self._digest().failed
270
+ return sorted(out, key=lambda f: (f["task"], f["object"] or "", f["code"]))
271
+
272
+ def summary(self) -> dict:
273
+ statuses = Counter(t["status"] for t in self.tasks.values())
274
+ d = self._digest()
275
+ return {"tasks": len(self.tasks), **{s: statuses.get(s, 0) for s in contract.RUN_TASK_STATUSES},
276
+ "items": {s: d.items.get(s, 0) for s in contract.ITEM_STATUSES}, "artifacts": d.artifacts}
277
+
278
+ @property
279
+ def exit_code(self) -> int:
280
+ """0 when every task and item succeeded (unsupported objects allowed), else 1."""
281
+ return 1 if self.failures() else 0
282
+
283
+ def manifest(self, layouts: dict | None = None, placement: dict | None = None) -> dict:
284
+ return contract.run_doc(context=self.context, selection=self.selection, params=self.params,
285
+ stages=self.stages, tasks=list(self.tasks.values()), layouts=layouts or {},
286
+ summary=self.summary(), placement=placement)
287
+
288
+ def write(self, layouts: dict | None = None, placement: dict | None = None) -> dict:
289
+ """Write the run manifest (layouts: {name: sha256 of its layout manifest}; placement: how their files were
290
+ made) and this execution's log."""
291
+ doc = self.manifest(layouts, placement)
292
+ self.store.write_run(doc, self.log)
293
+ return doc
294
+
295
+ def coverage(self, census: dict | None = None) -> dict:
296
+ d = self._digest()
297
+ if d.settled is not None and d.settled[0] != census:
298
+ self.scan(census=lambda: census)
299
+ d = self._digest()
300
+ return d.coverage(census)
301
+
302
+ def failures_doc(self) -> dict:
303
+ return {"schema": contract.FAILURES, "failures": self.failures()}
304
+
305
+
306
+ SEVERITY = ("failed", "unsupported", "generic", "exported", "contained") # most severe first
307
+ _SEVERITY = {s: i for i, s in enumerate(SEVERITY)}
308
+ _PATH_IDS = 1 << 64
309
+
310
+
311
+ class Digest:
312
+ """What the reports need of results, each read once and not kept: item and artifact counts, the failed items,
313
+ and per object its most severe item (coverage) as one small number, its severity plus 8 times the index of its
314
+ (class, reason code). A full catalog has about 2 M items; as dicts they take GBs."""
315
+
316
+ def __init__(self):
317
+ self.items: Counter = Counter()
318
+ self.artifacts = 0
319
+ self.failed: list[dict] = []
320
+ self.best: dict = {} # object key -> severity + 8 * kind
321
+ self.kinds: dict = {} # (class, reason code or None) -> kind
322
+ self.reasoned: dict = {} # object key -> object id, where the most severe item has a reason
323
+ self._files: dict = {} # serialized file -> number (object keys)
324
+ self.settled: tuple | None = None # (census, coverage document) once settle() dropped the object state
325
+
326
+ def _key(self, oid: str):
327
+ """An object id as a number (its serialized file's number and its pathId), smaller than the string; the
328
+ string itself when it is not <file>:<64-bit pathId> as contract.object_id writes it."""
329
+ file, _, pid = oid.rpartition(":")
330
+ try:
331
+ n = int(pid)
332
+ except ValueError:
333
+ return oid
334
+ if not file or not -_PATH_IDS // 2 <= n < _PATH_IDS // 2 or str(n) != pid:
335
+ return oid
336
+ return self._files.setdefault(file, len(self._files)) * _PATH_IDS + n % _PATH_IDS
337
+
338
+ def add(self, tid: str, doc: dict) -> None:
339
+ self.artifacts += len(doc.get("artifacts", ()))
340
+ best, kinds = self.best, self.kinds
341
+ for it in doc["items"]:
342
+ status = it["status"]
343
+ self.items[status] += 1
344
+ reason = it.get("reason")
345
+ if status == "failed":
346
+ self.failed.append(failure(tid, contract.parse_task_id(tid)[0], reason["code"], reason["message"],
347
+ obj=it["object"]))
348
+ sev, k = _SEVERITY[status], self._key(it["object"])
349
+ old = best.get(k)
350
+ if old is None or sev < old % 8:
351
+ code = reason["code"] if reason is not None else None
352
+ best[k] = sev + 8 * kinds.setdefault((it.get("class", "?"), code), len(kinds))
353
+ if code is None:
354
+ self.reasoned.pop(k, None)
355
+ else:
356
+ self.reasoned[k] = it["object"]
357
+
358
+ def settle(self, census: dict | None) -> None:
359
+ """Make the coverage document for `census` now and drop the per-object state (no object can be added)."""
360
+ self.settled = (census, self.coverage(census))
361
+ self.best = self.kinds = self.reasoned = self._files = None
362
+
363
+ def coverage(self, census: dict | None = None) -> dict:
364
+ """The coverage document (nnnotes.coverage/1): per class the count of every item status and the census
365
+ total (`census`: {class: objects}; default: the item count; `missing` = total - objects with an item); per
366
+ reason code its count and the first five object ids (sorted). An object with items in several results (a
367
+ sprite whose image sprite.crop makes) counts once, with the most severe of its statuses (SEVERITY) and
368
+ that item's reason (of the first such item)."""
369
+ if self.settled is not None:
370
+ if self.settled[0] != census:
371
+ raise ValueError("coverage of another census after settle()")
372
+ return self.settled[1]
373
+ kinds = list(self.kinds)
374
+ classes: dict[str, Counter] = {}
375
+ for v, n in Counter(self.best.values()).items():
376
+ classes.setdefault(kinds[v // 8][0], Counter())[SEVERITY[v % 8]] += n
377
+ reasons: dict[str, list] = {}
378
+ for k, oid in self.reasoned.items():
379
+ reasons.setdefault(kinds[self.best[k] // 8][1], []).append(oid)
380
+ out_classes = {}
381
+ for cls in sorted(set(classes) | set(census or {})):
382
+ c = classes.get(cls, Counter())
383
+ n = sum(c.values())
384
+ total = (census or {}).get(cls, n)
385
+ out_classes[cls] = {"total": total, "missing": total - n,
386
+ **{s: c.get(s, 0) for s in contract.ITEM_STATUSES}}
387
+ return {"schema": contract.COVERAGE, "classes": out_classes,
388
+ "reasons": {code: {"count": len(ids), "examples": sorted(ids)[:5]}
389
+ for code, ids in sorted(reasons.items())}}
390
+
391
+
392
+ def coverage(results, census: dict | None = None) -> dict:
393
+ """The coverage document (Digest.coverage) of results [(task id, result)]."""
394
+ d = Digest()
395
+ for tid, doc in results:
396
+ d.add(tid, doc)
397
+ return d.coverage(census)
398
+
399
+
400
+ class Orchestrator:
401
+ """Runs pipelines of stages into a store (see the module documentation). `initializer(store, *initargs)` runs
402
+ first in every worker (settings, caches, the store's fetcher); it must be importable (spawned processes)."""
403
+
404
+ def __init__(self, store, stages: dict[str, Stage], *, workers: int = 0, memory: int | None = None,
405
+ worker_bytes: int = WORKER_BYTES, recycle_tasks: int | None = None, recycle_rss: int | None = None,
406
+ initializer=None, initargs: tuple = (), log=_log):
407
+ self.store, self.stages = store, stages
408
+ self.workers, self.memory, self.worker_bytes = max(0, int(workers)), memory, int(worker_bytes)
409
+ self.recycle_tasks, self.recycle_rss = recycle_tasks, recycle_rss
410
+ self.initializer, self.initargs = initializer, tuple(initargs)
411
+ self.log = log
412
+ self._pool: list[_Worker] = []
413
+ self._started = 0
414
+
415
+ def explain(self) -> list[str]:
416
+ """Every knob of the run, with its value."""
417
+ gib = lambda b: "none" if b is None else f"{b / (1 << 30):.1f} GiB" # noqa: E731
418
+ return [f"workers: {self.workers or 'none (tasks run in this process)'}",
419
+ f"memory budget: {gib(self.memory)}, of which {gib(self.task_budget())} for the running tasks' "
420
+ f"estimated peaks ({gib(self.worker_bytes)} per worker process)",
421
+ "order: longest estimated CPU first",
422
+ f"worker recycling: after {self.recycle_tasks or 'unlimited'} tasks, "
423
+ f"above {gib(self.recycle_rss)} resident"
424
+ + ("" if rss() is not None else " (resident memory not measurable here: size limit inactive)")]
425
+
426
+ def task_budget(self) -> int | None:
427
+ """The memory the running tasks' estimates may sum to: the budget less what the worker processes hold."""
428
+ if self.memory is None:
429
+ return None
430
+ return max(0, self.memory - max(1, self.workers) * self.worker_bytes)
431
+
432
+ # ---------------------------------------------------------------- pipeline
433
+ def run(self, pipeline: list[str], *, params: dict | None = None, facts: dict | None = None,
434
+ context: dict | None = None, selection=(), force=()) -> Run:
435
+ """Run the stages named in `pipeline`, in order. `params`: {stage: parameters}; `facts`: the planning
436
+ facts; `context` / `selection`: recorded in the run manifest; `force`: task ids or stage names ("*": all)
437
+ run again even when their result is in the store (the new result must be identical)."""
438
+ params = params or {}
439
+ unknown = [n for n in pipeline if n not in self.stages]
440
+ if unknown:
441
+ raise ValueError(f"unknown stages: {', '.join(unknown)}")
442
+ for i, n in enumerate(pipeline):
443
+ late = [a for a in self.stages[n].after if a in pipeline and pipeline.index(a) > i]
444
+ if late:
445
+ raise ValueError(f"stage {n} runs before {', '.join(late)}, which it reads")
446
+ norm = {n: self.stages[n].normalize(params.get(n)) for n in pipeline}
447
+ run = Run(self.store, dict(context or {}), list(selection), norm,
448
+ {n: self.stages[n].version for n in pipeline})
449
+ env = Env(self.store, facts)
450
+ force = set(force)
451
+ for line in self.explain():
452
+ run.log.append({"event": "knob", "value": line})
453
+ try:
454
+ for name in pipeline:
455
+ self._stage(self.stages[name], norm[name], env, force, run)
456
+ except ConfigError as e:
457
+ e.run = run # what ran before the stop: its results stay valid
458
+ raise
459
+ finally:
460
+ self.close()
461
+ return run
462
+
463
+ def _stage(self, stage: Stage, params: dict, env: Env, force: set, run: Run) -> None:
464
+ t0 = time.perf_counter()
465
+ tasks: list[Task] = []
466
+ try:
467
+ subjects = stage.subjects(env)
468
+ except Pending as p:
469
+ self._fail(run, env, contract.task_id(stage.name, "*"), None, stage.name,
470
+ "upstream.failed" if p.failed else "upstream.missing", f"needs {p.task}: {p}")
471
+ return
472
+ for subject in subjects:
473
+ tid = contract.task_id(stage.name, subject)
474
+ try:
475
+ tasks.append(describe(stage, subject, params, env))
476
+ except Pending as p:
477
+ self._fail(run, env, tid, None, stage.name,
478
+ "upstream.failed" if p.failed else "upstream.missing", f"needs {p.task}: {p}")
479
+ except Exception as e:
480
+ self._fail(run, env, tid, None, stage.name, "task.describe", f"{type(e).__name__}: {e}"[:2000])
481
+ forced = "*" in force or stage.name in force
482
+ todo = []
483
+ for t in sorted(tasks, key=lambda t: t.id):
484
+ if not (forced or t.id in force) and self.store.has_result(t.key):
485
+ run.tasks[t.id] = {"id": t.id, "key": t.key, "status": "hit"}
486
+ env.done(t.id, t.key)
487
+ else:
488
+ todo.append(t)
489
+ replies = self._execute(todo, force=lambda t: forced or t.id in force, run=run)
490
+ stop = next((r for r in replies.values() if r.get("code") == "config"), None)
491
+ for t in todo:
492
+ reply = replies.get(t.id) or {"ok": False, "code": "config", "message": "not started: the run stopped"}
493
+ if reply["ok"]:
494
+ run.tasks[t.id] = {"id": t.id, "key": t.key, "status": "ran"}
495
+ env.done(t.id, t.key)
496
+ else:
497
+ self._fail(run, env, t.id, t.key, stage.name, reply["code"], reply["message"], _input_name(t))
498
+ n = Counter(run.tasks[t.id]["status"] for t in tasks)
499
+ self.log(f"{stage.name}: {len(tasks)} tasks, {n['hit']} hits, {n['ran']} ran, {n['failed']} failed "
500
+ f"({time.perf_counter() - t0:.1f} s)")
501
+ if stop is not None:
502
+ raise ConfigError(stop["message"])
503
+
504
+ @staticmethod
505
+ def _fail(run: Run, env: Env, tid: str, key, stage: str, code: str, message: str, inp=None) -> None:
506
+ run.tasks[tid] = {"id": tid, "key": key, "status": "failed"}
507
+ run.failures_.append(failure(tid, stage, code, message, inp))
508
+ env.waiting(tid, failed=True)
509
+
510
+ # ---------------------------------------------------------------- execution
511
+ def _execute(self, tasks: list[Task], force, run: Run) -> dict[str, dict]:
512
+ """Run `tasks`; {task id: reply}."""
513
+ if not tasks:
514
+ return {}
515
+ sched = Scheduler(tasks, self.task_budget())
516
+ replies: dict[str, dict] = {}
517
+ if self.workers == 0:
518
+ while True:
519
+ t = sched.next()
520
+ if t is None:
521
+ break
522
+ self._record(run, t, sched, None)
523
+ replies[t.id] = run_one(t, self.store, self.stages, force(t))
524
+ self._done(run, t, replies[t.id], None)
525
+ sched.finished(t.id)
526
+ if replies[t.id].get("code") == "config":
527
+ sched.queue.clear()
528
+ else:
529
+ self._pool_run(sched, force, replies, run)
530
+ run.oversize += sched.oversize
531
+ return replies
532
+
533
+ def _spawn(self) -> _Worker:
534
+ self._started += 1
535
+ return _Worker(mp.get_context("spawn"), self._started, self.store, self.stages, self.initializer,
536
+ self.initargs)
537
+
538
+ def _pool_run(self, sched: Scheduler, force, replies: dict, run: Run) -> None:
539
+ while len(self._pool) < min(self.workers, len(sched.queue)): # no more processes than tasks
540
+ self._pool.append(self._spawn())
541
+ try:
542
+ while sched.queue or sched.running:
543
+ for i in range(len(self._pool)):
544
+ if self._pool[i].task is None:
545
+ t = sched.next()
546
+ if t is None:
547
+ break
548
+ if not self._pool[i].proc.is_alive(): # died while idle
549
+ self._replace(self._pool[i])
550
+ w = self._pool[i]
551
+ self._record(run, t, sched, w)
552
+ w.send(t, force(t))
553
+ busy = [w for w in self._pool if w.task is not None]
554
+ ready = wait([w.conn for w in busy] + [w.proc.sentinel for w in busy])
555
+ for w in busy:
556
+ if w.conn not in ready and w.proc.sentinel not in ready:
557
+ continue
558
+ t = w.task
559
+ reply = None
560
+ try:
561
+ if w.conn.poll():
562
+ reply = w.conn.recv()
563
+ except (EOFError, OSError):
564
+ reply = None
565
+ if reply is None:
566
+ w.proc.join(5)
567
+ reply = {"ok": False, "code": "task.worker_died",
568
+ "message": f"worker exited with code {w.proc.exitcode} while running the task",
569
+ "trace": "", "rss": None}
570
+ replies[t.id] = reply
571
+ self._done(run, t, reply, w)
572
+ sched.finished(t.id)
573
+ if reply.get("code") == "config":
574
+ sched.queue.clear()
575
+ w.task = None
576
+ w.done += 1
577
+ if not w.proc.is_alive() or reply.get("code") == "task.worker_died" or self._recycle(w, reply):
578
+ self._replace(w)
579
+ except BaseException:
580
+ self.close(kill=True)
581
+ raise
582
+
583
+ def _recycle(self, w: _Worker, reply: dict) -> bool:
584
+ if self.recycle_tasks and w.done >= self.recycle_tasks:
585
+ return True
586
+ r = reply.get("rss")
587
+ return bool(self.recycle_rss and r is not None and r > self.recycle_rss)
588
+
589
+ def _replace(self, w: _Worker) -> None:
590
+ if w.proc.is_alive():
591
+ w.stop()
592
+ else:
593
+ w.proc.join(5)
594
+ w.conn.close()
595
+ self._pool[self._pool.index(w)] = self._spawn()
596
+
597
+ def _record(self, run: Run, t: Task, sched: Scheduler, w) -> None:
598
+ run.log.append({"event": "start", "task": t.id, "key": t.key, "worker": None if w is None else w.n,
599
+ "estimate": t.cost.to_json() if t.cost is not None else None,
600
+ "runningBytes": sched.used() - _peak(t), "budget": sched.budget})
601
+
602
+ def _done(self, run: Run, t: Task, reply: dict, w) -> None:
603
+ e = {"event": "end", "task": t.id, "worker": None if w is None else w.n, "ok": reply["ok"],
604
+ "rss": reply.get("rss")}
605
+ if reply["ok"]:
606
+ e.update(status=reply["status"], cost=reply.get("cost"))
607
+ else:
608
+ e.update(code=reply["code"], message=reply["message"], trace=reply.get("trace", ""))
609
+ self.log(f"{t.id}: failed: {reply['message'][:300]}")
610
+ run.log.append(e)
611
+
612
+ def close(self, kill: bool = False) -> None:
613
+ """Stop the worker processes (all asked first, then waited for: they end in parallel)."""
614
+ pool, self._pool = self._pool, []
615
+ if kill:
616
+ for w in pool:
617
+ w.proc.kill()
618
+ else:
619
+ for w in pool:
620
+ w.ask_stop()
621
+ deadline = time.monotonic() + 10.0
622
+ for w in pool:
623
+ w.join(deadline)
624
+
625
+
626
+ def _input_name(t: Task) -> str | None:
627
+ if not t.inputs:
628
+ return None
629
+ i = t.inputs[0]
630
+ return i.name or i.sha256