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/contract.py ADDED
@@ -0,0 +1,497 @@
1
+ """The contract of the asset stages: canonical JSON, identities, keys and the documents stages exchange.
2
+
3
+ Everything here is a public format (docs/contracts.md, JSON Schemas in docs/schema/):
4
+
5
+ canon/1 UTF-8, LF, one trailing newline, non-ASCII as is. Contract documents: keys sorted, indent 1.
6
+ Object documents (typetree JSON): the object's own field order, indent 1. Non-finite floats as
7
+ `1e999` / `-1e999` (jsonio), NaN as {"$float": "nan"}.
8
+ content id sha256 of the bytes (hex).
9
+ key hash sha256 of the compact canonical text (ASCII only, keys sorted) of a JSON value.
10
+ object id "<serialized file>:<pathId>" (CAB-2f1e...:-4153...; built-in files keep their name:
11
+ "unity default resources:10"). A bundle's serialized file name changes with its content, so an
12
+ object id names one content version; stable_address names the object across versions.
13
+ artifact id "<object id>#<role>" for an object's artifact, "<stage>:<subject>#<role>" for any other.
14
+ task id "<stage>:<subject>"; bundle stages use the stable bundle name as subject, so a task keeps its id
15
+ across catalog versions while its key follows the content.
16
+
17
+ A task is one stage applied to one subject. Its key covers exactly what its output depends on: the stage name and
18
+ version, the subject (it names the task's artifacts of no object), the output-affecting parameters (defaults
19
+ filled), the implementation ids of the atoms it uses, the roles and content ids of its inputs, and the context
20
+ entries it consumes. Input names, locators and the cost estimate never enter it, so the same subject with the same
21
+ content in another catalog version or region is the same task. A stage whose output depends on anything else puts
22
+ it into its parameters or context.
23
+
24
+ The result of a task (its action-cache entry) is therefore a function of its key alone: it holds no input name,
25
+ locator, timestamp or traceback (input names are in the task description; region, language and catalog and master
26
+ versions in the run manifest), so identical bundles of two regions or catalog versions share one result.
27
+ """
28
+ from __future__ import annotations
29
+
30
+ import hashlib
31
+ import json
32
+ import re
33
+ from dataclasses import dataclass, field
34
+ from functools import cached_property
35
+
36
+ from . import jsonio
37
+
38
+ CANON = "canon/1"
39
+ TASK = "nnnotes.task/1"
40
+ ARTIFACT = "nnnotes.artifact/1"
41
+ RESULT = "nnnotes.result/1"
42
+ RUN = "nnnotes.run/1"
43
+ LAYOUT = "nnnotes.layout/1"
44
+ COVERAGE = "nnnotes.coverage/1"
45
+ FAILURES = "nnnotes.failures/1"
46
+ PLAN = "nnnotes.plan/1"
47
+ COST = "nnnotes.cost/1"
48
+ CATALOG_INDEX = "nnnotes.catalog-index/1"
49
+ CENSUS = "nnnotes.census/1"
50
+ VIEW = "nnnotes.view/1"
51
+
52
+ NAN = {"$float": "nan"}
53
+ # item statuses of a result: every object a task covers has exactly one
54
+ ITEM_STATUSES = ("exported", "contained", "generic", "unsupported", "failed")
55
+ RESULT_STATUSES = ("ok", "partial")
56
+ RUN_TASK_STATUSES = ("hit", "ran", "failed")
57
+ # locator kinds of a task input: where the bytes can be read (never part of the key)
58
+ LOCATOR_KINDS = ("store", "cache", "file", "catalog")
59
+ # an object's primary artifact is the one whose role comes first here; its other artifacts are secondary
60
+ PRIMARY_ROLES = ("image", "mesh", "audio", "video", "font", "data", "prefab", "json")
61
+ MEDIA_TYPES = {
62
+ "png": "image/png", "astc": "image/astc", "jpg": "image/jpeg", "json": "application/json",
63
+ "glb": "model/gltf-binary", "flac": "audio/flac", "wav": "audio/wav", "ogg": "audio/ogg", "mkv": "video/x-matroska",
64
+ "ivf": "video/x-ivf", "adx": "audio/x-adx", "ttf": "font/ttf", "otf": "font/otf", "ttc": "font/collection",
65
+ "woff": "font/woff", "woff2": "font/woff2", "txt": "text/plain", "xml": "application/xml", "csv": "text/csv",
66
+ "bin": "application/octet-stream", "bytes": "application/octet-stream",
67
+ }
68
+ _SHA256 = re.compile(r"[0-9a-f]{64}")
69
+ _EXT = re.compile(r"[0-9a-z]+")
70
+ _BUNDLE_SUFFIX = re.compile(r"(_[0-9a-f]{32})?\.bundle$")
71
+
72
+
73
+ # ---------------------------------------------------------------- canonical JSON
74
+ def _tag_nan(v):
75
+ """`v` with every NaN replaced by {"$float": "nan"}; containers are copied only where something changed."""
76
+ if isinstance(v, float):
77
+ return dict(NAN) if v != v else v
78
+ if isinstance(v, dict):
79
+ out = None
80
+ for k, x in v.items():
81
+ y = _tag_nan(x)
82
+ if y is not x:
83
+ if out is None:
84
+ out = dict(v)
85
+ out[k] = y
86
+ return v if out is None else out
87
+ if isinstance(v, (list, tuple)):
88
+ out = None
89
+ for i, x in enumerate(v):
90
+ y = _tag_nan(x)
91
+ if y is not x:
92
+ if out is None:
93
+ out = list(v)
94
+ out[i] = y
95
+ return v if out is None else out
96
+ return v
97
+
98
+
99
+ def _text(obj, **kw) -> str:
100
+ try:
101
+ return jsonio.dumps(obj, **kw)
102
+ except ValueError:
103
+ tagged = _tag_nan(obj)
104
+ if tagged is obj:
105
+ raise # not a NaN: the writer's own error
106
+ return jsonio.dumps(tagged, **kw)
107
+
108
+
109
+ def dumps(obj, *, sort_keys: bool = True) -> str:
110
+ """The canon/1 text of a document: keys sorted (`sort_keys=False` keeps the document's order, for object
111
+ documents), indent 1, non-ASCII as is, one trailing newline."""
112
+ return _text(obj, indent=1, ensure_ascii=False, sort_keys=sort_keys) + "\n"
113
+
114
+
115
+ def encode(obj, *, sort_keys: bool = True) -> bytes:
116
+ """dumps() as UTF-8 bytes."""
117
+ return dumps(obj, sort_keys=sort_keys).encode("utf-8")
118
+
119
+
120
+ def encode_chunks(obj, size: int = 1 << 20):
121
+ """encode(obj) as byte strings of about `size` bytes, made as they are written (a large document's text is
122
+ never whole in memory, nor the encoder's list of its pieces). Raises ValueError, possibly after some chunks,
123
+ for a document with a non-finite number: encode() writes those."""
124
+ enc = json.JSONEncoder(indent=1, ensure_ascii=False, sort_keys=True, allow_nan=False)
125
+ buf, n = [], 0
126
+ for piece in enc.iterencode(obj):
127
+ buf.append(piece)
128
+ n += len(piece)
129
+ if n >= size:
130
+ yield "".join(buf).encode("utf-8")
131
+ buf, n = [], 0
132
+ buf.append("\n")
133
+ yield "".join(buf).encode("utf-8")
134
+
135
+
136
+ def _untag(d: dict):
137
+ return float("nan") if d == NAN else d
138
+
139
+
140
+ def loads(text):
141
+ """A canon/1 (or any JSON) document: {"$float": "nan"} reads back as NaN, 1e999 as infinity."""
142
+ if isinstance(text, (bytes, bytearray)):
143
+ text = bytes(text).decode("utf-8")
144
+ return json.loads(text, object_hook=_untag)
145
+
146
+
147
+ def key_text(value) -> str:
148
+ """The compact canonical text a key hash is taken of: keys sorted, ASCII only, no whitespace."""
149
+ return _text(value, separators=(",", ":"), ensure_ascii=True, sort_keys=True)
150
+
151
+
152
+ def digest(value) -> str:
153
+ """The key hash of a JSON value (sha256 hex of key_text)."""
154
+ return hashlib.sha256(key_text(value).encode("ascii")).hexdigest()
155
+
156
+
157
+ def sha256(data) -> str:
158
+ """The content id of bytes."""
159
+ return hashlib.sha256(data).hexdigest()
160
+
161
+
162
+ def is_sha256(s) -> bool:
163
+ return isinstance(s, str) and _SHA256.fullmatch(s) is not None
164
+
165
+
166
+ # ---------------------------------------------------------------- identities
167
+ def object_id(file: str, path_id: int) -> str:
168
+ return f"{file}:{int(path_id)}"
169
+
170
+
171
+ def parse_object_id(oid: str) -> tuple[str, int]:
172
+ """(serialized file, pathId) of an object id."""
173
+ file, sep, pid = oid.rpartition(":")
174
+ if not sep or not file:
175
+ raise ValueError(f"not an object id: {oid!r}")
176
+ return file, int(pid)
177
+
178
+
179
+ def artifact_id(owner: str, role: str) -> str:
180
+ """The id of the artifact `role` of `owner` (an object id, or a task id for artifacts of no object)."""
181
+ if "#" in owner:
182
+ raise ValueError(f"artifact owner {owner!r} contains '#'")
183
+ if not role:
184
+ raise ValueError("empty artifact role")
185
+ return f"{owner}#{role}"
186
+
187
+
188
+ def parse_artifact_id(aid: str) -> tuple[str, str]:
189
+ """(owner, role): the owner never contains '#', the role may."""
190
+ owner, sep, role = aid.partition("#")
191
+ if not sep or not owner or not role:
192
+ raise ValueError(f"not an artifact id: {aid!r}")
193
+ return owner, role
194
+
195
+
196
+ def task_id(stage: str, subject: str) -> str:
197
+ return f"{stage}:{subject}"
198
+
199
+
200
+ def parse_task_id(tid: str, stage: str | None = None) -> tuple[str, str]:
201
+ """(stage, subject). Stage names contain no ':'; subjects may."""
202
+ if stage is not None:
203
+ if not tid.startswith(stage + ":"):
204
+ raise ValueError(f"task id {tid!r} is not of stage {stage!r}")
205
+ return stage, tid[len(stage) + 1:]
206
+ name, sep, subject = tid.partition(":")
207
+ if not sep or not name:
208
+ raise ValueError(f"not a task id: {tid!r}")
209
+ return name, subject
210
+
211
+
212
+ def stable_bundle_name(name: str) -> str:
213
+ """A bundle's file name without its content hash and `.bundle` (membercard_x_3fa...c2.bundle -> membercard_x):
214
+ the same across catalog versions while the content changes."""
215
+ return _BUNDLE_SUFFIX.sub("", name)
216
+
217
+
218
+ def stable_bundle_names(names) -> tuple[dict[str, str], list[list[str]]]:
219
+ """{file name: stable name} for a catalog's bundles, and the collisions: names whose stable names coincide keep
220
+ their full file name (each collision group sorted, groups sorted)."""
221
+ groups: dict[str, list[str]] = {}
222
+ for n in sorted(set(names)):
223
+ groups.setdefault(stable_bundle_name(n), []).append(n)
224
+ out, collisions = {}, []
225
+ for stable, ns in groups.items():
226
+ if len(ns) == 1:
227
+ out[ns[0]] = stable
228
+ else:
229
+ collisions.append(ns)
230
+ out.update((n, n) for n in ns)
231
+ return out, sorted(collisions)
232
+
233
+
234
+ def stable_address(bundle: str, container: str | None = None, path_id: int | None = None,
235
+ name: str | None = None) -> str:
236
+ """Where an object is found again in another catalog version. A bundle's serialized file names (and so its
237
+ object and artifact ids) change with its content while path ids stay; the address uses what stays: the stable
238
+ bundle name and the container path ("<bundle>/<container>", "[<name>]" appended for a sub-object of the
239
+ container), else the path id ("<bundle>:<pathId>")."""
240
+ if container:
241
+ return f"{bundle}/{container}" + (f"[{name}]" if name else "")
242
+ if path_id is None:
243
+ raise ValueError("an object without a container is addressed by its path id")
244
+ return f"{bundle}:{int(path_id)}"
245
+
246
+
247
+ def media_type(ext: str) -> str:
248
+ return MEDIA_TYPES.get(ext.lower(), "application/octet-stream")
249
+
250
+
251
+ # ---------------------------------------------------------------- tasks
252
+ @dataclass(frozen=True)
253
+ class Cost:
254
+ """An estimate (or measurement) of a task: CPU seconds and peak memory in bytes."""
255
+ cpu_seconds: float = 0.0
256
+ peak_bytes: int = 0
257
+
258
+ def to_json(self) -> dict:
259
+ return {"cpuSeconds": round(float(self.cpu_seconds), 3), "peakBytes": int(self.peak_bytes)}
260
+
261
+ @classmethod
262
+ def from_json(cls, d: dict | None) -> "Cost | None":
263
+ return None if d is None else cls(float(d.get("cpuSeconds", 0.0)), int(d.get("peakBytes", 0)))
264
+
265
+
266
+ @dataclass(frozen=True)
267
+ class Input:
268
+ """One input of a task: its role, content id and size; `name` and `locators` (where to read it) are
269
+ information for running it, not part of the key."""
270
+ role: str
271
+ sha256: str
272
+ size: int
273
+ name: str | None = None
274
+ locators: tuple = ()
275
+
276
+ def __post_init__(self):
277
+ if not is_sha256(self.sha256):
278
+ raise ValueError(f"input {self.role!r}: not a sha256: {self.sha256!r}")
279
+ for loc in self.locators:
280
+ if loc.get("kind") not in LOCATOR_KINDS:
281
+ raise ValueError(f"input {self.role!r}: unknown locator {loc!r}")
282
+
283
+ def to_json(self) -> dict:
284
+ d = {"role": self.role, "sha256": self.sha256, "size": int(self.size)}
285
+ if self.name is not None:
286
+ d["name"] = self.name
287
+ d["locators"] = [dict(loc) for loc in self.locators]
288
+ return d
289
+
290
+ @classmethod
291
+ def from_json(cls, d: dict) -> "Input":
292
+ return cls(d["role"], d["sha256"], int(d["size"]), d.get("name"),
293
+ tuple(dict(loc) for loc in d.get("locators", ())))
294
+
295
+
296
+ class IncompatibleTask(ValueError):
297
+ """A task description this installation cannot run as described (key mismatch, unknown stage or version)."""
298
+
299
+
300
+ @dataclass(frozen=True)
301
+ class Task:
302
+ """One stage applied to one subject (nnnotes.task/1)."""
303
+ stage: str
304
+ version: int
305
+ subject: str
306
+ params: dict = field(default_factory=dict)
307
+ atoms: dict = field(default_factory=dict)
308
+ inputs: tuple = ()
309
+ context: dict = field(default_factory=dict)
310
+ cost: Cost | None = None
311
+
312
+ def __post_init__(self):
313
+ if not self.stage or ":" in self.stage or "#" in self.stage:
314
+ raise ValueError(f"bad stage name {self.stage!r}")
315
+ if "#" in self.subject:
316
+ raise ValueError(f"task subject {self.subject!r} contains '#'")
317
+ ins = tuple(sorted(self.inputs, key=lambda i: (i.role, i.sha256)))
318
+ roles = [i.role for i in ins]
319
+ if len(set(roles)) != len(roles):
320
+ raise ValueError(f"task {self.id}: duplicate input roles")
321
+ object.__setattr__(self, "inputs", ins)
322
+
323
+ @property
324
+ def id(self) -> str:
325
+ return task_id(self.stage, self.subject)
326
+
327
+ def key_parts(self) -> dict:
328
+ """What the key is the hash of."""
329
+ return {"stage": {"name": self.stage, "version": self.version}, "subject": self.subject,
330
+ "params": self.params, "atoms": self.atoms, "inputs": [[i.role, i.sha256] for i in self.inputs],
331
+ "context": self.context}
332
+
333
+ @cached_property
334
+ def key(self) -> str:
335
+ return digest(self.key_parts())
336
+
337
+ def input(self, role: str) -> Input:
338
+ for i in self.inputs:
339
+ if i.role == role:
340
+ return i
341
+ raise KeyError(f"task {self.id} has no input {role!r}")
342
+
343
+ def params_digest(self) -> str:
344
+ return digest(self.params)
345
+
346
+ def to_json(self) -> dict:
347
+ d = {"schema": TASK, "id": self.id, "stage": {"name": self.stage, "version": self.version},
348
+ "params": self.params, "atoms": self.atoms, "inputs": [i.to_json() for i in self.inputs],
349
+ "context": self.context, "key": self.key}
350
+ if self.cost is not None:
351
+ d["cost"] = self.cost.to_json()
352
+ return d
353
+
354
+ @classmethod
355
+ def from_json(cls, doc: dict) -> "Task":
356
+ """The task a description holds; raises IncompatibleTask when it is not a task/1 document or its key is
357
+ not the key of its parts (it was written by another version or edited)."""
358
+ if not isinstance(doc, dict) or doc.get("schema") != TASK:
359
+ raise IncompatibleTask(f"not a {TASK} document")
360
+ try:
361
+ name, version = doc["stage"]["name"], int(doc["stage"]["version"])
362
+ _, subject = parse_task_id(doc["id"], name)
363
+ t = cls(name, version, subject, doc.get("params", {}), doc.get("atoms", {}),
364
+ tuple(Input.from_json(i) for i in doc.get("inputs", ())), doc.get("context", {}),
365
+ Cost.from_json(doc.get("cost")))
366
+ except (KeyError, TypeError, ValueError) as e:
367
+ raise IncompatibleTask(f"malformed task: {e}") from None
368
+ if doc.get("key") != t.key:
369
+ raise IncompatibleTask(f"task {t.id}: key {doc.get('key')} is not the key of its parts ({t.key})")
370
+ return t
371
+
372
+
373
+ # ---------------------------------------------------------------- artifacts and results
374
+ def content(data: bytes, ext: str, media: str | None = None) -> dict:
375
+ """The content part of an artifact record for `data`."""
376
+ ext = ext.lower().lstrip(".")
377
+ return {"sha256": sha256(data), "size": len(data), "mediaType": media or media_type(ext), "ext": ext}
378
+
379
+
380
+ def provenance(task: Task, *, atoms: dict | None = None, obj: dict | None = None) -> dict:
381
+ """The provenance of an artifact a task produced: its stage (with the digest of the parameters), the atoms that
382
+ made it (default: all the task's atoms), the roles and content ids of the task's inputs, and the object facts."""
383
+ p = {"stage": {"name": task.stage, "version": task.version, "params": task.params_digest()},
384
+ "atoms": dict(task.atoms if atoms is None else atoms),
385
+ "inputs": [{"role": i.role, "sha256": i.sha256} for i in task.inputs]}
386
+ if obj is not None:
387
+ p["object"] = obj
388
+ return p
389
+
390
+
391
+ def artifact(aid: str, content_: dict, provenance_: dict, semantics: dict) -> dict:
392
+ """An artifact record (nnnotes.artifact/1)."""
393
+ parse_artifact_id(aid)
394
+ for k in ("sha256", "size", "mediaType", "ext"):
395
+ if k not in content_:
396
+ raise ValueError(f"artifact {aid}: content without {k}")
397
+ if not _EXT.fullmatch(content_["ext"]):
398
+ raise ValueError(f"artifact {aid}: extension {content_['ext']!r} is not lower-case letters and digits")
399
+ if "kind" not in semantics:
400
+ raise ValueError(f"artifact {aid}: semantics without kind")
401
+ return {"id": aid, "content": content_, "provenance": provenance_, "semantics": semantics}
402
+
403
+
404
+ def reason(code: str, message: str = "") -> dict:
405
+ """Why an object is generic, unsupported or failed: a documented code and a deterministic message."""
406
+ if not code:
407
+ raise ValueError("empty reason code")
408
+ return {"code": code, "message": message}
409
+
410
+
411
+ def item(obj: str, status: str, *, artifacts=(), within: str | None = None, why: dict | None = None,
412
+ cls: str | None = None) -> dict:
413
+ """The outcome for one object of a task. exported: its artifacts; contained: `within` (the artifact it is part
414
+ of); generic: its artifacts and why the specialized form was not made; unsupported: why (and artifacts, if
415
+ any); failed: why."""
416
+ if status not in ITEM_STATUSES:
417
+ raise ValueError(f"item {obj}: unknown status {status!r}")
418
+ need_art = status in ("exported", "generic")
419
+ need_why = status in ("generic", "unsupported", "failed")
420
+ if need_art and not artifacts:
421
+ raise ValueError(f"item {obj}: {status} without artifacts")
422
+ if need_why != (why is not None):
423
+ raise ValueError(f"item {obj}: {status} {'needs' if need_why else 'takes no'} reason")
424
+ if (status == "contained") != (within is not None):
425
+ raise ValueError(f"item {obj}: `in` is for contained items only")
426
+ if status in ("contained", "failed") and artifacts:
427
+ raise ValueError(f"item {obj}: {status} with artifacts")
428
+ d = {"object": obj, "status": status}
429
+ if cls is not None:
430
+ d["class"] = cls
431
+ if artifacts:
432
+ d["artifacts"] = sorted(artifacts)
433
+ if within is not None:
434
+ d["in"] = within
435
+ if why is not None:
436
+ d["reason"] = why
437
+ return d
438
+
439
+
440
+ def result(task: Task, artifacts, items) -> dict:
441
+ """The result document (nnnotes.result/1) of a task: the commit point of its run. Artifacts sorted by id, items
442
+ by object; status `partial` when an item failed."""
443
+ arts = sorted(artifacts, key=lambda a: a["id"])
444
+ ids = [a["id"] for a in arts]
445
+ if len(set(ids)) != len(ids):
446
+ raise ValueError(f"task {task.id}: duplicate artifact ids")
447
+ its = sorted(items, key=lambda i: i["object"])
448
+ objs = [i["object"] for i in its]
449
+ if len(set(objs)) != len(objs):
450
+ raise ValueError(f"task {task.id}: more than one item for an object")
451
+ known = set(ids)
452
+ for i in its:
453
+ for a in i.get("artifacts", ()):
454
+ if a not in known:
455
+ raise ValueError(f"task {task.id}: item {i['object']} names unknown artifact {a}")
456
+ status = "partial" if any(i["status"] == "failed" for i in its) else "ok"
457
+ return {"schema": RESULT, "key": task.key, "keyParts": task.key_parts(), "status": status,
458
+ "artifacts": arts, "items": its}
459
+
460
+
461
+ def primary_artifact(records: list[dict]) -> dict:
462
+ """The primary artifact of one object's records (PRIMARY_ROLES order, then role name)."""
463
+ def rank(r):
464
+ role = parse_artifact_id(r["id"])[1]
465
+ return (PRIMARY_ROLES.index(role) if role in PRIMARY_ROLES else len(PRIMARY_ROLES), role)
466
+ return min(records, key=rank)
467
+
468
+
469
+ # ---------------------------------------------------------------- runs and layouts
470
+ def run_id(doc: dict) -> str:
471
+ """A run's id: the hash of its deterministic parts (context, selection, parameters, stage versions, the tasks'
472
+ ids and keys, layout manifests); statuses and the summary are not part of it."""
473
+ return digest({"context": doc.get("context", {}), "selection": doc.get("selection", []),
474
+ "params": doc.get("params", {}), "stages": doc.get("stages", {}),
475
+ "tasks": [[t["id"], t["key"]] for t in doc.get("tasks", [])],
476
+ "layouts": doc.get("layouts", {})})
477
+
478
+
479
+ def run_doc(*, context: dict, selection: list, params: dict, stages: dict, tasks: list[dict], layouts: dict,
480
+ summary: dict, placement: dict | None = None) -> dict:
481
+ """A run manifest (nnnotes.run/1): tasks sorted by id; `placement` ({method, reason}: how the layouts' files
482
+ were made) when layouts were written. The id covers neither the summary nor the placement."""
483
+ doc = {"schema": RUN, "context": context, "selection": list(selection), "params": params, "stages": stages,
484
+ "tasks": sorted(tasks, key=lambda t: t["id"]), "layouts": layouts, "summary": summary}
485
+ if placement is not None:
486
+ doc["placement"] = placement
487
+ for t in doc["tasks"]:
488
+ if t["status"] not in RUN_TASK_STATUSES:
489
+ raise ValueError(f"run task {t['id']}: unknown status {t['status']!r}")
490
+ doc["id"] = run_id(doc)
491
+ return doc
492
+
493
+
494
+ def layout_doc(name: str, params: dict, entries: list[dict]) -> dict:
495
+ """A layout manifest (nnnotes.layout/1): entries sorted by path, then id."""
496
+ return {"schema": LAYOUT, "name": name, "params": params,
497
+ "entries": sorted(entries, key=lambda e: (e["path"], e["id"]))}