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/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], [])
|