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/catalogdb.py ADDED
@@ -0,0 +1,457 @@
1
+ """Catalog versions: the catalog index, a store of catalog versions, and the difference between two versions.
2
+
3
+ The catalog index (nnnotes.catalog-index/1) is everything a catalog pair says, decoded (addressables.parse_locations):
4
+ the remote catalog and, when given, the APK's local catalog. It holds every location (primary key, internal id,
5
+ provider, resource type, dependencies, extra data), the bundle and raw files the locations name with their
6
+ AssetBundleRequestOptions (content hash = the file name's `_<32 hex>`, CRC of the decompressed bundle data, size,
7
+ internal bundle name, flags), and the header facts of each catalog. Location ids are "<catalog>:<offset>" with
8
+ catalog "remote" or "apk". A file named by both catalogs takes the APK catalog's location (the remote catalog's
9
+ references to APK files may come from another build). The index is a function of the catalog bytes alone.
10
+
11
+ A catalog version is identified by the sha256 of its remote catalog and of its APK catalog (none without an APK).
12
+ The versions live in the store:
13
+
14
+ <store>/catalogs/<sha256>.bin each catalog file (remote and APK catalogs alike), by content
15
+ <store>/catalogs/index.json the versions (nnnotes.catalogs/1): id, labels, region, language, the two
16
+ catalogs (sha256, size, buildResultHash), the CDN's `.hash` text when it was
17
+ served (an opaque change signal), resource version, APK version name
18
+
19
+ `diff` compares two indexes: keys (the primary keys of asset locations: internal id, provider, resource type and
20
+ dependencies, bundles and raw files named by their stable names), bundles and raw files matched by stable name (the
21
+ name without its content hash: added, removed, changed with old and new name, size, hash, CRC) and the keys whose
22
+ dependency closure reaches a file that changed (the affected keys). Objects are compared by their stable addresses
23
+ (link.diff_addresses) when the address tables of both versions are given. Every list is sorted.
24
+ """
25
+ from __future__ import annotations
26
+
27
+ import re
28
+ import urllib.error
29
+ import urllib.request
30
+ import zipfile
31
+ from pathlib import Path
32
+
33
+ from . import contract
34
+ from .addressables import parse_header, parse_keys, parse_locations, remote_path
35
+ from .catalog import APK_CATALOG, Catalog, file_name, location_kind
36
+ from .contract import Cost, Input
37
+ from .stages import Output, Stage
38
+ from .store import write_file
39
+
40
+ CATALOGS = "nnnotes.catalogs/1"
41
+ DIFF = "nnnotes.catalog-diff/1"
42
+ CATALOG_NAMES = ("remote", "apk")
43
+ FILE_KINDS = ("bundle", "raw")
44
+ _RAW_HASH = re.compile(r"_[0-9a-f]{32}$")
45
+ FILE_FIELDS = ("name", "hash", "size", "crc", "bundleName")
46
+
47
+
48
+ # ---------------------------------------------------------------- index
49
+ def stable_raw_name(name: str) -> str:
50
+ """A raw file's name without its content hash (`<path>_<32 hex>` -> `<path>`)."""
51
+ return _RAW_HASH.sub("", name)
52
+
53
+
54
+ def _stable_names(names, stable) -> tuple[dict[str, str], list[list[str]]]:
55
+ groups: dict[str, list[str]] = {}
56
+ for n in sorted(set(names)):
57
+ groups.setdefault(stable(n), []).append(n)
58
+ out, collisions = {}, []
59
+ for s, ns in groups.items():
60
+ if len(ns) == 1:
61
+ out[ns[0]] = s
62
+ else:
63
+ collisions.append(ns)
64
+ out.update((n, n) for n in ns)
65
+ return out, sorted(collisions)
66
+
67
+
68
+ def _location(catalog: str, e: dict) -> dict:
69
+ return {"id": f"{catalog}:{e['offset']}", "primaryKey": e["primary_key"], "internalId": e["internal_id"],
70
+ "kind": location_kind(e["internal_id"]), "provider": e["provider"], "type": e["resource_type"],
71
+ "dependencies": [f"{catalog}:{d}" for d in e["dependencies"]], "dependencyHash": e["dependency_hash"],
72
+ "extraData": e["extra_data"]}
73
+
74
+
75
+ def _file(loc: dict, all_ids: list[str], stable: str) -> dict:
76
+ x = loc["extraData"] or {}
77
+ return {"name": file_name(loc["internalId"]), "stable": stable,
78
+ "remote": remote_path(loc["internalId"]) is not None, "location": loc["id"], "locations": all_ids,
79
+ "hash": x.get("hash"), "bundleName": x.get("bundleName"), "crc": x.get("crc"), "size": x.get("bundleSize"),
80
+ "flags": x.get("flags")}
81
+
82
+
83
+ def index(remote: bytes, apk: bytes | None = None) -> dict:
84
+ """The catalog index (nnnotes.catalog-index/1) of a remote catalog and, optionally, an APK catalog."""
85
+ catalogs, locations = {}, []
86
+ for name, data in (("remote", remote), ("apk", apk)):
87
+ if data is None:
88
+ continue
89
+ data = bytes(data)
90
+ locs = [_location(name, e) for e in parse_locations(data)]
91
+ catalogs[name] = {"sha256": contract.sha256(data), "size": len(data), "header": parse_header(data),
92
+ "keys": len(parse_keys(data)), "locations": len(locs)}
93
+ locations += locs
94
+ files: dict[tuple[str, str], list[dict]] = {}
95
+ for loc in locations:
96
+ if loc["kind"] in FILE_KINDS:
97
+ files.setdefault((loc["kind"], file_name(loc["internalId"])), []).append(loc)
98
+ out, collisions = {}, {}
99
+ for kind, key, stable in (("bundle", "bundles", contract.stable_bundle_name), ("raw", "rawFiles", stable_raw_name)):
100
+ names = sorted(n for k, n in files if k == kind)
101
+ stables, collisions[key] = _stable_names(names, stable)
102
+ entries = []
103
+ for n in names:
104
+ locs = files[(kind, n)]
105
+ chosen = next((x for x in locs if x["id"].startswith("apk:")), locs[0])
106
+ entries.append(_file(chosen, [x["id"] for x in locs], stables[n]))
107
+ out[key] = entries
108
+ return {"schema": contract.CATALOG_INDEX, "catalogs": catalogs, "locations": locations,
109
+ "bundles": out["bundles"], "rawFiles": out["rawFiles"], "collisions": collisions}
110
+
111
+
112
+ def index_catalog(cat: Catalog) -> dict:
113
+ """The index of an open Catalog (its remote catalog and, with an APK, the APK catalog)."""
114
+ src = cat.sources()
115
+ return index(src["remote"], src.get("apk"))
116
+
117
+
118
+ def summary(doc: dict) -> dict:
119
+ """Counts of an index: locations per kind, bundles (remote / APK), raw files, collisions."""
120
+ kinds: dict[str, int] = {}
121
+ for loc in doc["locations"]:
122
+ kinds[loc["kind"]] = kinds.get(loc["kind"], 0) + 1
123
+ return {"locations": len(doc["locations"]), "kinds": dict(sorted(kinds.items())),
124
+ "bundles": len(doc["bundles"]), "remoteBundles": sum(1 for b in doc["bundles"] if b["remote"]),
125
+ "rawFiles": len(doc["rawFiles"]),
126
+ "collisions": sum(len(g) for v in doc["collisions"].values() for g in v)}
127
+
128
+
129
+ def by_id(doc: dict) -> dict[str, dict]:
130
+ """{location id: location} of an index."""
131
+ return {loc["id"]: loc for loc in doc["locations"]}
132
+
133
+
134
+ def file_refs(doc: dict) -> dict[str, tuple[str, str]]:
135
+ """{location id: (kind, stable name)} of every bundle and raw file location of an index."""
136
+ out = {}
137
+ for kind, key in (("bundle", "bundles"), ("raw", "rawFiles")):
138
+ for f in doc[key]:
139
+ for lid in f["locations"]:
140
+ out[lid] = (kind, f["stable"])
141
+ return out
142
+
143
+
144
+ def closures(doc: dict) -> dict[str, frozenset]:
145
+ """{location id: the (kind, stable name) of every bundle and raw file its dependencies reach, itself included}."""
146
+ locs, files = by_id(doc), file_refs(doc)
147
+ memo: dict[str, frozenset] = {}
148
+
149
+ def closure(lid: str) -> frozenset:
150
+ hit = memo.get(lid)
151
+ if hit is not None:
152
+ return hit
153
+ seen, stack, found = {lid}, [lid], set()
154
+ while stack:
155
+ cur = stack.pop()
156
+ if cur in files:
157
+ found.add(files[cur])
158
+ for d in locs[cur]["dependencies"] if cur in locs else ():
159
+ if d not in seen:
160
+ seen.add(d)
161
+ stack.append(d)
162
+ memo[lid] = frozenset(found)
163
+ return memo[lid]
164
+
165
+ return {lid: closure(lid) for lid in locs}
166
+
167
+
168
+ # ---------------------------------------------------------------- diff
169
+ def _key_signatures(doc: dict) -> dict[str, list[dict]]:
170
+ """{primary key: [location signature]} of the asset (and other non-file) locations of an index. Dependencies are
171
+ named version-independently: files by kind and stable name, other locations by primary key."""
172
+ locs, files = by_id(doc), file_refs(doc)
173
+
174
+ def dep(lid: str) -> str:
175
+ if lid in files:
176
+ return f"{files[lid][0]}:{files[lid][1]}"
177
+ return "key:" + locs[lid]["primaryKey"] if lid in locs else "missing:" + lid
178
+
179
+ out: dict[str, list[dict]] = {}
180
+ for loc in doc["locations"]:
181
+ if loc["kind"] in FILE_KINDS:
182
+ continue
183
+ out.setdefault(loc["primaryKey"], []).append(
184
+ {"internalId": loc["internalId"], "provider": loc["provider"], "type": loc["type"],
185
+ "dependencies": [dep(d) for d in loc["dependencies"]]})
186
+ for sigs in out.values():
187
+ sigs.sort(key=contract.key_text)
188
+ return out
189
+
190
+
191
+ def _fields(old: list[dict], new: list[dict]) -> list[str]:
192
+ if len(old) != len(new):
193
+ return ["locations"]
194
+ return sorted({k for a, b in zip(old, new) for k in a if a[k] != b.get(k)})
195
+
196
+
197
+ def _files(doc: dict, key: str) -> dict[str, dict]:
198
+ return {f["stable"]: f for f in doc[key]}
199
+
200
+
201
+ def _brief(f: dict) -> dict:
202
+ return {k: f[k] for k in FILE_FIELDS}
203
+
204
+
205
+ def _diff_files(old: dict[str, dict], new: dict[str, dict]) -> dict:
206
+ added = [_brief(new[s]) | {"stable": s} for s in sorted(set(new) - set(old))]
207
+ removed = [_brief(old[s]) | {"stable": s} for s in sorted(set(old) - set(new))]
208
+ changed, same = [], 0
209
+ for s in sorted(set(old) & set(new)):
210
+ a, b = _brief(old[s]), _brief(new[s])
211
+ fields = [k for k in FILE_FIELDS if a[k] != b[k]]
212
+ if fields:
213
+ changed.append({"stable": s, "fields": fields, "old": a, "new": b})
214
+ else:
215
+ same += 1
216
+ return {"added": added, "removed": removed, "changed": changed, "unchanged": same}
217
+
218
+
219
+ def diff(old: dict, new: dict, old_addresses: dict | None = None, new_addresses: dict | None = None) -> dict:
220
+ """The difference between two catalog indexes (nnnotes.catalog-diff/1), and between their objects when the
221
+ address tables (link.addresses) of both are given."""
222
+ ko, kn = _key_signatures(old), _key_signatures(new)
223
+ keys = {"added": sorted(set(kn) - set(ko)), "removed": sorted(set(ko) - set(kn)), "changed": []}
224
+ for k in sorted(set(ko) & set(kn)):
225
+ if ko[k] != kn[k]:
226
+ keys["changed"].append({"key": k, "fields": _fields(ko[k], kn[k]), "old": ko[k], "new": kn[k]})
227
+ files = {key: _diff_files(_files(old, key), _files(new, key)) for key in ("bundles", "rawFiles")}
228
+ touched_new, touched_old = set(), set()
229
+ for key, kind in (("bundles", "bundle"), ("rawFiles", "raw")):
230
+ d = files[key]
231
+ touched_new |= {(kind, f["stable"]) for f in d["added"] + d["changed"]}
232
+ touched_old |= {(kind, f["stable"]) for f in d["removed"] + d["changed"]}
233
+ affected = set(keys["added"]) | set(keys["removed"]) | {c["key"] for c in keys["changed"]}
234
+ for doc, touched in ((new, touched_new), (old, touched_old)):
235
+ if not touched:
236
+ continue
237
+ reach = closures(doc)
238
+ for loc in doc["locations"]:
239
+ if loc["kind"] not in FILE_KINDS and reach[loc["id"]] & touched:
240
+ affected.add(loc["primaryKey"])
241
+ out = {"schema": DIFF, "old": _identity(old), "new": _identity(new), "keys": keys,
242
+ "bundles": files["bundles"], "rawFiles": files["rawFiles"], "affectedKeys": sorted(affected)}
243
+ if old_addresses is not None and new_addresses is not None:
244
+ from .link import diff_addresses
245
+ out["objects"] = diff_addresses(old_addresses, new_addresses)
246
+ out["summary"] = diff_summary(out)
247
+ return out
248
+
249
+
250
+ def _identity(doc: dict) -> dict:
251
+ return {name: doc["catalogs"][name]["sha256"] if name in doc["catalogs"] else None for name in CATALOG_NAMES}
252
+
253
+
254
+ def diff_summary(d: dict) -> dict:
255
+ out = {"keys": {k: len(v) for k, v in d["keys"].items()}, "affectedKeys": len(d["affectedKeys"])}
256
+ for key in ("bundles", "rawFiles"):
257
+ out[key] = {k: (v if isinstance(v, int) else len(v)) for k, v in d[key].items()}
258
+ if "objects" in d:
259
+ out["objects"] = d["objects"]["summary"]
260
+ return out
261
+
262
+
263
+ def format_diff(d: dict) -> str:
264
+ """A diff as text: one line per added (+), removed (-) or changed (~) key and file, then the counts."""
265
+ lines = []
266
+ for k in d["keys"]["added"]:
267
+ lines.append(f"+ key {k}")
268
+ for k in d["keys"]["removed"]:
269
+ lines.append(f"- key {k}")
270
+ for c in d["keys"]["changed"]:
271
+ lines.append(f"~ key {c['key']} ({', '.join(c['fields'])})")
272
+ for key, label in (("bundles", "bundle"), ("rawFiles", "raw")):
273
+ for f in d[key]["added"]:
274
+ lines.append(f"+ {label} {f['stable']} {f['name']} {f['size']}")
275
+ for f in d[key]["removed"]:
276
+ lines.append(f"- {label} {f['stable']} {f['name']} {f['size']}")
277
+ for c in d[key]["changed"]:
278
+ lines.append(f"~ {label} {c['stable']} {c['old']['name']} -> {c['new']['name']} "
279
+ f"({', '.join(c['fields'])})")
280
+ objects = d.get("objects", {})
281
+ lines += [f"+ object {a}" for a in objects.get("added", [])]
282
+ lines += [f"- object {a}" for a in objects.get("removed", [])]
283
+ lines += [f"~ object {o['address']} ({', '.join(o['fields'])})" for o in objects.get("changed", [])]
284
+ s = d["summary"]
285
+ lines.append(f"# keys +{s['keys']['added']} -{s['keys']['removed']} ~{s['keys']['changed']}; "
286
+ f"bundles +{s['bundles']['added']} -{s['bundles']['removed']} ~{s['bundles']['changed']} "
287
+ f"={s['bundles']['unchanged']}; raw files +{s['rawFiles']['added']} -{s['rawFiles']['removed']} "
288
+ f"~{s['rawFiles']['changed']} ={s['rawFiles']['unchanged']}; affected keys {s['affectedKeys']}")
289
+ if "objects" in s:
290
+ o = s["objects"]
291
+ lines.append(f"# objects +{o['added']} -{o['removed']} ~{o['changed']} rebuilt {o['rebuilt']} "
292
+ f"={o['unchanged']}")
293
+ return "\n".join(lines) + "\n"
294
+
295
+
296
+ # ---------------------------------------------------------------- the store of versions
297
+ def version_id(remote_sha: str, apk_sha: str | None) -> str:
298
+ """The id of a catalog version: the key hash of its two catalogs' content ids."""
299
+ return contract.digest({"remote": remote_sha, "apk": apk_sha})
300
+
301
+
302
+ def default_label(remote_sha: str, resource_version: str | None = None) -> str:
303
+ """The label of an imported version when none is given: the game's resource version when known, else the first
304
+ 12 hex digits of the remote catalog's sha256."""
305
+ return resource_version or remote_sha[:12]
306
+
307
+
308
+ def apk_catalog(apk) -> bytes:
309
+ """The local catalog inside an APK."""
310
+ with zipfile.ZipFile(apk) as z:
311
+ return z.read(APK_CATALOG)
312
+
313
+
314
+ def fetch(cdn: str, language: str, timeout: float = 120) -> tuple[bytes, str | None]:
315
+ """The remote catalog of `language` from a CDN base, and the text of its `.hash` file (None when not served)."""
316
+ base = cdn.rstrip("/") + "/asset/Android/" + Catalog.cache_file(language, Path(".")).name
317
+ with urllib.request.urlopen(base, timeout=timeout) as r:
318
+ data = r.read()
319
+ try:
320
+ with urllib.request.urlopen(base[:-len(".bin")] + ".hash", timeout=timeout) as r:
321
+ text = r.read().decode("ascii", "replace").strip()
322
+ except (urllib.error.URLError, OSError, ValueError):
323
+ text = None
324
+ return data, text or None
325
+
326
+
327
+ class CatalogDB:
328
+ """The catalog versions of a store (<store>/catalogs/)."""
329
+
330
+ def __init__(self, store_root):
331
+ self.root = Path(store_root) / "catalogs"
332
+
333
+ def path(self, sha: str) -> Path:
334
+ if not contract.is_sha256(sha):
335
+ raise ValueError(f"not a sha256: {sha!r}")
336
+ return self.root / f"{sha}.bin"
337
+
338
+ def _load(self) -> list[dict]:
339
+ try:
340
+ doc = contract.loads((self.root / "index.json").read_bytes())
341
+ except FileNotFoundError:
342
+ return []
343
+ if doc.get("schema") != CATALOGS:
344
+ raise ValueError(f"{self.root / 'index.json'} is not a {CATALOGS} document")
345
+ return doc["versions"]
346
+
347
+ def _save(self, versions: list[dict]) -> None:
348
+ write_file(self.root / "index.json", contract.encode({"schema": CATALOGS, "versions": versions}))
349
+
350
+ def versions(self, region: str | None = None, language: str | None = None) -> list[dict]:
351
+ """The versions in import order (of `region` / `language` when given)."""
352
+ return [v for v in self._load() if (region is None or v["region"] == region)
353
+ and (language is None or v["language"] == language)]
354
+
355
+ def _put(self, data: bytes) -> dict:
356
+ sha = contract.sha256(data)
357
+ p = self.path(sha)
358
+ if not (p.is_file() and p.stat().st_size == len(data)):
359
+ write_file(p, data)
360
+ return {"sha256": sha, "size": len(data), "buildResultHash": parse_header(data)["buildResultHash"]}
361
+
362
+ def add(self, remote: bytes, apk: bytes | None = None, *, label: str | None = None, region: str | None = None,
363
+ language: str | None = None, hash_text: str | None = None, resource_version: str | None = None,
364
+ apk_version_name: str | None = None) -> dict:
365
+ """Import a catalog pair (idempotent: a known pair gets the label and the facts it did not have yet); the
366
+ version. A label names one version per region and language."""
367
+ remote_rec = self._put(bytes(remote))
368
+ apk_rec = self._put(bytes(apk)) if apk is not None else None
369
+ vid = version_id(remote_rec["sha256"], apk_rec["sha256"] if apk_rec else None)
370
+ versions = self._load()
371
+ v = next((x for x in versions if x["id"] == vid), None)
372
+ if v is None:
373
+ v = {"id": vid, "seq": max((x["seq"] for x in versions), default=0) + 1, "labels": [],
374
+ "region": region, "language": language, "remote": remote_rec, "apk": apk_rec, "hash": None,
375
+ "resourceVersion": None, "apkVersionName": None}
376
+ versions.append(v)
377
+ for k, given in (("region", region), ("language", language), ("hash", hash_text),
378
+ ("resourceVersion", resource_version), ("apkVersionName", apk_version_name)):
379
+ if given is not None and v[k] is None:
380
+ v[k] = given
381
+ label = label or default_label(remote_rec["sha256"], resource_version)
382
+ for other in versions:
383
+ if other is not v and label in other["labels"] and (other["region"], other["language"]) == (
384
+ v["region"], v["language"]):
385
+ raise ValueError(f"label {label!r} already names catalog version {other['id'][:12]}")
386
+ if label not in v["labels"]:
387
+ v["labels"].append(label)
388
+ self._save(versions)
389
+ return v
390
+
391
+ def get(self, ref: str, region: str | None = None, language: str | None = None) -> dict:
392
+ """The version a label, a version id prefix or a remote catalog sha256 prefix names (of `region` /
393
+ `language` when given); "latest" is the last imported. KeyError when none, ValueError when several do."""
394
+ versions = self.versions(region, language)
395
+ if ref == "latest":
396
+ if not versions:
397
+ raise KeyError("no catalog version imported")
398
+ return versions[-1]
399
+ hits = [v for v in versions if ref in v["labels"]]
400
+ if not hits and re.fullmatch(r"[0-9a-f]{4,64}", ref):
401
+ hits = [v for v in versions if v["id"].startswith(ref) or v["remote"]["sha256"].startswith(ref)]
402
+ if not hits:
403
+ raise KeyError(f"no catalog version {ref!r}")
404
+ if len(hits) > 1:
405
+ raise ValueError(f"{ref!r} names {len(hits)} catalog versions: "
406
+ + ", ".join(f"{v['id'][:12]} ({v['region']}/{v['language']})" for v in hits))
407
+ return hits[0]
408
+
409
+ def catalog_bytes(self, version: dict) -> tuple[bytes, bytes | None]:
410
+ """(remote catalog, APK catalog or None) of a version."""
411
+ remote = self.path(version["remote"]["sha256"]).read_bytes()
412
+ apk = self.path(version["apk"]["sha256"]).read_bytes() if version["apk"] else None
413
+ return remote, apk
414
+
415
+ def index(self, version: dict) -> dict:
416
+ return index(*self.catalog_bytes(version))
417
+
418
+ def inputs(self, version: dict) -> list[Input]:
419
+ """The inputs of the catalog.index task of a version (roles "remote" and "apk"), read from this store."""
420
+ out = []
421
+ for role in CATALOG_NAMES:
422
+ rec = version[role]
423
+ if rec:
424
+ out.append(Input(role, rec["sha256"], rec["size"], f"{rec['sha256']}.bin",
425
+ ({"kind": "file", "path": str(self.path(rec["sha256"]))},)))
426
+ return out
427
+
428
+
429
+ # ---------------------------------------------------------------- stage
430
+ class IndexStage(Stage):
431
+ """catalog.index: a catalog pair -> its index. Subjects and inputs come from the fact "catalogs": {subject:
432
+ [Input "remote", Input "apk" (optional)]} (CatalogDB.inputs). Artifact "<task id>#index"."""
433
+ name = "catalog.index"
434
+ version = 1
435
+
436
+ def subjects(self, env) -> list[str]:
437
+ return sorted(env.fact("catalogs"))
438
+
439
+ def inputs(self, subject: str, env) -> list[Input]:
440
+ ins = list(env.fact("catalogs")[subject])
441
+ roles = sorted(i.role for i in ins)
442
+ if "remote" not in roles or not set(roles) <= set(CATALOG_NAMES):
443
+ raise ValueError(f"catalog.index {subject}: inputs {roles}, expected remote (+ apk)")
444
+ return ins
445
+
446
+ def estimate(self, subject: str, env, inputs: list[Input]) -> Cost:
447
+ size = sum(i.size for i in inputs)
448
+ return Cost(0.2 + size / 10e6, (60 << 20) + 40 * size)
449
+
450
+ def run(self, task, store) -> Output:
451
+ roles = {i.role for i in task.inputs}
452
+ doc = index(store.input_bytes(task.input("remote")),
453
+ store.input_bytes(task.input("apk")) if "apk" in roles else None)
454
+ content = store.add(contract.encode(doc), "json")
455
+ rec = contract.artifact(contract.artifact_id(task.id, "index"), content, contract.provenance(task),
456
+ {"kind": "catalog.index", "format": "json", "facts": summary(doc)})
457
+ return Output([rec], [])