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/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()
|