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