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/views.py ADDED
@@ -0,0 +1,966 @@
1
+ """Master data views: which catalog objects the rows of a master table name, role by role.
2
+
3
+ A view is one master table read through its rules (package data `viewrules.json`, "format": 1, a version per view;
4
+ docs/views.md): every row gives values (`vars`, columns or chains of columns through other tables), names in the five
5
+ text languages (`names`: MasterText id columns, languages.texts), and per role an address the game formats from
6
+ those values. An address is a catalog key, optionally with an Addressables sub-object: `key[sub]`. Resolving it
7
+ (`resolve`) looks the key up in an address index (catalog key -> the objects of its container path in the order the
8
+ bundle stores them, from the censuses) and picks the object the Addressables loader returns for a load of the
9
+ expected class:
10
+
11
+ [sub] given the first object named `sub` of the expected class
12
+ no [sub] the first object of the expected class (a Sprite load of a sprite sheet: its first sprite)
13
+
14
+ With no expected class (`any`), the first object. A list of classes tries them in order. When several objects fit,
15
+ the entry says how many (`among`).
16
+
17
+ Statuses: ok (an object fits), missing-key (not a catalog key, or no census object under it), missing-sub (no object
18
+ fits), not-applicable (the role's `when` condition is false), no-value (a value the address needs is empty or
19
+ absent). A role can be enumerated (`each`: fixed values, a list column, or the catalog keys under a formatted
20
+ prefix) and polymorphic references (a GameResourceType and an id) are resolved through `resolvers`.
21
+
22
+ Coverage both ways: forward per role (counts per status; the rows where a required role is missing-key or
23
+ missing-sub are its gaps) and reverse (catalog keys under the view's prefixes that no row references). Gaps are
24
+ data, not errors.
25
+
26
+ A view document (nnnotes.view/1) holds object ids, never content hashes, so its task depends on the master tables it
27
+ reads, the digest of its rules and the part of the address index it read (`Recorder.subset`), not on how objects
28
+ are encoded: new PNG bytes never change a view, a master data update changes only views.
29
+
30
+ The stages (`stages()`: one `view.<name>` per view, subjects = the catalog subjects of link.addresses) take exactly
31
+ those inputs: "master:<Table>" (the decoded table files), "rules" (the view's rule and the resolvers it uses) and
32
+ "addresses" (the recorded part of the address table of link.addresses:<subject>), and write the view document as
33
+ the artifact "<task id>#view". `derived` adds the layout paths of the objects for the `original` layout
34
+ (views/<name>.json).
35
+ """
36
+ from __future__ import annotations
37
+
38
+ import bisect
39
+ import copy
40
+ import functools
41
+ import json
42
+ import re
43
+ import string
44
+ from collections import OrderedDict
45
+ from dataclasses import dataclass
46
+ from importlib import resources
47
+ from pathlib import Path
48
+ from typing import Iterable, Mapping, Protocol
49
+
50
+ from . import contract, languages
51
+ from .contract import Cost, Input
52
+ from .stages import Output, Stage
53
+
54
+ FORMAT = 1
55
+ RULES_FILE = "viewrules.json"
56
+ TEXT_TABLE = "MasterText"
57
+ STATUSES = ("ok", "missing-key", "missing-sub", "not-applicable", "no-value")
58
+ GAP_STATUSES = ("missing-key", "missing-sub")
59
+ ANY = "any"
60
+ OPS = {
61
+ "eq": lambda a, b: a == b, "ne": lambda a, b: a != b,
62
+ "lt": lambda a, b: a is not None and a < b, "le": lambda a, b: a is not None and a <= b,
63
+ "gt": lambda a, b: a is not None and a > b, "ge": lambda a, b: a is not None and a >= b,
64
+ "in": lambda a, b: a in b, "notin": lambda a, b: a not in b,
65
+ "empty": lambda a, b: _empty(a), "nonempty": lambda a, b: not _empty(a),
66
+ }
67
+ _SUB = re.compile(r"(?P<key>.*[^\]])\[(?P<sub>[^\[\]]+)\]")
68
+ _NAME = re.compile(r"[a-z][a-z0-9_]*")
69
+ _VIEW_KEYS = {"version", "table", "sources", "id", "vars", "names", "prefixes", "exclude", "roles"}
70
+ _ROLE_KEYS = {"role", "address", "expect", "required", "when", "each", "resolver", "type", "id"}
71
+ _RESOLVER_KEYS = {"name", "table", "vars", "names", "address", "expect"}
72
+
73
+
74
+ class ViewError(ValueError):
75
+ """Rules that cannot be used as written, or master data a view cannot read."""
76
+
77
+
78
+ def _empty(v) -> bool:
79
+ return v is None or v == "" or v == []
80
+
81
+
82
+ # ---------------------------------------------------------------- the address index
83
+ @dataclass(frozen=True, order=True)
84
+ class Obj:
85
+ """A census object under a catalog key: its object id (contract.object_id), Unity class, m_Name and, when known,
86
+ its stable address (contract.stable_address: the same in another catalog version while the object id changes
87
+ with its bundle's content)."""
88
+ id: str
89
+ cls: str
90
+ name: str
91
+ stable: str | None = None
92
+
93
+ def to_json(self) -> list:
94
+ return [self.id, self.cls, self.name] + ([self.stable] if self.stable is not None else [])
95
+
96
+ @classmethod
97
+ def from_json(cls, v) -> "Obj":
98
+ return cls(str(v[0]), str(v[1]), str(v[2]), None if len(v) < 4 or v[3] is None else str(v[3]))
99
+
100
+
101
+ class Addresses(Protocol):
102
+ """What a view reads of the catalog and the censuses."""
103
+
104
+ def keys(self, prefix: str = "") -> list[str]:
105
+ """The catalog keys starting with `prefix`, sorted."""
106
+
107
+ def objects(self, key: str) -> list[Obj] | None:
108
+ """The objects under `key` in the order its bundle stores them (None: not a catalog key; []: a key whose
109
+ container has no census object)."""
110
+
111
+
112
+ class AddressIndex:
113
+ """Addresses from {catalog key: objects in stored order} (a key mapped to None or [] has no census object)."""
114
+
115
+ def __init__(self, entries: Mapping[str, Iterable[Obj] | None]):
116
+ self._objs = {k: list(v or ()) for k, v in entries.items()}
117
+ self._keys = sorted(self._objs)
118
+
119
+ def keys(self, prefix: str = "") -> list[str]:
120
+ i = bisect.bisect_left(self._keys, prefix)
121
+ out = []
122
+ for k in self._keys[i:]:
123
+ if not k.startswith(prefix):
124
+ break
125
+ out.append(k)
126
+ return out
127
+
128
+ def objects(self, key: str) -> list[Obj] | None:
129
+ objs = self._objs.get(key)
130
+ return None if objs is None else list(objs)
131
+
132
+ @classmethod
133
+ def build(cls, locations: Mapping[str, str], containers: Mapping[str, Iterable[Obj]]) -> "AddressIndex":
134
+ """The index of a catalog and its censuses: `locations` {key: internal id} (the asset path a key loads),
135
+ `containers` {container path: objects} (census container entries, in stored order). Unity writes container
136
+ paths in lower case, so an internal id matches its container path case-insensitively."""
137
+ by_path: dict[str, list[Obj]] = {}
138
+ for path, objs in containers.items():
139
+ by_path.setdefault(path.lower(), []).extend(objs)
140
+ return cls({key: by_path.get(internal.lower(), []) for key, internal in locations.items()})
141
+
142
+ @classmethod
143
+ def from_addresses(cls, doc: dict) -> "AddressIndex":
144
+ """The index of an address table (nnnotes.addresses/1, the link.addresses stage): a key's objects are those
145
+ of all its locations (in the table's order, the order of the bundle's container), with class, name and
146
+ stable address from the table's `objects`; a location whose bundle has no census yet contributes none."""
147
+ where: dict[str, str] = {}
148
+ for addr, info in doc["objects"].items():
149
+ where.setdefault(info["object"], addr)
150
+ entries = {}
151
+ for key, locs in doc["keys"].items():
152
+ objs = {}
153
+ for loc in locs:
154
+ for oid in loc.get("objects") or ():
155
+ addr = where.get(oid)
156
+ info = doc["objects"][addr] if addr is not None else {}
157
+ objs[oid] = Obj(oid, info.get("class") or "", info.get("name") or "", addr)
158
+ entries[key] = list(objs.values())
159
+ return cls(entries)
160
+
161
+ def to_json(self) -> dict:
162
+ return {"keys": {k: [o.to_json() for o in self._objs[k]] for k in self._keys}}
163
+
164
+ @classmethod
165
+ def from_json(cls, doc: dict) -> "AddressIndex":
166
+ return cls({k: [Obj.from_json(o) for o in (v or ())] for k, v in doc["keys"].items()})
167
+
168
+ @classmethod
169
+ def from_subset(cls, subset: dict) -> "AddressIndex":
170
+ """The index a recorded part of an index stands for (Recorder.subset): its keys with their objects, and the
171
+ keys its prefixes listed. A view built on it reads exactly what it read when the part was recorded."""
172
+ entries = {k: [Obj.from_json(o) for o in v] for k, v in subset["keys"].items() if v is not None}
173
+ for keys in subset["prefixes"].values():
174
+ for k in keys:
175
+ entries.setdefault(k, [])
176
+ return cls(entries)
177
+
178
+
179
+ class Recorder:
180
+ """Addresses that remember what was read: `subset()` is the part of the index a view depends on."""
181
+
182
+ def __init__(self, inner: Addresses):
183
+ self.inner = inner
184
+ self._keys: dict[str, list[Obj] | None] = {}
185
+ self._prefixes: dict[str, list[str]] = {}
186
+
187
+ def keys(self, prefix: str = "") -> list[str]:
188
+ if prefix not in self._prefixes:
189
+ self._prefixes[prefix] = list(self.inner.keys(prefix))
190
+ return list(self._prefixes[prefix])
191
+
192
+ def objects(self, key: str) -> list[Obj] | None:
193
+ if key not in self._keys:
194
+ objs = self.inner.objects(key)
195
+ self._keys[key] = None if objs is None else list(objs)
196
+ objs = self._keys[key]
197
+ return None if objs is None else list(objs)
198
+
199
+ def subset(self) -> dict:
200
+ """{"keys": {key: objects or null}, "prefixes": {prefix: keys}}, sorted."""
201
+ return {"keys": {k: None if v is None else [o.to_json() for o in v] for k, v in sorted(self._keys.items())},
202
+ "prefixes": {p: v for p, v in sorted(self._prefixes.items())}}
203
+
204
+
205
+ # ---------------------------------------------------------------- master tables
206
+ class Tables(Protocol):
207
+ def __getitem__(self, name: str) -> list[dict]:
208
+ """The rows of a master table (KeyError when there is no such table)."""
209
+
210
+
211
+ def table_rows(data: bytes) -> list[dict]:
212
+ """The rows of a decoded master table file (`_allData`, as master.table reads it)."""
213
+ return json.loads(bytes(data).decode("utf-8"))["_allData"]
214
+
215
+
216
+ class MasterTables:
217
+ """The decoded master tables of a directory (<name>.json), read once each."""
218
+
219
+ def __init__(self, master_dir):
220
+ self.dir = Path(master_dir)
221
+ self._rows: dict[str, list[dict]] = {}
222
+
223
+ def __getitem__(self, name: str) -> list[dict]:
224
+ if name not in self._rows:
225
+ try:
226
+ self._rows[name] = table_rows((self.dir / f"{name}.json").read_bytes())
227
+ except FileNotFoundError:
228
+ raise KeyError(name) from None
229
+ return self._rows[name]
230
+
231
+ def sha256(self, name: str) -> str:
232
+ """The content id of a table's file."""
233
+ return contract.sha256((self.dir / f"{name}.json").read_bytes())
234
+
235
+
236
+ # ---------------------------------------------------------------- rules
237
+ def load_rules(path=None) -> dict:
238
+ """The view rules: `path`, else the package's viewrules.json; checked (check_rules)."""
239
+ if path is None:
240
+ text = resources.files(__package__).joinpath(RULES_FILE).read_text(encoding="utf-8")
241
+ else:
242
+ text = Path(path).read_text(encoding="utf-8")
243
+ rules = json.loads(text)
244
+ check_rules(rules)
245
+ return rules
246
+
247
+
248
+ @functools.lru_cache(maxsize=None)
249
+ def _fields(template: str) -> tuple[str, ...]:
250
+ out = []
251
+ for _, name, _, _ in string.Formatter().parse(template):
252
+ if name is not None:
253
+ if not _NAME.fullmatch(name):
254
+ raise ViewError(f"template {template!r}: field {name!r} is not a plain name")
255
+ out.append(name)
256
+ return tuple(out)
257
+
258
+
259
+ def fields(template: str) -> list[str]:
260
+ """The names an address template formats ({name} or {name:spec})."""
261
+ return list(_fields(template))
262
+
263
+
264
+ def _check_var(where: str, spec) -> None:
265
+ chain = [spec] if isinstance(spec, str) else spec
266
+ if not isinstance(chain, list) or not chain or not all(isinstance(s, str) and s for s in chain):
267
+ raise ViewError(f"{where}: a column name or a list [column, Table.column, ...]")
268
+ for step in chain[1:]:
269
+ table, sep, col = step.partition(".")
270
+ if not sep or not table or not col:
271
+ raise ViewError(f"{where}: {step!r} is not Table.column")
272
+
273
+
274
+ def _check_when(where: str, when, names: set) -> None:
275
+ for c in when if isinstance(when, list) else [when]:
276
+ if not isinstance(c, dict) or set(c) - {"field", "op", "value"} or "field" not in c or "op" not in c:
277
+ raise ViewError(f"{where}: a condition is {{field, op, value}}")
278
+ if c["op"] not in OPS:
279
+ raise ViewError(f"{where}: unknown operator {c['op']!r} (one of {', '.join(OPS)})")
280
+ if c["field"] not in names and not c["field"].startswith("_"):
281
+ raise ViewError(f"{where}: {c['field']!r} is neither a var nor a column")
282
+
283
+
284
+ def _check_expect(where: str, expect) -> None:
285
+ if isinstance(expect, str):
286
+ return
287
+ if not isinstance(expect, list) or not expect or not all(isinstance(e, str) and e != ANY for e in expect):
288
+ raise ViewError(f"{where}: expect is a class name, a list of class names or \"any\"")
289
+
290
+
291
+ def check_rules(rules: dict) -> None:
292
+ """Raise ViewError naming the first problem of `rules` (format, views, roles, templates, resolvers)."""
293
+ if rules.get("format") != FORMAT:
294
+ raise ViewError(f"view rules: format {rules.get('format')!r}, this version reads {FORMAT}")
295
+ resolvers = rules.get("resolvers", {})
296
+ for rname, res in resolvers.items():
297
+ for code, r in res.items():
298
+ where = f"resolvers.{rname}.{code}"
299
+ if not code.lstrip("-").isdigit():
300
+ raise ViewError(f"{where}: resolver codes are integers")
301
+ if set(r) - _RESOLVER_KEYS or "address" not in r or "name" not in r:
302
+ raise ViewError(f"{where}: keys are {', '.join(sorted(_RESOLVER_KEYS))} (name and address needed)")
303
+ rvars = r.get("vars", {})
304
+ for v, spec in rvars.items():
305
+ _check_var(f"{where}.vars.{v}", spec)
306
+ if rvars and "table" not in r:
307
+ raise ViewError(f"{where}: vars without a table")
308
+ for f in fields(r["address"]):
309
+ if f not in rvars:
310
+ raise ViewError(f"{where}: address field {f!r} is not a var")
311
+ _check_expect(where, r.get("expect", ANY))
312
+ for name, v in rules.get("views", {}).items():
313
+ where = f"views.{name}"
314
+ if not _NAME.fullmatch(name):
315
+ raise ViewError(f"{where}: view names are lower case")
316
+ if set(v) - _VIEW_KEYS:
317
+ raise ViewError(f"{where}: unknown keys {sorted(set(v) - _VIEW_KEYS)}")
318
+ if not isinstance(v.get("version"), int) or v["version"] < 1:
319
+ raise ViewError(f"{where}: version is a positive integer")
320
+ if ("table" in v) == ("sources" in v):
321
+ raise ViewError(f"{where}: either a table or sources")
322
+ names = set(v.get("vars", {}))
323
+ if "sources" in v:
324
+ for s in v["sources"]:
325
+ if set(s) != {"table", "type", "id"}:
326
+ raise ViewError(f"{where}.sources: each source is {{table, type, id}}")
327
+ names = {"type", "id"}
328
+ if v.get("vars"):
329
+ raise ViewError(f"{where}: a view of sources has the vars type and id only")
330
+ for var, spec in v.get("vars", {}).items():
331
+ if not _NAME.fullmatch(var):
332
+ raise ViewError(f"{where}.vars.{var}: var names are lower case")
333
+ _check_var(f"{where}.vars.{var}", spec)
334
+ for label, spec in v.get("names", {}).items():
335
+ _check_var(f"{where}.names.{label}", spec)
336
+ seen = set()
337
+ for role in v.get("roles", []):
338
+ rw = f"{where}.roles.{role.get('role')}"
339
+ if set(role) - _ROLE_KEYS or not role.get("role"):
340
+ raise ViewError(f"{rw}: keys are {', '.join(sorted(_ROLE_KEYS))} (role needed)")
341
+ if role["role"] in seen:
342
+ raise ViewError(f"{rw}: repeated role")
343
+ seen.add(role["role"])
344
+ rnames = set(names)
345
+ each = role.get("each")
346
+ if each is not None:
347
+ kinds = {"values", "column", "catalogPrefix"} & set(each)
348
+ if len(kinds) != 1 or set(each) - {"var", "values", "column", "catalogPrefix"} or "var" not in each:
349
+ raise ViewError(f"{rw}.each: {{var}} and one of values, column, catalogPrefix")
350
+ if each["var"] in names:
351
+ raise ViewError(f"{rw}.each: {each['var']!r} is already a var")
352
+ if "catalogPrefix" in each:
353
+ for f in fields(each["catalogPrefix"]):
354
+ if f not in names:
355
+ raise ViewError(f"{rw}.each: prefix field {f!r} is not a var")
356
+ rnames.add(each["var"])
357
+ if "resolver" in role:
358
+ if "address" in role or "each" in role or "expect" in role or role["resolver"] not in resolvers:
359
+ raise ViewError(f"{rw}: a resolver role names a known resolver and has no address, each or "
360
+ f"expect")
361
+ for k in ("type", "id"):
362
+ if role.get(k, k) not in rnames:
363
+ raise ViewError(f"{rw}: {k} var {role.get(k, k)!r} is not a var")
364
+ else:
365
+ if "address" not in role:
366
+ raise ViewError(f"{rw}: no address")
367
+ for f in fields(role["address"]):
368
+ if f not in rnames:
369
+ raise ViewError(f"{rw}: address field {f!r} is not a var")
370
+ _check_expect(rw, role.get("expect", ANY))
371
+ if "when" in role:
372
+ _check_when(rw, role["when"], rnames)
373
+ for p in v.get("prefixes", []) + v.get("exclude", []):
374
+ if not isinstance(p, str) or not p or p.startswith("*"):
375
+ raise ViewError(f"{where}: a prefix is a non-empty string that does not start with *")
376
+
377
+
378
+ def view_names(rules: dict) -> list[str]:
379
+ return sorted(rules.get("views", {}))
380
+
381
+
382
+ def _chain(spec) -> list[str]:
383
+ return [spec] if isinstance(spec, str) else list(spec)
384
+
385
+
386
+ def used_resolvers(rules: dict, name: str) -> dict:
387
+ """The resolvers the view `name` uses: {resolver: {code: spec}}."""
388
+ v = rules["views"][name]
389
+ used = sorted({r["resolver"] for r in v.get("roles", []) if "resolver" in r})
390
+ return {r: rules["resolvers"][r] for r in used}
391
+
392
+
393
+ def view_tables(rules: dict, name: str) -> list[str]:
394
+ """Every master table the view `name` reads, sorted."""
395
+ v = rules["views"][name]
396
+ out = set()
397
+ if "table" in v:
398
+ out.add(v["table"])
399
+ out.update(s["table"] for s in v.get("sources", []))
400
+ specs = list(v.get("vars", {}).values()) + list(v.get("names", {}).values())
401
+ for res in used_resolvers(rules, name).values():
402
+ for r in res.values():
403
+ if "table" in r:
404
+ out.add(r["table"])
405
+ specs += list(r.get("vars", {}).values()) + list(r.get("names", {}).values())
406
+ if r.get("names"):
407
+ out.add(TEXT_TABLE)
408
+ for spec in specs:
409
+ out.update(step.partition(".")[0] for step in _chain(spec)[1:])
410
+ if v.get("names"):
411
+ out.add(TEXT_TABLE)
412
+ return sorted(out)
413
+
414
+
415
+ def rules_digest(rules: dict, name: str) -> str:
416
+ """The key hash of what the view `name` is made by: the rules format, its rule and the resolvers it uses."""
417
+ return contract.digest({"format": rules["format"], "view": rules["views"][name],
418
+ "resolvers": used_resolvers(rules, name)})
419
+
420
+
421
+ # ---------------------------------------------------------------- one address
422
+ def parse_address(address: str) -> tuple[str, str | None]:
423
+ """(key, sub-object name or None) of an address `key[sub]`."""
424
+ m = _SUB.fullmatch(address)
425
+ return (m["key"], m["sub"]) if m else (address, None)
426
+
427
+
428
+ def _classes(expect) -> list[str] | None:
429
+ if expect == ANY:
430
+ return None
431
+ return [expect] if isinstance(expect, str) else list(expect)
432
+
433
+
434
+ def choose(objs: list[Obj], sub: str | None, expect=ANY) -> list[Obj]:
435
+ """The objects (in stored order) that a load of `key[sub]` with the expected class(es) can return: those named
436
+ `sub` (all without [sub]) of the first expected class that has any (all classes with `any`). The Addressables
437
+ loader returns the first."""
438
+ pool = [o for o in objs if o.name == sub] if sub is not None else list(objs)
439
+ classes = _classes(expect)
440
+ if classes is None:
441
+ return pool
442
+ for c in classes:
443
+ tier = [o for o in pool if o.cls == c]
444
+ if tier:
445
+ return tier
446
+ return []
447
+
448
+
449
+ def resolve(index: Addresses, address: str, expect=ANY) -> dict:
450
+ """The resolution of one address (module docstring): {address, status, object, class, stable} when an object
451
+ fits (`among`: how many fit, when several), the objects the key has when none fits, a detail when the key is
452
+ missing."""
453
+ key, sub = parse_address(address)
454
+ objs = index.objects(key)
455
+ out = {"address": address}
456
+ if not objs:
457
+ out.update(status="missing-key", detail="not a catalog key" if objs is None else "no census objects")
458
+ return out
459
+ pool = choose(objs, sub, expect)
460
+ if not pool:
461
+ out.update(status="missing-sub", available=[o.to_json() for o in objs])
462
+ return out
463
+ out.update(status="ok", object=pool[0].id, **{"class": pool[0].cls})
464
+ if pool[0].stable is not None:
465
+ out["stable"] = pool[0].stable
466
+ if len(pool) > 1:
467
+ out["among"] = len(pool)
468
+ return out
469
+
470
+
471
+ # ---------------------------------------------------------------- one view
472
+ class _Row:
473
+ """Values of one master row: its columns, its vars (chains through other tables) and conditions."""
474
+
475
+ def __init__(self, row: dict, tables: "_Lookup"):
476
+ self.row, self.tables = row, tables
477
+
478
+ def value(self, spec) -> tuple[object, str | None]:
479
+ """(value, why it is missing or None) of a var spec."""
480
+ chain = _chain(spec)
481
+ v = self.row.get(chain[0])
482
+ for step in chain[1:]:
483
+ if _empty(v):
484
+ return None, f"{chain[0]} is empty"
485
+ table, _, col = step.partition(".")
486
+ target = self.tables.row(table, v)
487
+ if target is None:
488
+ return None, f"{table} has no row {v}"
489
+ v = target.get(col)
490
+ return v, None
491
+
492
+
493
+ class _Lookup:
494
+ """Master tables by id (the `_id` column), read once."""
495
+
496
+ def __init__(self, tables: Tables):
497
+ self.tables = tables
498
+ self._by_id: dict[str, dict] = {}
499
+
500
+ def rows(self, name: str) -> list[dict]:
501
+ try:
502
+ return self.tables[name]
503
+ except KeyError:
504
+ raise ViewError(f"master table {name} is not in the master data") from None
505
+
506
+ def row(self, name: str, rid):
507
+ if name not in self._by_id:
508
+ self._by_id[name] = {r.get("_id"): r for r in self.rows(name)}
509
+ return self._by_id[name].get(rid)
510
+
511
+ def text(self, tid):
512
+ """The five languages of a MasterText id (None when the table has no such row)."""
513
+ row = self.row(TEXT_TABLE, tid)
514
+ return None if row is None else languages.texts(row)
515
+
516
+
517
+ def _names(row: _Row, specs: dict, look: _Lookup) -> dict:
518
+ out = {}
519
+ for label, spec in specs.items():
520
+ tid, _ = row.value(spec)
521
+ if not _empty(tid):
522
+ out[label] = look.text(tid)
523
+ return out
524
+
525
+
526
+ def _holds(when, values: dict, row: dict) -> bool:
527
+ for c in when if isinstance(when, list) else [when]:
528
+ f = c["field"]
529
+ v = values[f] if f in values else row.get(f)
530
+ if not OPS[c["op"]](v, c.get("value")):
531
+ return False
532
+ return True
533
+
534
+
535
+ def _format(template: str, values: dict, missing: dict) -> tuple[str | None, str | None]:
536
+ for f in fields(template):
537
+ if _empty(values.get(f)):
538
+ return None, missing.get(f) or f"{f} is empty"
539
+ try:
540
+ return template.format_map(values), None
541
+ except (ValueError, TypeError) as e:
542
+ return None, f"{template}: {e}"
543
+
544
+
545
+ def _entry(index: Addresses, template: str, values: dict, missing: dict, expect) -> dict:
546
+ address, why = _format(template, values, missing)
547
+ if address is None:
548
+ return {"status": "no-value", "detail": why}
549
+ return resolve(index, address, expect)
550
+
551
+
552
+ def _enumerate(each: dict, values: dict, row: dict, index: Addresses) -> list:
553
+ if "values" in each:
554
+ return list(each["values"])
555
+ if "column" in each:
556
+ v = row.get(each["column"])
557
+ return list(v) if isinstance(v, list) else ([] if _empty(v) else [v])
558
+ prefix, why = _format(each["catalogPrefix"], values, {})
559
+ if prefix is None:
560
+ return []
561
+ out = set()
562
+ for k in index.keys(prefix):
563
+ rest = k[len(prefix):]
564
+ part = re.split(r"[/\[]", rest, maxsplit=1)[0]
565
+ if part:
566
+ out.add(part)
567
+ return sorted(out)
568
+
569
+
570
+ def _resolved(rules: dict, role: dict, values: dict, index: Addresses, look: _Lookup) -> tuple[dict, dict]:
571
+ """(entry, names of the target row) of a resolver role."""
572
+ code, rid = values.get(role.get("type", "type")), values.get(role.get("id", "id"))
573
+ spec = rules["resolvers"][role["resolver"]].get(str(code))
574
+ if spec is None:
575
+ return {"status": "not-applicable", "detail": f"{role['resolver']} {code} has no resolver"}, {}
576
+ head = {"resource": spec["name"]}
577
+ rvalues, missing, names = {}, {}, {}
578
+ if "table" in spec:
579
+ target = look.row(spec["table"], rid)
580
+ if target is None:
581
+ return {**head, "status": "no-value", "detail": f"{spec['table']} has no row {rid}"}, {}
582
+ trow = _Row(target, look)
583
+ for var, vs in spec.get("vars", {}).items():
584
+ rvalues[var], why = trow.value(vs)
585
+ if why:
586
+ missing[var] = why
587
+ names = _names(trow, spec.get("names", {}), look)
588
+ return {**head, **_entry(index, spec["address"], rvalues, missing, spec.get("expect", ANY))}, names
589
+
590
+
591
+ def _row_doc(rules: dict, view: dict, rid, values: dict, missing: dict, raw: dict, names: dict,
592
+ index: Addresses, look: _Lookup) -> dict:
593
+ roles = {}
594
+ for role in view.get("roles", []):
595
+ applies = "when" not in role or role.get("each") is not None or _holds(role["when"], values, raw)
596
+ if "resolver" in role:
597
+ if applies:
598
+ entry, tnames = _resolved(rules, role, values, index, look)
599
+ names = names or tnames
600
+ else:
601
+ entry = {"status": "not-applicable"}
602
+ roles[role["role"]] = entry
603
+ continue
604
+ expect = role.get("expect", ANY)
605
+ each = role.get("each")
606
+ if each is None:
607
+ roles[role["role"]] = (_entry(index, role["address"], values, missing, expect) if applies
608
+ else {"status": "not-applicable"})
609
+ continue
610
+ entries = []
611
+ for item in _enumerate(each, values, raw, index):
612
+ ivalues = {**values, each["var"]: item}
613
+ if "when" in role and not _holds(role["when"], ivalues, raw):
614
+ e = {"status": "not-applicable"}
615
+ else:
616
+ e = _entry(index, role["address"], ivalues, missing, expect)
617
+ entries.append({"each": {each["var"]: item}, **e})
618
+ roles[role["role"]] = entries
619
+ doc = {"id": rid, "vars": values, "roles": roles}
620
+ if names:
621
+ doc["names"] = names
622
+ return doc
623
+
624
+
625
+ def _sort_key(rid):
626
+ return (0, rid, "") if isinstance(rid, (int, float)) else (1, 0, str(rid))
627
+
628
+
629
+ def _rows(rules: dict, view: dict, index: Addresses, look: _Lookup) -> list[dict]:
630
+ out = []
631
+ if "sources" in view:
632
+ found: dict[tuple, set] = {}
633
+ for s in view["sources"]:
634
+ for r in look.rows(s["table"]):
635
+ t, i = r.get(s["type"]), r.get(s["id"])
636
+ if t is None or i is None:
637
+ continue
638
+ found.setdefault((t, i), set()).add(s["table"])
639
+ for (t, i), tables in sorted(found.items(), key=lambda kv: (_sort_key(kv[0][0]), _sort_key(kv[0][1]))):
640
+ doc = _row_doc(rules, view, f"{t}:{i}", {"type": t, "id": i}, {}, {}, {}, index, look)
641
+ doc["from"] = sorted(tables)
642
+ out.append(doc)
643
+ return out
644
+ idcol = view.get("id", "_id")
645
+ rows = look.rows(view["table"])
646
+ ids = [r.get(idcol) for r in rows]
647
+ if len(set(map(repr, ids))) != len(ids):
648
+ raise ViewError(f"{view['table']}: {idcol} is not unique")
649
+ for r in sorted(rows, key=lambda r: _sort_key(r.get(idcol))):
650
+ row = _Row(r, look)
651
+ values, missing = {}, {}
652
+ for var, spec in view.get("vars", {}).items():
653
+ values[var], why = row.value(spec)
654
+ if why:
655
+ missing[var] = why
656
+ out.append(_row_doc(rules, view, r.get(idcol), values, missing, r, _names(row, view.get("names", {}), look),
657
+ index, look))
658
+ return out
659
+
660
+
661
+ def _prefix_pattern(p: str) -> tuple[str, re.Pattern]:
662
+ literal = p.split("*", 1)[0]
663
+ return literal, re.compile(re.escape(p).replace(r"\*", "[^/]+"))
664
+
665
+
666
+ def prefix_keys(index: Addresses, prefixes: list[str], exclude: list[str] = ()) -> list[str]:
667
+ """The catalog keys matching a prefix (`*` stands for one path segment) and no exclusion, sorted."""
668
+ out = set()
669
+ for p in prefixes:
670
+ literal, pat = _prefix_pattern(p)
671
+ out.update(k for k in index.keys(literal) if pat.match(k))
672
+ for p in exclude:
673
+ _, pat = _prefix_pattern(p)
674
+ out = {k for k in out if not pat.match(k)}
675
+ return sorted(out)
676
+
677
+
678
+ def _entries(row: dict):
679
+ for role, e in row["roles"].items():
680
+ for x in (e if isinstance(e, list) else [e]):
681
+ yield role, x
682
+
683
+
684
+ def referenced(doc: dict) -> set[str]:
685
+ """The catalog keys the rows of a view document reference (their addresses without [sub])."""
686
+ return {parse_address(x["address"])[0] for row in doc["rows"] for _, x in _entries(row) if "address" in x}
687
+
688
+
689
+ def coverage(doc: dict, view: dict, index: Addresses) -> dict:
690
+ """Forward coverage per role and reverse coverage of the view's prefixes (module docstring)."""
691
+ roles = {}
692
+ for role in view.get("roles", []):
693
+ roles[role["role"]] = {"required": bool(role.get("required", False)), "counts": {}, "gaps": []}
694
+ for row in doc["rows"]:
695
+ for role, x in _entries(row):
696
+ c = roles[role]
697
+ c["counts"][x["status"]] = c["counts"].get(x["status"], 0) + 1
698
+ if c["required"] and x["status"] in GAP_STATUSES and (not c["gaps"] or c["gaps"][-1] != row["id"]):
699
+ c["gaps"].append(row["id"])
700
+ for c in roles.values():
701
+ c["counts"] = dict(sorted(c["counts"].items()))
702
+ keys = prefix_keys(index, view.get("prefixes", []), view.get("exclude", []))
703
+ refs = referenced(doc)
704
+ return {"rows": len(doc["rows"]), "roles": roles,
705
+ "reverse": {"prefixes": list(view.get("prefixes", [])), "exclude": list(view.get("exclude", [])),
706
+ "keys": len(keys), "unreferenced": [k for k in keys if k not in refs]}}
707
+
708
+
709
+ def build(rules: dict, name: str, tables: Tables, index: Addresses) -> dict:
710
+ """The view document (nnnotes.view/1) of the view `name`: its rows (id, vars, names, roles) sorted by id, and
711
+ its coverage. Wrap `index` in a Recorder to learn the part of the index it read."""
712
+ if name not in rules.get("views", {}):
713
+ raise ViewError(f"no view {name!r} (views: {', '.join(view_names(rules))})")
714
+ view = rules["views"][name]
715
+ look = _Lookup(tables)
716
+ doc = {"schema": contract.VIEW, "view": name, "version": view["version"], "rules": rules_digest(rules, name),
717
+ "tables": view_tables(rules, name), "rows": _rows(rules, view, index, look)}
718
+ doc["coverage"] = coverage(doc, view, index)
719
+ return doc
720
+
721
+
722
+ def gaps(doc: dict) -> int:
723
+ """The number of (row, required role) gaps of a view document."""
724
+ return sum(len(c["gaps"]) for c in doc["coverage"]["roles"].values())
725
+
726
+
727
+ def unreferenced(docs: Iterable[dict], rules: dict, index: Addresses) -> list[str]:
728
+ """The catalog keys under the prefixes of the given views that none of them references (views sharing a
729
+ prefix, such as two tables naming banners of one folder, cover each other here), sorted."""
730
+ docs = list(docs)
731
+ keys, refs = set(), set()
732
+ for d in docs:
733
+ v = rules["views"][d["view"]]
734
+ keys.update(prefix_keys(index, v.get("prefixes", []), v.get("exclude", [])))
735
+ refs |= referenced(d)
736
+ return sorted(keys - refs)
737
+
738
+
739
+ def build_all(rules: dict, tables: Tables, index: Addresses, names=None) -> dict[str, dict]:
740
+ """{name: view document} of `names` (default: every view), in name order."""
741
+ return {n: build(rules, n, tables, index) for n in (sorted(names) if names is not None else view_names(rules))}
742
+
743
+
744
+ def _index(doc: dict) -> dict:
745
+ out = {}
746
+ for row in doc["rows"]:
747
+ for role, x in _entries(row):
748
+ each = x.get("each")
749
+ out[(repr(row["id"]), role, repr(sorted(each.items())) if each else "")] = (row["id"], role, each, x)
750
+ return out
751
+
752
+
753
+ def _state(x: dict) -> dict:
754
+ return {k: x[k] for k in ("status", "address", "object", "stable") if k in x}
755
+
756
+
757
+ def diff(old: dict, new: dict) -> dict:
758
+ """What changed between two documents of one view (two master or catalog versions): rows added and removed, and
759
+ per (row, role, enumerated value) entry a change of status, of address, of where the object is (its stable
760
+ address) or only of its object id (the same place, new bundle content). Sorted, deterministic."""
761
+ if old.get("view") != new.get("view"):
762
+ raise ViewError(f"documents of different views: {old.get('view')} and {new.get('view')}")
763
+ oids = {repr(r["id"]): r["id"] for r in old["rows"]}
764
+ nids = {repr(r["id"]): r["id"] for r in new["rows"]}
765
+ a, b = _index(old), _index(new)
766
+ changes = []
767
+ for k in sorted(set(a) | set(b)):
768
+ if k[0] not in oids or k[0] not in nids:
769
+ continue # an added or removed row: listed once below
770
+ x, y = a.get(k), b.get(k)
771
+ sx, sy = _state(x[3]) if x else None, _state(y[3]) if y else None
772
+ if sx == sy:
773
+ continue
774
+ if sx is None or sy is None:
775
+ kind = "added" if sx is None else "removed"
776
+ elif sx.get("status") != sy.get("status"):
777
+ kind = "status"
778
+ elif sx.get("address") != sy.get("address"):
779
+ kind = "address"
780
+ elif sx.get("stable") != sy.get("stable"):
781
+ kind = "moved"
782
+ else:
783
+ kind = "object"
784
+ row, role, each, _ = x or y
785
+ c = {"row": row, "role": role, "kind": kind, "old": sx, "new": sy}
786
+ if each:
787
+ c["each"] = each
788
+ changes.append(c)
789
+ key = lambda i: _sort_key(i) # noqa: E731
790
+ return {"view": new["view"],
791
+ "rows": {"added": sorted((nids[i] for i in set(nids) - set(oids)), key=key),
792
+ "removed": sorted((oids[i] for i in set(oids) - set(nids)), key=key)},
793
+ "changes": changes}
794
+
795
+
796
+ # ---------------------------------------------------------------- stages
797
+ ADDRESSES_STAGE = "link.addresses"
798
+ STAGE_PREFIX = "view."
799
+ MASTER_ROLE = "master:"
800
+ VIEW_ROLE = "view"
801
+ MAIN_SUBJECT = "main"
802
+ _TABLE_CACHE = 128
803
+ _rows_cache: OrderedDict = OrderedDict() # content id of a table file -> its rows (the latest _TABLE_CACHE)
804
+ _index_cache: OrderedDict = OrderedDict() # content id of an address table -> its AddressIndex (the latest 2)
805
+
806
+
807
+ def _cached_rows(sha: str, read) -> list[dict]:
808
+ """The rows of the table file with content id `sha` (`read()` gives its bytes; checked against `sha`)."""
809
+ rows = _rows_cache.get(sha)
810
+ if rows is not None:
811
+ _rows_cache.move_to_end(sha)
812
+ return rows
813
+ data = read()
814
+ if contract.sha256(data) != sha:
815
+ raise ViewError(f"a master table file changed while it was read (expected {sha})")
816
+ rows = _rows_cache[sha] = table_rows(data)
817
+ while len(_rows_cache) > _TABLE_CACHE:
818
+ _rows_cache.popitem(last=False)
819
+ return rows
820
+
821
+
822
+ def _address_index(env, tid: str) -> AddressIndex:
823
+ """The address index of the result of the link.addresses task `tid` (Pending until it has run)."""
824
+ sha = env.artifact(tid, "addresses")["content"]["sha256"]
825
+ ix = _index_cache.get(sha)
826
+ if ix is None:
827
+ ix = _index_cache[sha] = AddressIndex.from_addresses(contract.loads(env.store.read(sha)))
828
+ while len(_index_cache) > 2:
829
+ _index_cache.popitem(last=False)
830
+ return ix
831
+
832
+
833
+ def _stored(store, role: str, data: bytes) -> Input:
834
+ return Input(role, store.put(data), len(data), None, ({"kind": "store"},))
835
+
836
+
837
+ def _rank(aid: str) -> tuple:
838
+ role = contract.parse_artifact_id(aid)[1]
839
+ order = contract.PRIMARY_ROLES
840
+ return (order.index(role) if role in order else len(order), role)
841
+
842
+
843
+ def with_paths(doc: dict, paths, layout: str = "original") -> dict:
844
+ """A copy of a view document with the files of its objects in a layout (`paths.objects(object id)`: the
845
+ object's artifact ids; `paths.artifact(artifact id)`: its path in the layout or None): every entry naming an
846
+ object gets `files` ({artifact role: path} of the artifacts the layout places) and `path` (the file of its
847
+ primary artifact, contract.PRIMARY_ROLES; null when the layout places none)."""
848
+ out = copy.deepcopy(doc)
849
+ seen: dict[str, dict] = {}
850
+ for row in out["rows"]:
851
+ for _, x in _entries(row):
852
+ oid = x.get("object")
853
+ if oid is None:
854
+ continue
855
+ if oid not in seen:
856
+ files = {}
857
+ for aid in sorted(paths.objects(oid) or (), key=_rank):
858
+ p = paths.artifact(aid)
859
+ if p is not None:
860
+ files.setdefault(contract.parse_artifact_id(aid)[1], p)
861
+ seen[oid] = files
862
+ x["path"] = next(iter(seen[oid].values()), None)
863
+ x["files"] = dict(sorted(seen[oid].items()))
864
+ out["layout"] = layout
865
+ return out
866
+
867
+
868
+ def view_path(view: str, subject: str) -> str:
869
+ """Where the `original` layout puts the view document of `view` for a catalog subject: views/<view>.json for
870
+ the main catalog, views/<subject>/<view>.json for another."""
871
+ return f"views/{view}.json" if subject == MAIN_SUBJECT else f"views/{subject}/{view}.json"
872
+
873
+
874
+ class ViewStage(Stage):
875
+ """view.<name>: one view of the catalog subject's address table (the result of link.addresses:<subject>) and the
876
+ decoded master data (the fact "master": its directory; no master data, no tasks). Selected by the fact "views"
877
+ (view names; absent: every view). Inputs: "master:<Table>" for every table the view reads (the table files),
878
+ "rules" (the rules document of this view alone: its rule, the resolvers it uses, the format) and "addresses"
879
+ (the part of the address table the view reads, Recorder.subset); both stored at description. Version: the
880
+ view's version in the rules. Artifact "<task id>#view" (nnnotes.view/1); no items (a view covers no object)."""
881
+ after = (ADDRESSES_STAGE,)
882
+
883
+ def __init__(self, view: str, rules: dict):
884
+ if view not in rules.get("views", {}):
885
+ raise ViewError(f"no view {view!r} (views: {', '.join(view_names(rules))})")
886
+ self.view = view
887
+ self.name = STAGE_PREFIX + view
888
+ self.version = rules["views"][view]["version"]
889
+ self.rules = {"format": rules["format"], "resolvers": used_resolvers(rules, view),
890
+ "views": {view: rules["views"][view]}}
891
+ check_rules(self.rules)
892
+ self.rules_bytes = contract.encode(self.rules)
893
+ self.tables = view_tables(self.rules, view)
894
+
895
+ def subjects(self, env) -> list[str]:
896
+ if env.facts.get("master") is None:
897
+ return []
898
+ selected = env.facts.get("views")
899
+ if selected is not None and self.view not in selected:
900
+ return []
901
+ return sorted(env.fact("catalogs"))
902
+
903
+ def depends(self, subject: str, env) -> list[str]:
904
+ return [contract.task_id(ADDRESSES_STAGE, subject)]
905
+
906
+ def master_inputs(self, env) -> list[Input]:
907
+ """The inputs "master:<Table>" of the tables the view reads (ViewError for a table the master data lacks)."""
908
+ mdir = Path(env.fact("master"))
909
+ out = []
910
+ for t in self.tables:
911
+ p = mdir / f"{t}.json"
912
+ if not p.is_file():
913
+ raise ViewError(f"view {self.view}: master table {t} is not in the master data")
914
+ sha, size = env.store.identify(p)
915
+ out.append(Input(MASTER_ROLE + t, sha, size, p.name, ({"kind": "file", "path": str(p)},)))
916
+ return out
917
+
918
+ def inputs(self, subject: str, env) -> list[Input]:
919
+ index = _address_index(env, contract.task_id(ADDRESSES_STAGE, subject))
920
+ masters = self.master_inputs(env)
921
+ tables = {i.role[len(MASTER_ROLE):]: _cached_rows(i.sha256, Path(i.locators[0]["path"]).read_bytes)
922
+ for i in masters}
923
+ rec = Recorder(index)
924
+ build(self.rules, self.view, tables, rec)
925
+ return masters + [_stored(env.store, "addresses", contract.encode(rec.subset())),
926
+ _stored(env.store, "rules", self.rules_bytes)]
927
+
928
+ def estimate(self, subject: str, env, inputs: list[Input]) -> Cost:
929
+ size = sum(i.size for i in inputs)
930
+ return Cost(0.05 + size / 20e6, (48 << 20) + 16 * size)
931
+
932
+ def run(self, task, store) -> Output:
933
+ rules = contract.loads(store.input_bytes(task.input("rules")))
934
+ check_rules(rules)
935
+ if list(rules["views"]) != [self.view] or rules["views"][self.view]["version"] != task.version:
936
+ raise ViewError(f"task {task.id}: its rules are not those of view {self.view} version {task.version}")
937
+ tables = {i.role[len(MASTER_ROLE):]: _cached_rows(i.sha256, functools.partial(store.input_bytes, i))
938
+ for i in task.inputs if i.role.startswith(MASTER_ROLE)}
939
+ index = AddressIndex.from_subset(contract.loads(store.input_bytes(task.input("addresses"))))
940
+ doc = build(rules, self.view, tables, index)
941
+ content = store.add(contract.encode(doc), "json")
942
+ entries: dict[str, int] = {}
943
+ for c in doc["coverage"]["roles"].values():
944
+ for s, n in c["counts"].items():
945
+ entries[s] = entries.get(s, 0) + n
946
+ facts = {"rows": doc["coverage"]["rows"], "entries": dict(sorted(entries.items())), "gaps": gaps(doc),
947
+ "unreferenced": len(doc["coverage"]["reverse"]["unreferenced"])}
948
+ rec = contract.artifact(contract.artifact_id(task.id, VIEW_ROLE), content, contract.provenance(task),
949
+ {"kind": "view", "format": "json", "facts": facts})
950
+ return Output([rec], [])
951
+
952
+ def derived(self, task_id: str, result: dict, store, paths) -> list[tuple[str, bytes]]:
953
+ """The view document with the layout paths of its objects (with_paths), at view_path."""
954
+ aid = contract.artifact_id(task_id, VIEW_ROLE)
955
+ rec = next((a for a in result["artifacts"] if a["id"] == aid), None)
956
+ if rec is None:
957
+ raise ValueError(f"result of {task_id} has no artifact {aid}")
958
+ doc = contract.loads(store.read(rec["content"]["sha256"]))
959
+ subject = contract.parse_task_id(task_id, self.name)[1]
960
+ return [(view_path(self.view, subject), contract.encode(with_paths(doc, paths)))]
961
+
962
+
963
+ def stages(rules: dict | None = None) -> list[ViewStage]:
964
+ """One stage per view of `rules` (default: the package rules), in name order."""
965
+ rules = load_rules() if rules is None else rules
966
+ return [ViewStage(n, rules) for n in view_names(rules)]