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/cli_assets.py ADDED
@@ -0,0 +1,1539 @@
1
+ """The asset commands: the default orchestration of the stages (export), its plan, single tasks, catalog versions and
2
+ the store.
3
+
4
+ nnnotes export -o OUT [--layout original[,cas]] [--select group:G|key:PREFIX|bundle:GLOB ...]
5
+ [--views all|none|a,b] [--store DIR] [--workers N] [--memory GiB] [--fetch-workers N]
6
+ [--catalog-version L|SHA] [--png-level N] [--flac-level N] [--only-class C,...]
7
+ [--link auto|clone|hard|copy] [--strict] [--dry-run] [--explain]
8
+ nnnotes plan [the selection and parameters of export] [-o OUT] [--json] [--since RUN] [--check] [--census]
9
+ [--emit-tasks DIR] [--why TASK]
10
+ nnnotes run-stage TASK.json|DIR [...] [--store DIR] [--fetch] [--force]
11
+ nnnotes catalogs list [--json] | import FILE [--label L] | fetch [--label L] | diff A B [--json]
12
+ nnnotes store verify [--quick]
13
+
14
+ The pipeline is the registry STAGE_SOURCES in order: each entry names the module and attribute of a stage (a Stage
15
+ class) or of a family of stages (a function returning them, `view.`). An entry whose module or attribute is not
16
+ installed is left out of the pipeline and named by --explain. Stage modules import heavy libraries (UnityPy) only
17
+ when a task runs, so `plan`, and `run-stage` of stages that do not read bundles, never load them.
18
+
19
+ Facts the stages read while their tasks are described (stages.Env):
20
+
21
+ catalogs {"main": [Input remote, Input apk]}: the catalog version's files in <store>/catalogs/
22
+ index the catalog index document (nnnotes.catalog-index/1)
23
+ bundles {stable bundle name: Input "bundle"} of the selected bundles and their dependencies (a bundle whose
24
+ bytes are not known yet: a Pending "fetch:<file name>")
25
+ selected the selected bundles' stable names, sorted (the subjects of the export stages)
26
+ raw {stable raw file name: Input "raw"} of the selected raw files (only with a stage that reads them;
27
+ export fetches them while the stages before run, and reading the fact waits for that)
28
+ boot the Input of the game's boot data (with a CRI stage: cristages.boot_input; None without an APK)
29
+ master the decoded master data directory, or None
30
+ views the names of the selected views, sorted
31
+
32
+ Selection (--select, repeatable; the union; none: everything): `group:G` the bundles of an Addressables group (the
33
+ stable name up to `_assets_`, else up to its first `_`), `key:PREFIX` the bundles and raw files the catalog keys
34
+ under PREFIX load (their dependency closure), `bundle:GLOB` bundles and raw files whose stable name matches. The
35
+ census also covers the bundles the selected ones depend on (the dependencies of the keys they hold: a key's first
36
+ dependency is its own bundle), so script and atlas references resolve.
37
+
38
+ Task cost estimates (scheduling only: never part of a key) are each stage's estimate scaled per stage by the
39
+ costs measured in earlier runs (<store>/costs/calibration.json, updated after every export).
40
+
41
+ Exit codes: export 0 complete (unsupported objects allowed), 1 item or task failures (reports written), 2 usage or
42
+ settings, 3 aborted (results written so far stay valid); run-stage 0, 1 (a task failed or a result is partial), 2,
43
+ 4 an incompatible task (nothing of the batch runs); plan --check 1 when anything would run or change.
44
+ """
45
+ from __future__ import annotations
46
+
47
+ import argparse
48
+ import fnmatch
49
+ import json
50
+ import sys
51
+ import threading
52
+ from collections import Counter
53
+ from collections.abc import Mapping
54
+ from concurrent.futures import ThreadPoolExecutor
55
+ from dataclasses import dataclass
56
+ from importlib import import_module, util
57
+ from pathlib import Path
58
+ from types import SimpleNamespace
59
+
60
+ from . import contract
61
+ from .config import ConfigError, describe as describe_setting, usable_cpus, use
62
+ from .contract import Cost, IncompatibleTask, Input, Task
63
+ from .stages import Env, Pending, Stage, describe, execute
64
+
65
+ GIB = 1 << 30
66
+ LAYOUT_NAMES = ("original", "cas")
67
+ FETCH_WORKERS = 8
68
+ RECYCLE_TASKS = 500
69
+ RECYCLE_RSS = 3 * GIB # a worker whose resident memory after a task is above this is replaced ...
70
+ RECYCLE_SHARE = 0.4 # ... or above this share of its part of the memory budget (budget / workers),
71
+ RECYCLE_RSS_MIN = 256 << 20 # but not below this
72
+ REPORTS = "_reports"
73
+ SELECT_KINDS = ("group", "key", "bundle")
74
+ RAW_STAGES = ("cri.audio", "cri.movie")
75
+ EXPORT_STAGE = "unity.export"
76
+ CATALOG_SUBJECT = "main"
77
+ EXIT_FAILED, EXIT_USAGE, EXIT_ABORTED, EXIT_INCOMPATIBLE = 1, 2, 3, 4
78
+
79
+
80
+ # ---------------------------------------------------------------- the stage registry
81
+ @dataclass(frozen=True)
82
+ class StageSource:
83
+ """Where a stage (or, for a name ending with '.', a family of stages) comes from. `output`: its artifacts are
84
+ placed in the layouts; `root`: the layout root of its artifacts without a container path (format fields
85
+ {stage} and {subject}; a stage's LAYOUT_ROOT attribute wins)."""
86
+ name: str
87
+ target: str
88
+ output: bool = False
89
+ root: str | None = None
90
+
91
+ def matches(self, stage: str) -> bool:
92
+ return stage.startswith(self.name) if self.name.endswith(".") else stage == self.name
93
+
94
+
95
+ STAGE_SOURCES = (
96
+ StageSource("catalog.index", "nnnotes.catalogdb:IndexStage"),
97
+ StageSource("unity.census", "nnnotes.census:CensusStage"),
98
+ StageSource("link.scripts", "nnnotes.link:ScriptsStage"),
99
+ StageSource("link.addresses", "nnnotes.link:AddressesStage"),
100
+ StageSource("unity.export", "nnnotes.objexport:ExportStage", output=True, root="_bundles/{subject}"),
101
+ StageSource("sprite.crop", "nnnotes.objexport:SpriteCropStage", output=True),
102
+ StageSource("cri.audio", "nnnotes.cristages:AudioStage", output=True),
103
+ StageSource("cri.movie", "nnnotes.cristages:MovieStage", output=True),
104
+ StageSource("live2d.model", "nnnotes.modelstages:Live2DStage", output=True),
105
+ StageSource("spine.skeleton", "nnnotes.modelstages:SpineStage", output=True),
106
+ StageSource("link.artifacts", "nnnotes.link:ArtifactsStage"),
107
+ StageSource("view.", "nnnotes.views:stages", output=True),
108
+ StageSource("view.voices", "nnnotes.voices:VoiceStage", output=True),
109
+ )
110
+
111
+
112
+ def _present(module: str) -> bool:
113
+ try:
114
+ return util.find_spec(module) is not None
115
+ except ModuleNotFoundError:
116
+ return False
117
+
118
+
119
+ def load_source(src: StageSource) -> list[Stage] | None:
120
+ """The stages of a source, sorted by name; None when its module or attribute is not installed. Errors raised
121
+ while importing an installed module are not hidden."""
122
+ module, _, attr = src.target.partition(":")
123
+ if not _present(module):
124
+ return None
125
+ obj = getattr(import_module(module), attr, None)
126
+ if obj is None:
127
+ return None
128
+ stages = [obj()] if isinstance(obj, type) and issubclass(obj, Stage) else list(obj())
129
+ for s in stages:
130
+ if not src.matches(s.name):
131
+ raise ValueError(f"{src.target} gives stage {s.name!r}, not {src.name!r}")
132
+ return sorted(stages, key=lambda s: s.name)
133
+
134
+
135
+ @dataclass
136
+ class Installed:
137
+ """The installed stages in pipeline order, the source of each, and the sources not installed."""
138
+ stages: dict
139
+ sources: dict
140
+ missing: list
141
+
142
+
143
+ def installed(names=None) -> Installed:
144
+ """The stages of STAGE_SOURCES (`names`: only the sources of these stage names, for run-stage)."""
145
+ stages, of, missing = {}, {}, []
146
+ for src in STAGE_SOURCES:
147
+ if names is not None and not any(src.matches(n) for n in names):
148
+ continue
149
+ got = load_source(src)
150
+ if got is None:
151
+ missing.append(src.name + ("*" if src.name.endswith(".") else ""))
152
+ continue
153
+ for s in got:
154
+ if s.name in stages:
155
+ raise ValueError(f"stage {s.name} given twice")
156
+ stages[s.name], of[s.name] = s, src
157
+ return Installed(stages, of, missing)
158
+
159
+
160
+ def layout_root(stage: Stage, src: StageSource, subject: str) -> str | None:
161
+ root = getattr(stage, "LAYOUT_ROOT", None) or src.root
162
+ return None if root is None else root.format(stage=stage.name, subject=subject)
163
+
164
+
165
+ # ---------------------------------------------------------------- selection
166
+ def parse_selection(items) -> list[str]:
167
+ """The normalized selection: sorted unique `kind:value` items, ["all"] for none."""
168
+ out = set()
169
+ for s in items or ():
170
+ kind, sep, value = s.partition(":")
171
+ if not sep or kind not in SELECT_KINDS or not value:
172
+ raise ValueError(f"--select {s}: expected group:<group>, key:<key prefix> or bundle:<glob>")
173
+ out.add(f"{kind}:{value}")
174
+ return sorted(out) or ["all"]
175
+
176
+
177
+ def bundle_group(stable: str) -> str:
178
+ """The Addressables group of a bundle: its stable name up to `_assets_`, else up to its first `_`."""
179
+ head, sep, _ = stable.partition("_assets_")
180
+ return head if sep else stable.split("_", 1)[0]
181
+
182
+
183
+ @dataclass
184
+ class Selection:
185
+ selected: list # stable names of the selected bundles (export subjects)
186
+ census: list # the selected bundles and the bundles they depend on
187
+ raw: list # stable names of the selected raw files
188
+
189
+
190
+ def select(index: dict, selection: list[str]) -> Selection:
191
+ """The bundles and raw files a selection names in a catalog index (module documentation)."""
192
+ from . import catalogdb
193
+ bundles = sorted(b["stable"] for b in index["bundles"])
194
+ raws = sorted(r["stable"] for r in index["rawFiles"])
195
+ if selection == ["all"]:
196
+ return Selection(bundles, bundles, raws)
197
+ files = catalogdb.file_refs(index)
198
+ reach = catalogdb.closures(index)
199
+ chosen: set = set()
200
+ for item in selection:
201
+ kind, _, value = item.partition(":")
202
+ if kind == "group":
203
+ chosen |= {("bundle", s) for s in bundles if bundle_group(s) == value}
204
+ elif kind == "bundle":
205
+ chosen |= {("bundle", s) for s in bundles if fnmatch.fnmatchcase(s, value)}
206
+ chosen |= {("raw", s) for s in raws if fnmatch.fnmatchcase(s, value)}
207
+ else:
208
+ for loc in index["locations"]:
209
+ if loc["kind"] not in catalogdb.FILE_KINDS and loc["primaryKey"].startswith(value):
210
+ chosen |= reach[loc["id"]]
211
+ deps = set(chosen)
212
+ for loc in index["locations"]: # a key's first dependency is the bundle that holds it
213
+ if loc["kind"] not in catalogdb.FILE_KINDS and loc["dependencies"] and files.get(loc["dependencies"][0]) in chosen:
214
+ deps |= reach[loc["id"]]
215
+ pick = lambda s, kind: sorted(n for k, n in s if k == kind) # noqa: E731
216
+ return Selection(pick(chosen, "bundle"), pick(deps, "bundle"), pick(chosen, "raw"))
217
+
218
+
219
+ # ---------------------------------------------------------------- settings
220
+ def store_root(args, cfg) -> Path:
221
+ """--store, else [paths] store, else <[paths] cache>/store."""
222
+ p = getattr(args, "store", None)
223
+ if p:
224
+ return Path(p).expanduser()
225
+ p = cfg.path("paths", "store")
226
+ if p is not None:
227
+ return p
228
+ cache = cfg.path("paths", "cache")
229
+ if cache is not None:
230
+ return cache / "store"
231
+ raise ConfigError("setting paths.store is not set (nor paths.cache, whose store/ directory is the default): "
232
+ f"give it as {describe_setting('paths', 'store', '--store')}")
233
+
234
+
235
+ def default_memory() -> int | None:
236
+ """80 % of the physical memory (Linux), else None (no budget)."""
237
+ try:
238
+ with open("/proc/meminfo") as f:
239
+ for line in f:
240
+ if line.startswith("MemTotal:"):
241
+ return int(int(line.split()[1]) * 1024 * 0.8)
242
+ except (OSError, ValueError):
243
+ pass
244
+ return None
245
+
246
+
247
+ def recycle_rss(memory: int | None, workers: int) -> int:
248
+ """The resident memory after a task above which a worker is replaced: RECYCLE_RSS, or with a memory budget
249
+ RECYCLE_SHARE of each worker's part of it when that is less (not below RECYCLE_RSS_MIN). A worker keeps much of
250
+ its largest task's memory (freed but not returned to the system), which the budget does not count."""
251
+ if memory is None:
252
+ return RECYCLE_RSS
253
+ return min(RECYCLE_RSS, max(RECYCLE_RSS_MIN, int(RECYCLE_SHARE * memory / max(1, workers))))
254
+
255
+
256
+ def _bundle_key(cfg):
257
+ from .addressables import BundleKey
258
+ return BundleKey(cfg.hex("bundle", "key", 16), cfg.hex("bundle", "nonce_seed"))
259
+
260
+
261
+ class CatalogFetcher:
262
+ """The fetcher of "catalog" locators ({catalog: version id, location: location id}): the file is fetched through
263
+ that catalog version of the store (the settings' CDN, bundle key and APK) into the cache. Crosses to worker
264
+ processes as its settings."""
265
+
266
+ def __init__(self, store_root_, cache, cfg):
267
+ self.root, self.cache, self.cfg = Path(store_root_), Path(cache) if cache else None, cfg
268
+ self._catalogs: dict = {}
269
+ self._lock = threading.Lock()
270
+
271
+ def __getstate__(self):
272
+ return {"root": self.root, "cache": self.cache, "cfg": self.cfg}
273
+
274
+ def __setstate__(self, state):
275
+ self.__init__(state["root"], state["cache"], state["cfg"])
276
+
277
+ def catalog(self, vid: str):
278
+ """(Catalog, {location id: location}) of a stored catalog version."""
279
+ from . import catalogdb
280
+ from .catalog import Catalog
281
+ with self._lock:
282
+ hit = self._catalogs.get(vid)
283
+ if hit is None:
284
+ if self.cache is None:
285
+ raise self.cfg.missing("paths", "cache")
286
+ db = catalogdb.CatalogDB(self.root)
287
+ v = next((x for x in db.versions() if x["id"] == vid), None)
288
+ if v is None:
289
+ raise FileNotFoundError(f"catalog version {vid[:12]} is not in the store")
290
+ remote, apk = db.catalog_bytes(v)
291
+ cfg, region = self.cfg, v.get("region")
292
+ cat = Catalog(remote, self.cache, cdn=lambda: cfg.cdn(region or cfg.region()),
293
+ bundle_key=lambda: _bundle_key(cfg), apk=cfg.path("paths", "apk"))
294
+ hit = self._catalogs[vid] = (cat, catalogdb.by_id(catalogdb.index(remote, apk)))
295
+ return hit
296
+
297
+ def fetch_location(self, vid: str, lid: str) -> Path:
298
+ from .addressables import remote_path
299
+ from .catalog import Bundle, file_name
300
+ cat, locs = self.catalog(vid)
301
+ loc = locs.get(lid)
302
+ if loc is None:
303
+ raise FileNotFoundError(f"catalog version {vid[:12]} has no location {lid}")
304
+ iid = loc["internalId"]
305
+ if loc["kind"] == "bundle":
306
+ return cat.fetch(Bundle(0, iid, file_name(iid), remote_path(iid) is not None))
307
+ if remote_path(iid) is None:
308
+ return self._apk_file(iid)
309
+ return cat.fetch_raw({"internal_id": iid})
310
+
311
+ def _apk_file(self, internal_id: str) -> Path:
312
+ """A raw file of the APK as stored, through the cache (raw/<its path below the APK's Addressables
313
+ directory>, where a CDN file of that path would be); KeyError when the APK does not hold it."""
314
+ import zipfile
315
+ from .cache import write_atomic
316
+ from .catalog import APK_AA_DIR
317
+ if self.cache is None:
318
+ raise self.cfg.missing("paths", "cache")
319
+ apk = self.cfg.path("paths", "apk")
320
+ if apk is None:
321
+ raise self.cfg.missing("paths", "apk")
322
+ rel = apk_rel(internal_id)
323
+ dst = self.cache / "raw" / rel
324
+ if not (dst.is_file() and dst.stat().st_size > 0):
325
+ with zipfile.ZipFile(apk) as z:
326
+ data = z.read(APK_AA_DIR + rel)
327
+ dst.parent.mkdir(parents=True, exist_ok=True)
328
+ write_atomic(dst, data)
329
+ return dst
330
+
331
+ def __call__(self, inp: Input, loc: dict) -> Path:
332
+ return self.fetch_location(loc["catalog"], loc["location"])
333
+
334
+
335
+ def apk_rel(internal_id: str) -> str:
336
+ """The path of an APK-local location's file below the APK's Addressables directory (catalog.APK_AA_DIR)."""
337
+ from .catalog import LOCAL_PREFIX
338
+ return internal_id[len(LOCAL_PREFIX):].lstrip("/") if internal_id.startswith(LOCAL_PREFIX) else internal_id
339
+
340
+
341
+ def _worker_init(store, cfg) -> None:
342
+ """Every worker process: the settings of the command."""
343
+ use(cfg)
344
+
345
+
346
+ def _log(message: str) -> None:
347
+ from .orchestrate import _log as log
348
+ log(message)
349
+
350
+
351
+ # ---------------------------------------------------------------- cost calibration
352
+ CALIBRATION = "calibration.json" # <store>/costs/calibration.json
353
+ MIN_SAMPLES = 10
354
+
355
+
356
+ class Calibrated:
357
+ """A stage whose cost estimates are scaled by measured factors. Estimates only order and admit tasks: keys and
358
+ outputs do not change."""
359
+
360
+ def __init__(self, stage: Stage, cpu: float = 1.0, peak: float = 1.0):
361
+ self._stage, self._cpu, self._peak = stage, float(cpu), float(peak)
362
+
363
+ def __getattr__(self, name):
364
+ if name.startswith("_"):
365
+ raise AttributeError(name)
366
+ return getattr(self._stage, name)
367
+
368
+ def estimate(self, subject, env, inputs):
369
+ c = self._stage.estimate(subject, env, inputs)
370
+ return Cost(c.cpu_seconds * self._cpu, int(c.peak_bytes * self._peak))
371
+
372
+
373
+ def load_calibration(root: Path) -> dict:
374
+ """{stage: {"cpu": factor, "peak": factor, "samples": n}} of a store ({} when none was measured)."""
375
+ try:
376
+ doc = contract.loads((Path(root) / "costs" / CALIBRATION).read_bytes())
377
+ except (OSError, ValueError):
378
+ return {}
379
+ return doc.get("stages", {}) if isinstance(doc, dict) else {}
380
+
381
+
382
+ def calibrated(stages: dict, factors: dict) -> dict:
383
+ return {n: Calibrated(s, factors[n]["cpu"], factors[n]["peak"]) if n in factors else s for n, s in stages.items()}
384
+
385
+
386
+ def _clamp(x: float) -> float:
387
+ return round(min(20.0, max(0.05, x)), 4)
388
+
389
+
390
+ def calibrate(log: list[dict], factors: dict, worker_bytes: int) -> dict:
391
+ """New factors from a run log: per stage with at least MIN_SAMPLES measured tasks, CPU by the ratio of the
392
+ measured (the task's process and its child processes, such as external tools) to the estimated sums, peak memory
393
+ by the 95th percentile of measured over estimated, the measured peak less the process's memory at the task's
394
+ start (startRssBytes; in older records a worker's own memory). Where the child processes' CPU
395
+ time cannot be measured (childCpuSeconds null) the CPU factor stays. The logged estimates carry the old factors,
396
+ which the new ones build on."""
397
+ est = {e["task"]: e["estimate"] for e in log if e.get("event") == "start" and e.get("estimate")}
398
+ samples: dict[str, list] = {}
399
+ for e in log:
400
+ if e.get("event") == "end" and e.get("ok") and e.get("cost") and e["task"] in est:
401
+ samples.setdefault(contract.parse_task_id(e["task"])[0], []).append((est[e["task"]], e["cost"]))
402
+ out = {k: dict(v) for k, v in factors.items()}
403
+ for stage, pairs in samples.items():
404
+ pairs = [(a, c) for a, c in pairs if a["cpuSeconds"] > 0 and a["peakBytes"] > 0]
405
+ if len(pairs) < MIN_SAMPLES:
406
+ continue
407
+ old = factors.get(stage, {"cpu": 1.0, "peak": 1.0})
408
+ if any(c.get("childCpuSeconds", 0) is None for _, c in pairs):
409
+ cpu = 1.0 # child processes not measured here: keep the CPU factor
410
+ else:
411
+ cpu = sum(c["cpuSeconds"] for _, c in pairs) / sum(a["cpuSeconds"] for a, _ in pairs)
412
+ ratios = sorted(max(0, (c.get("peakRssBytes") or 0) - (c.get("startRssBytes") or worker_bytes))
413
+ / a["peakBytes"] for a, c in pairs)
414
+ peak = ratios[min(len(ratios) - 1, int(0.95 * len(ratios)))]
415
+ if not any(c.get("peakRssBytes") for _, c in pairs):
416
+ peak = 1.0 # not measurable on this platform
417
+ out[stage] = {"cpu": _clamp(old["cpu"] * cpu), "peak": _clamp(old["peak"] * max(peak, 0.05)),
418
+ "samples": len(pairs)}
419
+ return dict(sorted(out.items()))
420
+
421
+
422
+ def write_calibration(root: Path, factors: dict) -> None:
423
+ from .store import write_file
424
+ write_file(Path(root) / "costs" / CALIBRATION, contract.encode({"stages": factors}))
425
+
426
+
427
+ # ---------------------------------------------------------------- the workspace of a command
428
+ class Workspace:
429
+ """What export and plan share: the store, the catalog version, its index, the selection, the pipeline and its
430
+ parameters, the facts."""
431
+
432
+ def __init__(self, args, cfg, common, log=_log):
433
+ from .catalogdb import CatalogDB
434
+ from .store import Store
435
+ self.args, self.cfg, self.common, self.log = args, cfg, common, log
436
+ self.root = store_root(args, cfg)
437
+ self.cache = cfg.path("paths", "cache")
438
+ self.apk = cfg.path("paths", "apk")
439
+ self.fetcher = CatalogFetcher(self.root, self.cache, cfg)
440
+ self.store = Store(self.root, cache=self.cache, fetch=self.fetcher)
441
+ self.db = CatalogDB(self.root)
442
+ try:
443
+ self.selection = parse_selection(getattr(args, "select", None))
444
+ except ValueError as e:
445
+ args.usage(str(e))
446
+ self.installed = installed()
447
+ self.calibration = load_calibration(self.root)
448
+ self.scheduled = calibrated(self.installed.stages, self.calibration) # the stages with calibrated costs
449
+ self.version, self.remote, self.apk_catalog = self._catalog_version()
450
+ self._index = None
451
+ self._problems: list = []
452
+ self._apk_names: set | None = None
453
+ self._facts: dict = {}
454
+ self._later = None # the background fetch of the raw files
455
+ self._cancel = threading.Event() # set: start no more fetches
456
+ self._names: dict = {}
457
+ self._done: dict = {} # task id -> key of the tasks with a result
458
+
459
+ # ---------------------------------------------------------------- catalog
460
+ def _catalog_version(self):
461
+ from . import catalogdb
462
+ ref = getattr(self.args, "catalog_version", None)
463
+ if ref:
464
+ try:
465
+ try:
466
+ v = self.db.get(ref)
467
+ except ValueError:
468
+ v = self.db.get(ref, self.cfg.get("catalog", "region"), self.cfg.get("catalog", "language"))
469
+ except (KeyError, ValueError) as e:
470
+ self.args.usage(f"--catalog-version {ref}: {e.args[0] if e.args else e}")
471
+ remote, apk = self.db.catalog_bytes(v)
472
+ return v, remote, apk
473
+ cat = self.common.open_catalog(self.cfg, bundles=False)
474
+ src = cat.sources()
475
+ remote, apk = src["remote"], src.get("apk")
476
+ vid = catalogdb.version_id(contract.sha256(remote), contract.sha256(apk) if apk is not None else None)
477
+ v = next((x for x in self.db.versions() if x["id"] == vid), None)
478
+ if v is None:
479
+ v = self.db.add(remote, apk, region=self.cfg.get("catalog", "region"),
480
+ language=self.cfg.get("catalog", "language"), apk_version_name=_apk_version(self.apk))
481
+ return v, remote, apk
482
+
483
+ def catalogs_fact(self) -> dict:
484
+ return {CATALOG_SUBJECT: self.db.inputs(self.version)}
485
+
486
+ def index_task(self) -> Task:
487
+ stage = self.installed.stages["catalog.index"]
488
+ return describe(stage, CATALOG_SUBJECT, None, Env(self.store, {"catalogs": self.catalogs_fact()}))
489
+
490
+ def index(self) -> dict:
491
+ """The catalog index: from the store when catalog.index has run, else computed here."""
492
+ if self._index is None:
493
+ from . import catalogdb
494
+ t = self.index_task()
495
+ doc = self.store.result(t.key)
496
+ if doc is not None:
497
+ (rec,) = [a for a in doc["artifacts"] if a["id"] == contract.artifact_id(t.id, "index")]
498
+ self._index = self.store.document(rec["content"]["sha256"])
499
+ else:
500
+ self._index = catalogdb.index(self.remote, self.apk_catalog)
501
+ return self._index
502
+
503
+ def run_index(self) -> None:
504
+ execute(self.index_task(), self.store, self.installed.stages)
505
+ self._index = None
506
+
507
+ def context(self) -> dict:
508
+ v = self.version
509
+ return {"region": self.cfg.get("catalog", "region"),
510
+ "language": v.get("language") or self.cfg.get("catalog", "language"),
511
+ "catalog": {"id": v["id"], "sha": v["remote"]["sha256"],
512
+ "label": v["labels"][0] if v["labels"] else None,
513
+ "resourceVersion": v.get("resourceVersion"),
514
+ "apkCatalogSha": v["apk"]["sha256"] if v.get("apk") else None,
515
+ "apkVersionName": v.get("apkVersionName")}}
516
+
517
+ def master_context(self, results) -> dict | None:
518
+ """The master data a run read: {"version": the `version` of <master>/MasterManifest.json (None without
519
+ one), "tables": {table: sha256} of the tables its view tasks read}; None when no view task has a result."""
520
+ from .views import MASTER_ROLE
521
+ tables, any_view = {}, False
522
+ for tid, doc in results:
523
+ if not tid.startswith("view."):
524
+ continue
525
+ any_view = True
526
+ for role, sha in doc["keyParts"]["inputs"]:
527
+ if role.startswith(MASTER_ROLE):
528
+ tables[role[len(MASTER_ROLE):]] = sha
529
+ if not any_view:
530
+ return None
531
+ version = None
532
+ md = self.master()
533
+ if md is not None:
534
+ try:
535
+ version = json.loads((md / "MasterManifest.json").read_text(encoding="utf-8")).get("version")
536
+ except (OSError, ValueError, AttributeError):
537
+ version = None
538
+ return {"version": version, "tables": dict(sorted(tables.items()))}
539
+
540
+ # ---------------------------------------------------------------- pipeline
541
+ def master(self) -> Path | None:
542
+ section, key = self.cfg.master(self.cfg.get("catalog", "region"))
543
+ p = self.cfg.path(section, key)
544
+ if p is not None and not p.is_dir():
545
+ raise ConfigError(f"setting {section}.{key}: directory {p} not found")
546
+ return p
547
+
548
+ def views(self) -> list[str]:
549
+ """The selected view stages (--views; default: every installed view when master data is set)."""
550
+ avail = [n for n in self.installed.stages if n.startswith("view.")]
551
+ spec = getattr(self.args, "views", None)
552
+ if spec is None:
553
+ return avail if avail and self.master() is not None else []
554
+ if spec == "none":
555
+ return []
556
+ want = avail if spec == "all" else sorted({"view." + v.strip() for v in spec.split(",") if v.strip()})
557
+ unknown = [w[5:] for w in want if w not in avail]
558
+ if unknown:
559
+ self.args.usage(f"--views: no view {', '.join(unknown)} (views: "
560
+ f"{', '.join(a[5:] for a in avail) or 'none installed'})")
561
+ if want and self.master() is None:
562
+ raise self.cfg.missing(*self.cfg.master(self.cfg.get("catalog", "region")))
563
+ return want
564
+
565
+ def pipeline(self) -> list[str]:
566
+ views = set(self.views())
567
+ return [n for n in self.installed.stages if not n.startswith("view.") or n in views]
568
+
569
+ def params(self, pipeline: list[str]) -> dict:
570
+ """{stage: parameters} from the flags; a flag no stage of the pipeline takes is a usage error."""
571
+ stages, out = self.installed.stages, {}
572
+ level = getattr(self.args, "png_level", None)
573
+ if level is not None:
574
+ if not 0 <= level <= 9:
575
+ self.args.usage(f"--png-level {level}: expected 0 to 9")
576
+ takers = [n for n in pipeline if "png" in stages[n].PARAMS]
577
+ if not takers:
578
+ self.args.usage("--png-level: no stage of the pipeline encodes PNG")
579
+ for n in takers:
580
+ base = stages[n].PARAMS["png"]
581
+ out.setdefault(n, {})["png"] = {**(base if isinstance(base, dict) else {}), "level": level}
582
+ level = getattr(self.args, "flac_level", None)
583
+ if level is not None:
584
+ if not 0 <= level <= 12:
585
+ self.args.usage(f"--flac-level {level}: expected 0 to 12")
586
+ takers = [n for n in pipeline if "flac" in stages[n].PARAMS]
587
+ if not takers:
588
+ self.args.usage("--flac-level: no stage of the pipeline encodes FLAC")
589
+ for n in takers:
590
+ out.setdefault(n, {})["flac"] = {**stages[n].PARAMS["flac"], "level": level}
591
+ only = getattr(self.args, "only_class", None)
592
+ if only:
593
+ classes = sorted({c.strip() for c in only.split(",") if c.strip()})
594
+ takers = [n for n in pipeline if "classes" in stages[n].PARAMS]
595
+ if not classes or not takers:
596
+ self.args.usage("--only-class: " + ("no class given" if not classes
597
+ else "no stage of the pipeline selects classes"))
598
+ for n in takers:
599
+ out.setdefault(n, {})["classes"] = classes
600
+ for n, p in out.items():
601
+ try:
602
+ stages[n].normalize(p)
603
+ except ValueError as e:
604
+ self.args.usage(str(e))
605
+ return out
606
+
607
+ def workers(self) -> int:
608
+ w = getattr(self.args, "workers", None)
609
+ return max(0, w) if w is not None else usable_cpus()
610
+
611
+ def memory(self) -> int | None:
612
+ m = getattr(self.args, "memory", None)
613
+ return int(m * GIB) if m is not None else default_memory()
614
+
615
+ def recycle_rss(self) -> int:
616
+ return recycle_rss(self.memory(), self.workers())
617
+
618
+ # ---------------------------------------------------------------- inputs
619
+ def facts(self, pipeline: list[str], fetch: bool, background: bool = False) -> dict:
620
+ """The planning facts (module documentation). `fetch`: fetch the files whose content is not known yet
621
+ (else they are Pending); what cannot be read is in problems() (after wait()). `background`: the raw files
622
+ are fetched while the caller goes on (the fact waits for them when it is read)."""
623
+ index = self.index()
624
+ sel = select(index, self.selection)
625
+ self._problems = []
626
+ bundles = {b["stable"]: b for b in index["bundles"]}
627
+ inputs = self._inputs("bundle", [bundles[s] for s in sel.census], fetch)
628
+ sel.selected = [s for s in sel.selected if s in inputs] # files that cannot be read: problems()
629
+ facts = {"catalogs": self.catalogs_fact(), "index": index, "selected": sel.selected,
630
+ "master": self.master() if any(n.startswith("view.") for n in pipeline) else None,
631
+ "views": sorted(n[5:] for n in pipeline if n.startswith("view.")),
632
+ "bundles": inputs, "raw": Inputs({})}
633
+ if any(n in pipeline for n in RAW_STAGES):
634
+ from .cristages import boot_input
635
+ raws = {r["stable"]: r for r in index["rawFiles"]}
636
+ job = (self._inputs, "raw", [raws[s] for s in sel.raw], fetch)
637
+ if background and fetch:
638
+ pool = ThreadPoolExecutor(1, thread_name_prefix="nnnotes-raw")
639
+ self._later = pool.submit(*job)
640
+ pool.shutdown(wait=False)
641
+ facts["raw"] = Later(self._later)
642
+ else:
643
+ facts["raw"] = job[0](*job[1:])
644
+ facts["boot"] = boot_input(self.store, self.apk)
645
+ self.selected = sel
646
+ self._facts, self._names = facts, {}
647
+ return facts
648
+
649
+ def wait(self) -> None:
650
+ """Wait for the background fetch of facts(background=True), if any (its errors are raised here)."""
651
+ if self._later is not None:
652
+ self._later.result()
653
+
654
+ def cancel(self) -> None:
655
+ """Start no more fetches: a background fetch ends once the downloads under way are done."""
656
+ self._cancel.set()
657
+
658
+ def problems(self) -> list[dict]:
659
+ """Files of the selection that could not be read: {kind, stable, name, code, message}, sorted."""
660
+ return sorted(self._problems, key=lambda p: (p["kind"], p["stable"]))
661
+
662
+ def _cache_rel(self, kind: str, entry: dict, loc: dict) -> str:
663
+ from .addressables import remote_path
664
+ if kind == "bundle":
665
+ return f"bundles/{entry['name']}"
666
+ rel = remote_path(loc["internalId"])
667
+ if rel is None: # a raw file of the APK (CatalogFetcher._apk_file)
668
+ rel = apk_rel(loc["internalId"])
669
+ return "raw/" + rel.lstrip("/")
670
+
671
+ def _inputs(self, kind: str, entries: list[dict], fetch: bool) -> dict:
672
+ from .catalogdb import by_id
673
+ locs = by_id(self.index())
674
+ memo_kind = "bundles" if kind == "bundle" else "raw"
675
+ out, todo = {}, []
676
+ for e in entries:
677
+ loc = locs[e["location"]]
678
+ rel = self._cache_rel(kind, e, loc)
679
+ locators = ([{"kind": "cache", "path": rel}] if rel else []) + [
680
+ {"kind": "catalog", "catalog": self.version["id"], "location": e["location"]}]
681
+ path = self.cache / rel if self.cache is not None and rel else None
682
+ if path is not None and path.is_file() and path.stat().st_size > 0:
683
+ todo.append((e, locators, path))
684
+ continue
685
+ known = self.store.named(memo_kind, e["name"]) if e["name"] != e["stable"] else None
686
+ if known is not None: # a hashed file name: its recorded identity holds
687
+ out[e["stable"]] = Input(kind, known[0], known[1], e["name"], tuple(locators))
688
+ elif not e["remote"] and self.apk is None:
689
+ self._problem(kind, e, "source.absent", "a file of the APK, and [paths] apk is not set")
690
+ elif not e["remote"] and not self._in_apk(loc["internalId"]):
691
+ self._problem(kind, e, "source.absent", "not in the APK")
692
+ elif not fetch:
693
+ out[e["stable"]] = Pending(f"fetch:{e['name']}")
694
+ else:
695
+ todo.append((e, locators, None))
696
+
697
+ def one(job):
698
+ e, locators, path = job
699
+ if self._cancel.is_set():
700
+ return None
701
+ try:
702
+ if path is None:
703
+ path = self.fetcher.fetch_location(self.version["id"], e["location"])
704
+ sha, size = self.store.identify(path, memo_kind, e["name"])
705
+ except ConfigError:
706
+ raise
707
+ except KeyError as x: # not in the APK
708
+ self._problem(kind, e, "source.absent", f"not in the APK: {x.args[0] if x.args else x}")
709
+ return None
710
+ except Exception as x:
711
+ self._problem(kind, e, "source.error", f"{type(x).__name__}: {x}"[:300])
712
+ return None
713
+ return e["stable"], Input(kind, sha, size, e["name"], tuple(locators))
714
+
715
+ workers = max(1, getattr(self.args, "fetch_workers", None) or FETCH_WORKERS)
716
+ fetches = sum(1 for j in todo if j[2] is None)
717
+ if fetches:
718
+ self.log(f"fetching {fetches} {kind} files ({workers} at a time)")
719
+ with ThreadPoolExecutor(workers) as pool:
720
+ for r in pool.map(one, todo):
721
+ if r is not None:
722
+ out[r[0]] = r[1]
723
+ return Inputs(out)
724
+
725
+ def _in_apk(self, internal_id: str) -> bool:
726
+ """Whether the APK holds the file of a location (read from its directory, not fetched)."""
727
+ from .catalog import APK_AA_DIR
728
+ if self._apk_names is None:
729
+ import zipfile
730
+ try:
731
+ with zipfile.ZipFile(self.apk) as z:
732
+ self._apk_names = set(z.namelist())
733
+ except (OSError, zipfile.BadZipFile):
734
+ raise ConfigError(f"setting paths.apk: {self.apk} is not a readable APK") from None
735
+ return APK_AA_DIR + apk_rel(internal_id) in self._apk_names
736
+
737
+ def _problem(self, kind: str, e: dict, code: str, message: str) -> None:
738
+ self._problems.append({"kind": kind, "stable": e["stable"], "name": e["name"], "code": code,
739
+ "message": message})
740
+
741
+ # ---------------------------------------------------------------- explain
742
+ def explain(self, pipeline: list[str], params: dict, orch=None, layouts=(), placement=None) -> list[str]:
743
+ ctx = self.context()
744
+ c = ctx["catalog"]
745
+ lines = [f"store: {self.root}", f"cache: {self.cache if self.cache is not None else 'not set'}",
746
+ f"context: region {ctx['region'] or '-'}, language {ctx['language'] or '-'}, catalog "
747
+ f"{c['label'] or '-'} ({c['id'][:12]}), APK catalog {(c['apkCatalogSha'] or '-')[:12]}",
748
+ f"selection: {', '.join(self.selection)}",
749
+ f"pipeline: {', '.join(pipeline)}",
750
+ f"not installed: {', '.join(self.installed.missing) or 'none'}"]
751
+ for n in pipeline:
752
+ lines.append(f"params {n}: {contract.key_text(self.installed.stages[n].normalize(params.get(n)))}")
753
+ lines.append(f"fetch workers: {getattr(self.args, 'fetch_workers', None) or FETCH_WORKERS}")
754
+ for n in pipeline:
755
+ if n in self.calibration:
756
+ f = self.calibration[n]
757
+ lines.append(f"cost calibration {n}: CPU x{f['cpu']}, peak memory x{f['peak']} "
758
+ f"({f.get('samples', 0)} measured tasks)")
759
+ if orch is not None:
760
+ lines += orch.explain()
761
+ if layouts:
762
+ how = placement[1] if placement else f"{link_mode(self.args, self.cfg)}, probed when the files are placed"
763
+ lines.append(f"layouts: {', '.join(layouts)} (placement: {how})")
764
+ return lines
765
+
766
+ # ---------------------------------------------------------------- plan
767
+ def previous(self, since: str | None) -> dict | None:
768
+ if not since:
769
+ return self.store.latest_run(self.selection)
770
+ doc = self.store.run(since) if contract.is_sha256(since) else None
771
+ if doc is None:
772
+ runs = sorted(p.name[:-5] for p in (self.root / "runs").glob("*.json") if p.name.startswith(since))
773
+ if len(runs) != 1:
774
+ self.args.usage(f"--since {since}: {'no run' if not runs else f'{len(runs)} runs'} with this id")
775
+ doc = self.store.run(runs[0])
776
+ return doc
777
+
778
+ def plan(self, pipeline: list[str], params: dict, *, since=None, out=None, layouts=()):
779
+ from . import plan as plan_mod
780
+ facts = self.facts(pipeline, fetch=False)
781
+ p = plan_mod.plan(self.scheduled, pipeline, self.store, params=params, facts=facts,
782
+ previous=self.previous(since), workers=self.workers(), memory=self.memory())
783
+ if out is not None and layouts:
784
+ from . import layout
785
+ self.done({n["id"]: n["key"] for n in p.doc["nodes"] if n["status"] == "hit"})
786
+ hits = [(n["id"], n["key"]) for n in p.doc["nodes"] if n["status"] == "hit" and self._output(n["stage"])]
787
+ placed = self.layout_input(dict(hits))
788
+ for tid, key in hits:
789
+ placed.add(tid, self.store.result(key))
790
+ for name, d in layout_dirs(Path(out), layouts).items():
791
+ entries, _ = self.layout_entries(name, placed, store_derived=False)
792
+ p.doc["summary"]["layouts"][name] = layout.diff(d, entries)
793
+ return p
794
+
795
+ def _output(self, stage: str) -> bool:
796
+ src = self.installed.sources.get(stage)
797
+ return bool(src and src.output)
798
+
799
+ # ---------------------------------------------------------------- layouts
800
+ def layout_input(self, tasks: dict) -> LayoutInput:
801
+ """An empty LayoutInput for the output tasks `tasks` ({task id: key}) that knows the objects the derived
802
+ documents name: the results of the stages that make them are read here (small: the views) and their
803
+ documents made once with no paths, noting the objects asked for."""
804
+ stages, asked = self.installed.stages, _Asked()
805
+ makers = sorted(t for t in tasks if hasattr(stages[contract.parse_task_id(t)[0]], "derived"))
806
+ for tid in makers:
807
+ stages[contract.parse_task_id(tid)[0]].derived(tid, self.store.result(tasks[tid]), self.store, asked)
808
+ return LayoutInput(asked.objects_asked, makers)
809
+
810
+ def layout_entries(self, name: str, placed: LayoutInput, *, store_derived: bool = True
811
+ ) -> tuple[list[dict], dict]:
812
+ """The entries of layout `name` for the results of the output stages given to `placed` (derived documents
813
+ of the stages in `original`) and its report."""
814
+ from . import layout
815
+ stages = self.installed.stages
816
+ sources, named = [], []
817
+ for tid, doc in placed.results:
818
+ stage_name, subject = contract.parse_task_id(tid)
819
+ stage = stages[stage_name]
820
+ names = self.names(stage_name).get(subject) if name == "original" else None
821
+ if names:
822
+ sources += [layout.Source(tid, doc, n) for n in names]
823
+ if len(names) > 1:
824
+ named.append({"task": tid, "names": list(names)})
825
+ else:
826
+ sources.append(layout.Source(tid, doc, layout_root(stage, self.installed.sources[stage_name],
827
+ subject)))
828
+ index = self.index()
829
+ case = {loc["internalId"].lower(): loc["internalId"] for loc in index["locations"] if loc["kind"] == "asset"}
830
+ if name != "original":
831
+ return layout.entries(name, sources)
832
+ derives = lambda s: hasattr(stages[contract.parse_task_id(s.task)[0]], "derived") # noqa: E731
833
+ roots = {} # every object of a bundle under its export task's root
834
+ for s in sources:
835
+ if s.task.startswith(EXPORT_STAGE + ":") and s.root is not None:
836
+ for f in s.result["files"]:
837
+ roots.setdefault(f, s.root)
838
+ entries, report = layout.entries(name, [s for s in sources if not derives(s)], case=case,
839
+ object_roots=roots)
840
+ report["names"] = named
841
+ extra = self._derived(sources, entries, store_derived, placed.objects)
842
+ taken = {e["path"].casefold() for e in entries}
843
+ for e in extra:
844
+ if e["path"].casefold() in taken:
845
+ raise layout.LayoutError(f"derived document {e['id']}: {e['path']} is taken")
846
+ taken.add(e["path"].casefold())
847
+ return sorted(entries + extra, key=lambda e: (e["path"], e["id"])), report
848
+
849
+ def done(self, tasks: dict) -> None:
850
+ """The tasks whose results the layouts place, {task id: key} (after a run: every task with a result)."""
851
+ self._done, self._names = dict(tasks), {}
852
+
853
+ def names(self, stage_name: str) -> dict:
854
+ """{subject: catalog names} of a stage that names its tasks' files (stage.names(env): the CRI stages root a
855
+ content at each catalog name that refers to it, bundle-held content through the unity.export results;
856
+ layout-time data, never part of a key); {} for others. The environment has the planning facts and every
857
+ task given to done()."""
858
+ if stage_name not in self._names:
859
+ stage = self.installed.stages[stage_name]
860
+ got = {}
861
+ if hasattr(stage, "names"):
862
+ env = Env(self.store, self._facts)
863
+ for tid, key in sorted(self._done.items()):
864
+ env.done(tid, key)
865
+ got = stage.names(env)
866
+ self._names[stage_name] = {k: sorted(v) for k, v in got.items()}
867
+ return self._names[stage_name]
868
+
869
+ def _derived(self, sources, entries: list[dict], store_derived: bool, objects: dict) -> list[dict]:
870
+ from . import layout
871
+ stages = self.installed.stages
872
+ makers = list({s.task: s for s in sources
873
+ if hasattr(stages[contract.parse_task_id(s.task)[0]], "derived")}.values())
874
+ if not makers:
875
+ return []
876
+ paths = LayoutPaths(layout.paths(entries), objects)
877
+ out = []
878
+ for s in makers:
879
+ stage = stages[contract.parse_task_id(s.task)[0]]
880
+ for rel, data in stage.derived(s.task, s.result, self.store, paths):
881
+ sha = self.store.put(data) if store_derived else contract.sha256(data)
882
+ out.append({"path": layout.escape_path(rel.split("/")), "sha256": sha, "size": len(data),
883
+ "id": contract.artifact_id(s.task, "derived:" + rel)})
884
+ return out
885
+
886
+
887
+ class LayoutInput:
888
+ """What the layouts read of the output stages' results, given one at a time (add) and not kept whole (the
889
+ results of a full catalog take GBs as objects): of each result its artifacts with the fields the layouts use
890
+ (id, content, the object facts) and, for an export task, the serialized files of its objects; the whole result
891
+ of a stage with derived documents (`makers`: small, the views); the artifacts of the objects those documents
892
+ name (`asked`: object ids; an object contained in another: the artifact it is part of)."""
893
+
894
+ def __init__(self, asked=(), makers=()):
895
+ self.asked, self.makers = set(asked), set(makers)
896
+ self.results: list[tuple[str, dict]] = []
897
+ self.objects: dict[str, set] = {}
898
+
899
+ def add(self, tid: str, doc: dict) -> None:
900
+ if self.asked:
901
+ for it in doc["items"]:
902
+ if it["object"] in self.asked:
903
+ arts = it.get("artifacts") or ([it["in"]] if "in" in it else [])
904
+ self.objects.setdefault(it["object"], set()).update(arts)
905
+ if tid in self.makers:
906
+ self.results.append((tid, doc))
907
+ return
908
+ arts = []
909
+ for a in doc["artifacts"]:
910
+ c = a["content"]
911
+ slim = {"id": a["id"], "content": {"sha256": c["sha256"], "size": c["size"], "ext": c["ext"]}}
912
+ obj = a.get("provenance", {}).get("object")
913
+ if obj:
914
+ slim["provenance"] = {"object": obj}
915
+ arts.append(slim)
916
+ files = {}
917
+ if tid.startswith(EXPORT_STAGE + ":"):
918
+ for it in doc["items"]:
919
+ files.setdefault(contract.parse_object_id(it["object"])[0], None)
920
+ self.results.append((tid, {"artifacts": arts, "files": list(files)}))
921
+
922
+
923
+ class _Asked:
924
+ """LayoutPaths for a first making of the derived documents: places nothing, notes the objects asked for."""
925
+
926
+ def __init__(self):
927
+ self.objects_asked: set[str] = set()
928
+
929
+ def artifact(self, aid: str) -> None:
930
+ return None
931
+
932
+ def objects(self, oid: str) -> list[str]:
933
+ self.objects_asked.add(oid)
934
+ return []
935
+
936
+
937
+ class Inputs(Mapping):
938
+ """{subject: Input} of the files of a selection, read-only; a subject whose bytes are not known yet (not
939
+ fetched) is listed, and reading it raises its Pending ("fetch:<file name>"), as the stages expect."""
940
+
941
+ def __init__(self, items: dict):
942
+ self._items = dict(sorted(items.items()))
943
+
944
+ def __getitem__(self, subject: str) -> Input:
945
+ v = self._items[subject]
946
+ if isinstance(v, Pending):
947
+ raise v
948
+ return v
949
+
950
+ def __contains__(self, subject) -> bool:
951
+ return subject in self._items
952
+
953
+ def __iter__(self):
954
+ return iter(self._items)
955
+
956
+ def __len__(self) -> int:
957
+ return len(self._items)
958
+
959
+ def waiting(self) -> list[str]:
960
+ """The subjects not fetched yet."""
961
+ return [k for k, v in self._items.items() if isinstance(v, Pending)]
962
+
963
+
964
+ class Later(Mapping):
965
+ """A mapping a background job makes (Future): reading it waits for the job."""
966
+
967
+ def __init__(self, future):
968
+ self._future = future
969
+
970
+ def __getitem__(self, key):
971
+ return self._future.result()[key]
972
+
973
+ def __contains__(self, key) -> bool:
974
+ return key in self._future.result()
975
+
976
+ def __iter__(self):
977
+ return iter(self._future.result())
978
+
979
+ def __len__(self) -> int:
980
+ return len(self._future.result())
981
+
982
+ def waiting(self) -> list[str]:
983
+ return self._future.result().waiting()
984
+
985
+
986
+ class LayoutPaths:
987
+ """What a stage's derived documents may read of the `original` layout: the path of an artifact, the artifacts
988
+ of an object (a contained object: the artifact it is part of)."""
989
+
990
+ def __init__(self, paths: dict[str, str], objects: dict[str, list[str]]):
991
+ self._paths, self._objects = paths, objects
992
+
993
+ def artifact(self, aid: str) -> str | None:
994
+ return self._paths.get(aid)
995
+
996
+ def objects(self, oid: str) -> list[str]:
997
+ return sorted(set(self._objects.get(oid, ())))
998
+
999
+
1000
+ def layout_dirs(out: Path, names) -> dict[str, Path]:
1001
+ """One layout: in OUT; several: each in OUT/<name>."""
1002
+ names = list(names)
1003
+ return {names[0]: out} if len(names) == 1 else {n: out / n for n in names}
1004
+
1005
+
1006
+ def parse_layouts(spec: str) -> list[str]:
1007
+ names = [n.strip() for n in (spec or "").split(",") if n.strip()]
1008
+ if not names or any(n not in LAYOUT_NAMES for n in names) or len(set(names)) != len(names):
1009
+ raise ValueError(f"--layout {spec}: expected {' or '.join(LAYOUT_NAMES)}, comma-separated")
1010
+ return names
1011
+
1012
+
1013
+ def _apk_version(apk: Path | None) -> str | None:
1014
+ if apk is None or not apk.is_file():
1015
+ return None
1016
+ from .gameapi import apk_version_name
1017
+ return apk_version_name(apk)
1018
+
1019
+
1020
+ # ---------------------------------------------------------------- export
1021
+ class Totals:
1022
+ """What export reports of the results besides the run's counts, from its one reading of each (Run.scan): the
1023
+ census totals of `subjects` ({class: objects}, the census artifacts' facts) and the results of the view tasks
1024
+ (small; their master data tables and gaps)."""
1025
+
1026
+ def __init__(self, subjects):
1027
+ self.want = {contract.task_id("unity.census", s) for s in subjects}
1028
+ self.classes: Counter = Counter()
1029
+ self.views: list[tuple[str, dict]] = []
1030
+
1031
+ def add(self, tid: str, doc: dict) -> None:
1032
+ if tid in self.want:
1033
+ for a in doc["artifacts"]:
1034
+ self.classes.update(a["semantics"].get("facts", {}).get("classes", {}))
1035
+ if tid.startswith("view."):
1036
+ self.views.append((tid, doc))
1037
+
1038
+ def census(self) -> dict[str, int]:
1039
+ return dict(sorted(self.classes.items()))
1040
+
1041
+
1042
+ def view_gaps(results) -> int:
1043
+ return sum(int(a["semantics"].get("facts", {}).get("gaps", 0)) for tid, doc in results
1044
+ if tid.startswith("view.") for a in doc["artifacts"])
1045
+
1046
+
1047
+ def _write_report(d: Path, name: str, doc) -> Path:
1048
+ from .store import write_file
1049
+ p = d / name
1050
+ write_file(p, contract.encode(doc))
1051
+ return p
1052
+
1053
+
1054
+ def cmd_export(args, cfg, common):
1055
+ from . import layout
1056
+ from .orchestrate import Orchestrator, failure
1057
+ try:
1058
+ names = parse_layouts(args.layout)
1059
+ except ValueError as e:
1060
+ args.usage(str(e))
1061
+ use(cfg) # the tools the atoms name are part of the keys
1062
+ ws = Workspace(args, cfg, common)
1063
+ pipeline = ws.pipeline()
1064
+ params = ws.params(pipeline)
1065
+ orch = Orchestrator(ws.store, ws.scheduled, workers=ws.workers(), memory=ws.memory(),
1066
+ recycle_tasks=RECYCLE_TASKS, recycle_rss=ws.recycle_rss(), initializer=_worker_init,
1067
+ initargs=(cfg,), log=ws.log)
1068
+ out = Path(args.out)
1069
+ placement = None
1070
+ if not args.dry_run: # before any work: an explicit method that cannot work stops it
1071
+ try:
1072
+ placement = layout.probe(out, ws.root / "cas" / "sha256", link_mode(args, cfg))
1073
+ except layout.LayoutError as e:
1074
+ args.usage(str(e))
1075
+ if args.explain:
1076
+ for line in ws.explain(pipeline, params, orch, names, placement):
1077
+ print(line, file=sys.stderr)
1078
+ if args.dry_run:
1079
+ sys.stdout.write(ws.plan(pipeline, params, out=out, layouts=names).text())
1080
+ return
1081
+ ws.run_index()
1082
+ facts = ws.facts(pipeline, fetch=True, background=True)
1083
+ sel = ws.selected
1084
+ ws.log(f"{len(sel.selected)} bundles selected ({len(sel.census)} with their dependencies), "
1085
+ f"{len(sel.raw)} raw files" + (" (fetched while the bundle stages run)" if isinstance(facts["raw"], Later)
1086
+ else "")
1087
+ + f"; {sum(p['kind'] == 'bundle' for p in ws.problems())} bundles not readable")
1088
+ aborted = None
1089
+ try:
1090
+ run = orch.run(pipeline, params=params, facts=facts, context=ws.context(), selection=ws.selection)
1091
+ except ConfigError as e:
1092
+ run = getattr(e, "run", None)
1093
+ if run is None:
1094
+ ws.cancel()
1095
+ raise
1096
+ aborted = str(e)
1097
+ except KeyboardInterrupt:
1098
+ ws.cancel()
1099
+ print("nnnotes: interrupted (results written so far stay valid)", file=sys.stderr)
1100
+ sys.exit(EXIT_ABORTED)
1101
+ try:
1102
+ ws.wait()
1103
+ except ConfigError as e: # the raw files' fetch stopped (the run stopped with it)
1104
+ aborted = aborted or str(e)
1105
+ if isinstance(facts["raw"], Later) and aborted is None:
1106
+ ws.log(f"{sum(p['kind'] == 'raw' for p in ws.problems())} raw files not readable")
1107
+ stage_of ={"bundle": "unity.census", "raw": next((n for n in RAW_STAGES if n in pipeline), "cri.audio")}
1108
+ for p in ws.problems():
1109
+ if p["code"] == "source.error":
1110
+ run.failures_.append(failure(contract.task_id(stage_of[p["kind"]], p["stable"]), stage_of[p["kind"]],
1111
+ p["code"], p["message"], p["name"]))
1112
+ done = {tid: t["key"] for tid, t in run.tasks.items() if t["status"] != "failed"}
1113
+ ws.done(done)
1114
+ output = {tid for tid in done if ws._output(contract.parse_task_id(tid)[0])}
1115
+ placed = ws.layout_input({tid: done[tid] for tid in output})
1116
+ totals = Totals(sel.selected)
1117
+
1118
+ def visit(tid, doc):
1119
+ totals.add(tid, doc)
1120
+ if tid in output:
1121
+ placed.add(tid, doc)
1122
+ run.scan(visit, census=totals.census) # the only reading of the results
1123
+ run.context["master"] = ws.master_context(totals.views)
1124
+ reports = out / REPORTS
1125
+ written, shas, unplaced = {}, {}, 0
1126
+ for name, d in layout_dirs(out, names).items():
1127
+ entries, report = ws.layout_entries(name, placed)
1128
+ w = layout.materialize(d, name, {}, entries, layout.from_store(ws.store), link=placement[0])
1129
+ shas[name] = w["sha256"]
1130
+ written[name] = {"dir": str(d), "files": len(w["manifest"]["entries"]), "write": w["write"],
1131
+ "remove": w["remove"], "keep": w["keep"], "cloned": w["cloned"], "linked": w["linked"],
1132
+ "copied": w["copied"], "failed": len(w["failed"]), "collisions": len(report["collisions"]),
1133
+ "shared": len(report.get("shared", []))}
1134
+ unplaced += len(w["failed"])
1135
+ ws.log(f"layout {name}: placed {w['cloned'] + w['linked'] + w['copied']}: {w['cloned']} cloned / "
1136
+ f"{w['linked']} hard-linked (read-only) / {w['copied']} copied"
1137
+ + (f"; {len(w['failed'])} not placed (layout-{name}.json)" if w["failed"] else ""))
1138
+ _write_report(reports, f"layout-{name}.json", dict(report, failed=w["failed"]) if w["failed"] else report)
1139
+ manifest = run.write(shas, placement={"method": placement[0], "reason": placement[1]})
1140
+ write_calibration(ws.root, calibrate(run.log, ws.calibration, orch.worker_bytes))
1141
+ _write_report(reports, "run.json", manifest)
1142
+ _write_report(reports, "coverage.json", run.coverage(totals.census()))
1143
+ _write_report(reports, "failures.json", run.failures_doc())
1144
+ _write_report(reports, "sources.json", {"problems": ws.problems()})
1145
+ s = manifest["summary"]
1146
+ gaps = view_gaps(totals.views)
1147
+ common.print_json({"run": manifest["id"], "selection": ws.selection, "tasks": {k: s[k] for k in (
1148
+ "tasks", "hit", "ran", "failed")}, "items": s["items"], "artifacts": s["artifacts"],
1149
+ "failures": len(run.failures()), "sources": dict(sorted(Counter(p["code"] for p in ws.problems()).items())),
1150
+ "viewGaps": gaps, "placement": manifest["placement"], "layouts": written, "reports": str(reports)})
1151
+ if aborted:
1152
+ print(f"nnnotes: aborted: {aborted}", file=sys.stderr)
1153
+ sys.exit(EXIT_ABORTED)
1154
+ if run.failures() or unplaced or (args.strict and gaps):
1155
+ sys.exit(EXIT_FAILED)
1156
+
1157
+
1158
+ def link_mode(args, cfg) -> str:
1159
+ """How export places the layout files (layout.LINKS): --link, else [export] link, else auto."""
1160
+ from .layout import LINKS
1161
+ v = getattr(args, "link", None)
1162
+ if v:
1163
+ return v
1164
+ v = cfg.get("export", "link") or "auto"
1165
+ if v not in LINKS:
1166
+ raise ConfigError(f"setting export.link: {v!r} is not one of {', '.join(LINKS)}")
1167
+ return v
1168
+
1169
+
1170
+ # ---------------------------------------------------------------- plan
1171
+ def cmd_plan(args, cfg, common):
1172
+ from .orchestrate import Orchestrator
1173
+ layouts = []
1174
+ if args.out:
1175
+ try:
1176
+ layouts = parse_layouts(args.layout)
1177
+ except ValueError as e:
1178
+ args.usage(str(e))
1179
+ use(cfg) # the tools the atoms name are part of the keys
1180
+ ws = Workspace(args, cfg, common)
1181
+ pipeline = ws.pipeline()
1182
+ params = ws.params(pipeline)
1183
+ if args.explain:
1184
+ for line in ws.explain(pipeline, params, layouts=layouts):
1185
+ print(line, file=sys.stderr)
1186
+ if args.census:
1187
+ ws.run_index()
1188
+ facts = ws.facts(["catalog.index", "unity.census"], fetch=True)
1189
+ Orchestrator(ws.store, ws.scheduled, workers=ws.workers(), memory=ws.memory(),
1190
+ recycle_tasks=RECYCLE_TASKS, recycle_rss=ws.recycle_rss(), initializer=_worker_init,
1191
+ initargs=(cfg,), log=ws.log).run(["catalog.index", "unity.census"], facts=facts)
1192
+ p = ws.plan(pipeline, params, since=args.since, out=args.out, layouts=layouts)
1193
+ if args.emit_tasks:
1194
+ files = p.emit(args.emit_tasks)
1195
+ ws.log(f"{len(files)} task descriptions in {args.emit_tasks}")
1196
+ if args.why:
1197
+ common.print_json(why(ws, p, args.why, args.since))
1198
+ elif args.json:
1199
+ common.print_json(p.doc)
1200
+ else:
1201
+ sys.stdout.write(p.text())
1202
+ if args.check:
1203
+ sys.exit(p.check())
1204
+
1205
+
1206
+ def why(ws: Workspace, p, tid: str, since=None) -> dict:
1207
+ """Why a task of a plan has its status: its reasons, and its key parts beside those of the previous run."""
1208
+ node = next((n for n in p.doc["nodes"] if n["id"] == tid), None)
1209
+ if node is None:
1210
+ ws.args.usage(f"--why {tid}: no task with this id in the plan")
1211
+ new = None
1212
+ if node["key"] is not None:
1213
+ t = next((t for t in p.tasks if t.key == node["key"]), None)
1214
+ doc = None if t is not None else ws.store.result(node["key"])
1215
+ new = t.key_parts() if t is not None else (doc["keyParts"] if doc else None)
1216
+ prev = ws.previous(since)
1217
+ old_task = next((t for t in (prev or {}).get("tasks", []) if t["id"] == tid), None)
1218
+ old = None
1219
+ if old_task is not None and old_task.get("key"):
1220
+ doc = ws.store.result(old_task["key"])
1221
+ old = doc["keyParts"] if doc else None
1222
+ return {"task": tid, "status": node["status"], "key": node["key"], "reasons": node["reasons"],
1223
+ "previous": {"run": prev["id"], "key": old_task.get("key"), "status": old_task["status"]}
1224
+ if old_task is not None else None, "keyParts": {"old": old, "new": new}}
1225
+
1226
+
1227
+ # ---------------------------------------------------------------- run-stage
1228
+ def _task_files(items) -> list[Path]:
1229
+ out = []
1230
+ for s in items:
1231
+ p = Path(s)
1232
+ out += sorted(p.glob("*.json")) if p.is_dir() else [p]
1233
+ return out
1234
+
1235
+
1236
+ def cmd_run_stage(args, cfg, common):
1237
+ from .orchestrate import run_one
1238
+ from .store import Store
1239
+ problems, tasks = [], []
1240
+ for f in _task_files(args.tasks):
1241
+ try:
1242
+ tasks.append(Task.from_json(contract.loads(f.read_bytes())))
1243
+ except OSError as e:
1244
+ args.usage(f"{f}: {e.strerror or e}")
1245
+ except (IncompatibleTask, ValueError) as e:
1246
+ problems.append(f"{f}: {e}")
1247
+ inst = installed(sorted({t.stage for t in tasks}))
1248
+ for t in tasks:
1249
+ s = inst.stages.get(t.stage)
1250
+ if s is None:
1251
+ problems.append(f"task {t.id}: no stage {t.stage} in this installation")
1252
+ elif s.version != t.version:
1253
+ problems.append(f"task {t.id}: stage {t.stage} is version {s.version} here, the task was described "
1254
+ f"for version {t.version}")
1255
+ if problems:
1256
+ for m in problems:
1257
+ print(f"nnnotes: incompatible task: {m}", file=sys.stderr)
1258
+ sys.exit(EXIT_INCOMPATIBLE)
1259
+ root = store_root(args, cfg)
1260
+ cache = cfg.path("paths", "cache")
1261
+ store = Store(root, cache=cache, fetch=CatalogFetcher(root, cache, cfg) if args.fetch else None)
1262
+ use(cfg)
1263
+ out, bad = [], 0
1264
+ for t in tasks:
1265
+ r = run_one(t, store, inst.stages, args.force)
1266
+ if r["ok"]:
1267
+ out.append({"id": t.id, "key": t.key, "status": r["status"], "result": r["resultStatus"]})
1268
+ bad += r["resultStatus"] != "ok"
1269
+ else:
1270
+ out.append({"id": t.id, "key": t.key, "status": "failed", "code": r["code"], "message": r["message"]})
1271
+ bad += 1
1272
+ if r["code"] == "config":
1273
+ break
1274
+ n = Counter(o["status"] for o in out)
1275
+ common.print_json({"tasks": out, "summary": {"tasks": len(tasks), "hit": n["hit"], "ran": n["ran"],
1276
+ "failed": n["failed"]}})
1277
+ if bad:
1278
+ sys.exit(EXIT_FAILED)
1279
+
1280
+
1281
+ # ---------------------------------------------------------------- catalogs
1282
+ def _db(args, cfg):
1283
+ from .catalogdb import CatalogDB
1284
+ return CatalogDB(store_root(args, cfg))
1285
+
1286
+
1287
+ def _version_line(v: dict) -> str:
1288
+ apk = v["apk"]["sha256"][:12] if v.get("apk") else "-"
1289
+ return (f"{v['seq']:>4} {v['id'][:12]} {','.join(v['labels']) or '-':<24} {v.get('region') or '-':<6} "
1290
+ f"{v.get('language') or '-':<8} remote {v['remote']['sha256'][:12]} apk {apk} "
1291
+ f"resource {v.get('resourceVersion') or '-'}")
1292
+
1293
+
1294
+ def cmd_catalogs_list(args, cfg, common):
1295
+ from .catalogdb import CATALOGS
1296
+ vs = _db(args, cfg).versions()
1297
+ if args.json:
1298
+ common.print_json({"schema": CATALOGS, "versions": vs})
1299
+ return
1300
+ for v in vs:
1301
+ print(_version_line(v))
1302
+ print(f"# {len(vs)} catalog versions", file=sys.stderr)
1303
+
1304
+
1305
+ def _apk_catalog(args, cfg) -> bytes | None:
1306
+ from .catalogdb import apk_catalog
1307
+ if getattr(args, "apk_catalog", None):
1308
+ return Path(args.apk_catalog).read_bytes()
1309
+ apk = cfg.path("paths", "apk")
1310
+ if apk is None:
1311
+ return None
1312
+ if not apk.is_file():
1313
+ raise ConfigError(f"setting paths.apk: file {apk} not found")
1314
+ return apk_catalog(apk)
1315
+
1316
+
1317
+ def cmd_catalogs_import(args, cfg, common):
1318
+ try:
1319
+ remote = Path(args.file).read_bytes()
1320
+ except OSError as e:
1321
+ args.usage(f"{args.file}: {e.strerror or e}")
1322
+ try:
1323
+ v = _db(args, cfg).add(remote, _apk_catalog(args, cfg), label=args.label,
1324
+ region=cfg.get("catalog", "region"), language=cfg.get("catalog", "language"),
1325
+ resource_version=args.resource_version,
1326
+ apk_version_name=_apk_version(cfg.path("paths", "apk")))
1327
+ except ValueError as e:
1328
+ args.usage(str(e))
1329
+ print(_version_line(v))
1330
+
1331
+
1332
+ def cmd_catalogs_fetch(args, cfg, common):
1333
+ from . import catalogdb
1334
+ region = cfg.region()
1335
+ cdn = cfg.cdn(region)
1336
+ language = cfg.require("catalog", "language")
1337
+ try:
1338
+ data, hash_text = catalogdb.fetch(cdn, language)
1339
+ except OSError as e: # URLError, HTTPError, timeouts (the message has no URL)
1340
+ sys.exit(f"nnnotes: catalog_main_{language}.bin could not be fetched: {type(e).__name__}: "
1341
+ f"{getattr(e, 'reason', None) or getattr(e, 'code', None) or ''}".rstrip(": "))
1342
+ resource = None
1343
+ if cfg.has(f"servers.{region}", "api"):
1344
+ from . import gameapi
1345
+ try:
1346
+ resource = gameapi.master_version(cfg, region).resource_version
1347
+ except gameapi.GameApiError as e:
1348
+ print(f"nnnotes: no resource version (the label defaults to the catalog's sha256): {e}",
1349
+ file=sys.stderr)
1350
+ try:
1351
+ v = _db(args, cfg).add(data, _apk_catalog(args, cfg), label=args.label, region=region, language=language,
1352
+ hash_text=hash_text, resource_version=resource,
1353
+ apk_version_name=_apk_version(cfg.path("paths", "apk")))
1354
+ except ValueError as e:
1355
+ args.usage(str(e))
1356
+ print(_version_line(v))
1357
+
1358
+
1359
+ def addresses_of(store, vid: str) -> dict | None:
1360
+ """The address table (link.addresses) of the latest run over a catalog version, None when no run has one."""
1361
+ runs = sorted((store.root / "runs").glob("*.json"), key=lambda p: p.stat().st_mtime, reverse=True)
1362
+ for p in runs:
1363
+ try:
1364
+ doc = contract.loads(p.read_bytes())
1365
+ except (OSError, ValueError):
1366
+ continue
1367
+ if (doc.get("context", {}).get("catalog") or {}).get("id") != vid:
1368
+ continue
1369
+ t = next((t for t in doc["tasks"] if t["id"] == contract.task_id("link.addresses", CATALOG_SUBJECT)
1370
+ and t["status"] != "failed"), None)
1371
+ res = store.result(t["key"]) if t else None
1372
+ if res:
1373
+ (a,) = res["artifacts"]
1374
+ return contract.loads(store.read(a["content"]["sha256"]))
1375
+ return None
1376
+
1377
+
1378
+ def cmd_catalogs_diff(args, cfg, common):
1379
+ from . import catalogdb
1380
+ from .store import Store
1381
+ db = _db(args, cfg)
1382
+ vs = []
1383
+ for ref in (args.old, args.new):
1384
+ try:
1385
+ vs.append(db.get(ref))
1386
+ except (KeyError, ValueError) as e:
1387
+ args.usage(f"{ref}: {e.args[0] if e.args else e}")
1388
+ store = Store(store_root(args, cfg))
1389
+ a, b = vs
1390
+ d = catalogdb.diff(db.index(a), db.index(b), addresses_of(store, a["id"]), addresses_of(store, b["id"]))
1391
+ if args.json:
1392
+ common.print_json(d)
1393
+ else:
1394
+ sys.stdout.buffer.write(catalogdb.format_diff(d).encode("utf-8"))
1395
+ sys.stdout.buffer.flush()
1396
+
1397
+
1398
+ # ---------------------------------------------------------------- store
1399
+ def cmd_store_verify(args, cfg, common):
1400
+ from .store import Store
1401
+ r = Store(store_root(args, cfg)).verify(quick=args.quick, workers=args.workers or FETCH_WORKERS)
1402
+ common.print_json(r)
1403
+ if r["problems"]:
1404
+ sys.exit(EXIT_FAILED)
1405
+
1406
+
1407
+ # ---------------------------------------------------------------- parser
1408
+ def default_common():
1409
+ """The helpers of the command line the handlers use (open_catalog, print_json)."""
1410
+ from . import cli
1411
+ return SimpleNamespace(open_catalog=cli.open_catalog, print_json=cli._print_json)
1412
+
1413
+
1414
+ def _gib(s: str) -> float:
1415
+ try:
1416
+ v = float(s)
1417
+ except ValueError:
1418
+ raise argparse.ArgumentTypeError(f"{s}: expected a size in GiB") from None
1419
+ if v <= 0:
1420
+ raise argparse.ArgumentTypeError(f"{s}: expected a positive size in GiB")
1421
+ return v
1422
+
1423
+
1424
+ def _store_arg(c) -> None:
1425
+ c.add_argument("--store", help="store directory ([paths] store; default <cache>/store)")
1426
+
1427
+
1428
+ def _run_args(c) -> None:
1429
+ """The selection and parameters shared by export and plan."""
1430
+ c.add_argument("--select", action="append", metavar="SEL",
1431
+ help="group:<group>, key:<key prefix> or bundle:<glob> (repeatable; default: everything)")
1432
+ c.add_argument("--views", metavar="all|none|a,b",
1433
+ help="semantic views to build (default: all when master data is set, else none)")
1434
+ c.add_argument("--catalog-version", metavar="LABEL|SHA",
1435
+ help="an imported catalog version (`catalogs list`; default: the current catalog)")
1436
+ c.add_argument("--png-level", type=int, metavar="N", help="PNG compression level 0-9 (changes the output)")
1437
+ c.add_argument("--flac-level", type=int, metavar="N",
1438
+ help="FLAC compression level 0-12 of the CRI audio (default 8; every level decodes to the same "
1439
+ "samples; changes the files)")
1440
+ c.add_argument("--only-class", metavar="C,...", help="export only objects of these classes")
1441
+ c.add_argument("--workers", type=int,
1442
+ help="worker processes (default: the CPUs this process may use; 0: this process)")
1443
+ c.add_argument("--memory", type=_gib, metavar="GiB",
1444
+ help="memory budget of the running tasks and workers (default: 80 %% of the physical memory)")
1445
+ c.add_argument("--fetch-workers", type=int, metavar="N", help=f"parallel downloads (default {FETCH_WORKERS})")
1446
+ c.add_argument("--layout", default="original", help="original, cas or original,cas (default original)")
1447
+ c.add_argument("--explain", action="store_true", help="print every setting of the run first")
1448
+ _store_arg(c)
1449
+
1450
+
1451
+ def register(sub, common=None) -> None:
1452
+ """Add the asset commands to the command line's subparsers. `common`: the helpers of cli (open_catalog,
1453
+ print_json; default_common())."""
1454
+ common = common or default_common()
1455
+
1456
+ def bind(fn):
1457
+ return lambda args, cfg: fn(args, cfg, common)
1458
+
1459
+ c = sub.add_parser("export", help="every object of the selected bundles -> common formats (store + layouts)")
1460
+ c.add_argument("-o", "--out", required=True, help="output directory")
1461
+ _run_args(c)
1462
+ c.add_argument("--link", choices=("auto", "clone", "hard", "copy"),
1463
+ help="how the layout files are made from the store: auto (default; [export] link): a clone where "
1464
+ "the file system makes them, else a hard link (read-only), else a copy; or that method only")
1465
+ c.add_argument("--strict", action="store_true", help="exit 1 when a view has gaps")
1466
+ c.add_argument("--dry-run", action="store_true", help="print the plan, run nothing")
1467
+ c.set_defaults(func=bind(cmd_export), usage=c.error)
1468
+
1469
+ c = sub.add_parser("plan", help="what export would run and why, without running it")
1470
+ c.add_argument("-o", "--out", help="output directory of the export (counts the layout files to write)")
1471
+ _run_args(c)
1472
+ c.add_argument("--json", action="store_true", help="the plan document (nnnotes.plan/1)")
1473
+ c.add_argument("--since", metavar="RUN", help="explain against this run (default: the latest of the selection)")
1474
+ c.add_argument("--check", action="store_true", help="exit 1 when anything would run or change")
1475
+ c.add_argument("--census", action="store_true", help="first fetch and census the bundles without one")
1476
+ c.add_argument("--emit-tasks", metavar="DIR", help="write the runnable tasks as task descriptions")
1477
+ c.add_argument("--why", metavar="TASK", help="the reasons and key parts of one task")
1478
+ c.set_defaults(func=bind(cmd_plan), usage=c.error)
1479
+
1480
+ c = sub.add_parser("run-stage", help="run task descriptions (nnnotes.task/1) into the store")
1481
+ c.add_argument("tasks", nargs="+", metavar="TASK", help="task description files, or directories of them")
1482
+ _store_arg(c)
1483
+ c.add_argument("--fetch", action="store_true", help="fetch inputs through their catalog locators")
1484
+ c.add_argument("--force", action="store_true", help="run tasks whose result is stored (it must not change)")
1485
+ c.set_defaults(func=bind(cmd_run_stage), usage=c.error)
1486
+
1487
+ c = sub.add_parser("catalogs", help="catalog versions of the store")
1488
+ csub = c.add_subparsers(dest="catalogs_cmd", required=True, metavar="<catalogs command>")
1489
+ m = csub.add_parser("list", help="the imported catalog versions")
1490
+ m.add_argument("--json", action="store_true", help="the versions as JSON")
1491
+ _store_arg(m)
1492
+ m.set_defaults(func=bind(cmd_catalogs_list), usage=m.error)
1493
+ m = csub.add_parser("import", help="import a catalog file (with the APK's catalog when [paths] apk is set)")
1494
+ m.add_argument("file", help="catalog_main_<language>.bin")
1495
+ m.add_argument("--label", help="label of the version (default: its resource version, else the sha256 prefix)")
1496
+ m.add_argument("--apk-catalog", help="the APK's catalog.bin (default: read from [paths] apk)")
1497
+ m.add_argument("--resource-version", help="the game's resource version of this catalog")
1498
+ _store_arg(m)
1499
+ m.set_defaults(func=bind(cmd_catalogs_import), usage=m.error)
1500
+ m = csub.add_parser("fetch", help="fetch the region's current catalog and import it")
1501
+ m.add_argument("--label", help="label of the version (default: the resource version when an API root is set)")
1502
+ _store_arg(m)
1503
+ m.set_defaults(func=bind(cmd_catalogs_fetch), usage=m.error)
1504
+ m = csub.add_parser("diff", help="keys, bundles, raw files (and objects) that differ between two versions")
1505
+ m.add_argument("old", help="label, version id or sha256 prefix")
1506
+ m.add_argument("new", help="label, version id or sha256 prefix")
1507
+ m.add_argument("--json", action="store_true", help="the diff document (nnnotes.catalog-diff/1)")
1508
+ _store_arg(m)
1509
+ m.set_defaults(func=bind(cmd_catalogs_diff), usage=m.error)
1510
+
1511
+ c = sub.add_parser("store", help="the store")
1512
+ ssub = c.add_subparsers(dest="store_cmd", required=True, metavar="<store command>")
1513
+ m = ssub.add_parser("verify", help="check every stored content and result")
1514
+ m.add_argument("--quick", action="store_true", help="check sizes only, do not hash the contents")
1515
+ m.add_argument("--workers", type=int, help=f"parallel hashing (default {FETCH_WORKERS})")
1516
+ _store_arg(m)
1517
+ m.set_defaults(func=bind(cmd_store_verify), usage=m.error)
1518
+
1519
+
1520
+ def main(argv=None) -> None:
1521
+ """The asset commands alone, with the global flags of the command line (tests; `nnnotes` has them too)."""
1522
+ from .cli import load_config
1523
+ p = argparse.ArgumentParser(prog="nnnotes")
1524
+ for flag in ("--config", "--region", "--language", "--catalog", "--cache", "--master", "--apk", "--ffmpeg",
1525
+ "--vgmstream", "--node"):
1526
+ p.add_argument(flag)
1527
+ sub = p.add_subparsers(dest="cmd", required=True, metavar="<command>")
1528
+ register(sub)
1529
+ args = p.parse_args(argv)
1530
+ try:
1531
+ cfg = load_config(args)
1532
+ args.func(args, cfg)
1533
+ except ConfigError as e:
1534
+ print(f"nnnotes: {e}", file=sys.stderr)
1535
+ sys.exit(EXIT_USAGE)
1536
+
1537
+
1538
+ if __name__ == "__main__": # pragma: no cover
1539
+ main()