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.
- nnnotes/__init__.py +7 -0
- nnnotes/__main__.py +4 -0
- nnnotes/acb.py +190 -0
- nnnotes/addressables.py +413 -0
- nnnotes/adv.py +243 -0
- nnnotes/advcommand.py +29 -0
- nnnotes/advmedia.py +178 -0
- nnnotes/advscene.py +113 -0
- nnnotes/advui.py +1154 -0
- nnnotes/advvideo.py +684 -0
- nnnotes/atoms/__init__.py +151 -0
- nnnotes/atoms/astc.py +52 -0
- nnnotes/atoms/gltf.py +290 -0
- nnnotes/atoms/mesh.py +289 -0
- nnnotes/atoms/png.py +28 -0
- nnnotes/atoms/reader.py +269 -0
- nnnotes/atoms/sniff.py +198 -0
- nnnotes/atoms/sprite.py +236 -0
- nnnotes/atoms/texture.py +80 -0
- nnnotes/cache.py +289 -0
- nnnotes/catalog.py +373 -0
- nnnotes/catalogdb.py +457 -0
- nnnotes/census.py +276 -0
- nnnotes/cli.py +744 -0
- nnnotes/cli_assets.py +1539 -0
- nnnotes/config.py +238 -0
- nnnotes/contract.py +497 -0
- nnnotes/cri.py +371 -0
- nnnotes/crikey.py +71 -0
- nnnotes/crilips.py +265 -0
- nnnotes/cristages.py +558 -0
- nnnotes/deckdata.py +462 -0
- nnnotes/export.py +1462 -0
- nnnotes/gameapi.py +318 -0
- nnnotes/jsonio.py +98 -0
- nnnotes/languages.py +44 -0
- nnnotes/layout.py +596 -0
- nnnotes/link.py +418 -0
- nnnotes/live.py +115 -0
- nnnotes/live2d.py +235 -0
- nnnotes/liveaudio.py +331 -0
- nnnotes/livenotes.py +291 -0
- nnnotes/liveoptions.py +250 -0
- nnnotes/livescene.py +450 -0
- nnnotes/liveui.py +328 -0
- nnnotes/master.py +251 -0
- nnnotes/modelstages.py +424 -0
- nnnotes/motion.py +131 -0
- nnnotes/objexport.py +1564 -0
- nnnotes/orchestrate.py +630 -0
- nnnotes/plan.py +218 -0
- nnnotes/player.py +392 -0
- nnnotes/room.py +391 -0
- nnnotes/score.py +1139 -0
- nnnotes/shader.py +217 -0
- nnnotes/spot.py +201 -0
- nnnotes/stages.py +285 -0
- nnnotes/store.py +518 -0
- nnnotes/story.py +142 -0
- nnnotes/storyfonts.py +1258 -0
- nnnotes/storyhost.py +1190 -0
- nnnotes/storysite.py +662 -0
- nnnotes/storyvoices.py +102 -0
- nnnotes/textstyle.py +355 -0
- nnnotes/tmpfont.py +636 -0
- nnnotes/typetrees/6000.3.12f1.json +19 -0
- nnnotes/unity.py +368 -0
- nnnotes/viewrules.json +441 -0
- nnnotes/views.py +966 -0
- nnnotes/voices.py +1363 -0
- nnnotes/web.py +1624 -0
- nnnotes/webaudio.py +14 -0
- nnnotes/webmodel.py +397 -0
- nnnotes-0.1.0.dist-info/METADATA +200 -0
- nnnotes-0.1.0.dist-info/RECORD +79 -0
- nnnotes-0.1.0.dist-info/WHEEL +5 -0
- nnnotes-0.1.0.dist-info/entry_points.txt +2 -0
- nnnotes-0.1.0.dist-info/licenses/LICENSE +21 -0
- 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"]))}
|