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/layout.py ADDED
@@ -0,0 +1,596 @@
1
+ """Output layouts: where the artifacts of a run go as files. A layout never changes artifact bytes or keys.
2
+
3
+ cas assets/<sha256>.<ext> for every distinct content, plus manifest.json
4
+ original the game's names: the container path of each object (in the catalog's case when a catalog location
5
+ matches it case-insensitively), plus manifest.json
6
+
7
+ `original` path rules:
8
+ - several objects under one container path: the primary object (the class the container's extension names,
9
+ EXT_CLASSES; else the first by file and pathId) gets the path, every other `stem[<name>].<ext>` (the
10
+ Addressables sub-object syntax: .../member_full[square].png); sub-objects whose paths coincide all get
11
+ `~<pathId>` (stem[name]~123.png)
12
+ - an object's primary artifact (contract.PRIMARY_ROLES) is written at that path, its extension appended unless
13
+ the path already ends with it (x.prefab -> x.prefab.json, y.png -> y.png); its other artifacts at
14
+ `<path>.<role>.<ext>` (':' and '/' of the role as '.')
15
+ - objects without a container: `<root>/<Class>/<name>~<pathId>.<ext>`, root = the root of the object's
16
+ serialized file when one is given (`object_roots`: the export command roots every object of a bundle at
17
+ `_bundles/<stable bundle name>`, whichever task made the artifact), else the source's root; under a root whose
18
+ objects come from several serialized files (scene bundles, whose files share path ids)
19
+ `<root>/<file>/<Class>/...`
20
+ - a container path present in several bundles (the same asset in two bundles): each bundle's objects are placed
21
+ as above under `stem@<stable bundle name>.ext`, and the report lists the container
22
+ - artifacts of no object (their provenance names no `object`): `<root>/<role>`, the extension appended unless
23
+ the role ends with it
24
+ - every path segment escapes the characters Windows refuses, '%' itself, trailing dots and spaces, and the
25
+ reserved device names as %XX (unescape_path reverses it)
26
+ - paths that coincide ignoring case (or a file named like another path's directory) keep the first by path and
27
+ id; each other gets `~<first 8 hex of sha256(artifact id)>` before its extension and is reported; artifacts with
28
+ the same path and the same content share the file
29
+ So every path ends with its artifact's extension, and `cas` follows from the `original` manifest alone; `original`
30
+ follows from `cas` plus the results (the records). Documents a stage derives for `original` from its records and
31
+ the paths (the views with the files of their objects) are the exception: `original` has the derived documents,
32
+ `cas` the records they are made from.
33
+
34
+ Writing (materialize): the layout manifest (nnnotes.layout/1) lists the files the layout owns. A run writes a
35
+ journal of the files it will write and remove, writes each file atomically (from the byte source by one method:
36
+ a clone, a hard link or a copy), removes the owned files no longer wanted, then writes the manifest and deletes the
37
+ journal; a run after an interrupted one owns what the journal lists too. Files the layout does not own are never
38
+ touched: an unowned file in the way is an error unless it already has the right content. A file the method cannot
39
+ place is reported and left out of the manifest (the next run tries it again); no file falls back to another method.
40
+
41
+ Placement (PLACEMENTS; probe() picks one per run for `auto` and checks an explicit one): a clone (reflink: Linux
42
+ FICLONE, macOS clonefile) shares the store object's blocks until either file is written, so it is independent and
43
+ takes no space; a hard link is the store object itself, read-only like every object of the store (an edit fails
44
+ instead of changing the store); a copy is independent and writable, and takes the space again. Every method gives
45
+ the same files and the same manifest.
46
+ """
47
+ from __future__ import annotations
48
+
49
+ import errno
50
+ import os
51
+ import shutil
52
+ import stat
53
+ import sys
54
+ from dataclasses import dataclass
55
+ from pathlib import Path
56
+
57
+ from . import contract
58
+ from .cache import temp_path
59
+ from .store import write_doc, write_file
60
+
61
+ MANIFEST = "manifest.json"
62
+ JOURNAL = ".nnnotes-layout-journal.json"
63
+ LAYOUTS = ("original", "cas")
64
+ PLACEMENTS = ("clone", "hard", "copy") # how a file is made from its byte source
65
+ LINKS = ("auto", *PLACEMENTS) # what a run may ask for: auto probes
66
+ FICLONE = 0x40049409 # Linux ioctl: make a file share another's blocks
67
+ DEFAULT_ROOT = "_tasks/{stage}/{subject}"
68
+ # container extension -> the classes of the object that owns the container path (lower case, without the dot)
69
+ EXT_CLASSES = {
70
+ **dict.fromkeys(("png", "jpg", "jpeg", "tga", "psd", "exr", "hdr", "tif", "tiff", "bmp", "gif", "iff", "pict"),
71
+ ("Texture2D",)),
72
+ **dict.fromkeys(("prefab", "fbx", "obj", "blend", "dae", "3ds", "max", "ma", "mb"), ("GameObject",)),
73
+ **dict.fromkeys(("txt", "bytes", "json", "csv", "xml", "html", "htm", "yaml", "yml", "md", "fnt"),
74
+ ("TextAsset",)),
75
+ **dict.fromkeys(("wav", "mp3", "ogg", "aif", "aiff", "flac", "m4a"), ("AudioClip",)),
76
+ **dict.fromkeys(("mp4", "mov", "webm", "avi", "m4v", "asf", "wmv", "mpg", "mpeg", "ogv", "vp8"), ("VideoClip",)),
77
+ **dict.fromkeys(("ttf", "otf", "ttc", "fontsettings"), ("Font",)),
78
+ **dict.fromkeys(("spriteatlas", "spriteatlasv2"), ("SpriteAtlas",)),
79
+ "mat": ("Material",), "anim": ("AnimationClip",), "controller": ("AnimatorController",),
80
+ "overridecontroller": ("AnimatorOverrideController",), "shader": ("Shader",),
81
+ "shadervariants": ("ShaderVariantCollection",), "mesh": ("Mesh",), "mask": ("AvatarMask",),
82
+ "physicmaterial": ("PhysicMaterial",), "cubemap": ("Cubemap",), "rendertexture": ("RenderTexture",),
83
+ "asset": ("MonoBehaviour",), "playable": ("MonoBehaviour",), "unity": ("SceneAsset",),
84
+ }
85
+ _BAD = set('<>:"/\\|?*%') | {chr(c) for c in range(32)} | {"\x7f"}
86
+ _RESERVED = {"CON", "PRN", "AUX", "NUL", *(f"COM{i}" for i in range(10)), *(f"LPT{i}" for i in range(10)),
87
+ "COM¹", "COM²", "COM³", "LPT¹", "LPT²", "LPT³"}
88
+
89
+
90
+ class LayoutError(RuntimeError):
91
+ """A layout cannot be written as asked (a file it does not own is in the way)."""
92
+
93
+
94
+ # ---------------------------------------------------------------- escaping
95
+ def escape_segment(s: str) -> str:
96
+ """One path segment as a name every common file system accepts (reversible with unescape_path)."""
97
+ out = "".join(f"%{ord(c):02X}" if c in _BAD else c for c in s)
98
+ body = out.rstrip(". ")
99
+ tail = out[len(body):]
100
+ out = body + "".join(f"%{ord(c):02X}" for c in tail)
101
+ if out.split(".", 1)[0].upper() in _RESERVED:
102
+ out = f"%{ord(out[0]):02X}" + out[1:]
103
+ return out
104
+
105
+
106
+ def escape_path(segments) -> str:
107
+ return "/".join(escape_segment(s) for s in segments if s != "")
108
+
109
+
110
+ def unescape_path(path: str) -> str:
111
+ """The raw text of an escaped path ('/' inside a segment comes back as '/')."""
112
+ out, i = [], 0
113
+ while i < len(path):
114
+ c = path[i]
115
+ if c == "%" and _is_hex(path[i + 1:i + 3]):
116
+ out.append(chr(int(path[i + 1:i + 3], 16)))
117
+ i += 3
118
+ else:
119
+ out.append(c)
120
+ i += 1
121
+ return "".join(out)
122
+
123
+
124
+ def _is_hex(s: str) -> bool:
125
+ return len(s) == 2 and all(c in "0123456789abcdefABCDEF" for c in s)
126
+
127
+
128
+ # ---------------------------------------------------------------- entries
129
+ @dataclass
130
+ class Source:
131
+ """The result of one task as layout input. `root`: the directory of its artifacts that have no container
132
+ (default DEFAULT_ROOT)."""
133
+ task: str
134
+ result: dict
135
+ root: str | None = None
136
+
137
+ def root_segments(self) -> list[str]:
138
+ stage, subject = contract.parse_task_id(self.task)
139
+ root = self.root if self.root is not None else DEFAULT_ROOT.format(stage=stage, subject=subject)
140
+ return [s for s in root.split("/") if s]
141
+
142
+
143
+ def _entry(path: str, record: dict) -> dict:
144
+ c = record["content"]
145
+ return {"path": path, "sha256": c["sha256"], "size": c["size"], "id": record["id"]}
146
+
147
+
148
+ def cas_entries(sources, extra=()) -> list[dict]:
149
+ """The entries of the `cas` layout: assets/<sha256>.<ext> per artifact (artifacts of equal content share it)."""
150
+ out = [dict(e) for e in extra]
151
+ for s in sources:
152
+ for a in s.result["artifacts"]:
153
+ out.append(_entry(f"assets/{a['content']['sha256']}.{a['content']['ext']}", a))
154
+ return sorted(out, key=lambda e: (e["path"], e["id"]))
155
+
156
+
157
+ def cas_from_manifest(doc: dict) -> list[dict]:
158
+ """The `cas` entries of any layout manifest (its paths end with the artifacts' extensions; an artifact placed
159
+ at several paths is one cas entry)."""
160
+ out = {(f"assets/{e['sha256']}.{e['path'].rsplit('.', 1)[1].lower()}", e["id"]): e for e in doc["entries"]}
161
+ return [{"path": p, "sha256": e["sha256"], "size": e["size"], "id": i} for (p, i), e in sorted(out.items())]
162
+
163
+
164
+ def _with_ext(segments: list[str], ext: str) -> list[str]:
165
+ """The segments with `.ext` appended to the last unless it already ends with it (case-insensitively)."""
166
+ last = segments[-1]
167
+ if last.lower().endswith("." + ext.lower()):
168
+ return segments
169
+ return segments[:-1] + [f"{last}.{ext}"]
170
+
171
+
172
+ def _role_segment(role: str) -> str:
173
+ return role.replace(":", ".").replace("/", ".")
174
+
175
+
176
+ def _split_ext(name: str) -> tuple[str, str]:
177
+ stem, dot, ext = name.rpartition(".")
178
+ return (stem, "." + ext) if dot and stem else (name, "")
179
+
180
+
181
+ def original_entries(sources, *, case: dict | None = None, bundles: dict | None = None,
182
+ object_roots: dict | None = None, extra=()) -> tuple[list[dict], dict]:
183
+ """The entries of the `original` layout and its report: {"collisions": [{path, id, renamed}], "shared":
184
+ [{container, sources}]} (container paths present in more than one bundle). `case`: {lower-case container path:
185
+ the catalog's spelling}; `bundles`: {serialized file: stable bundle name} (default: the subject of the task
186
+ whose result holds the object); `object_roots`: {serialized file: root} of the objects without a container path
187
+ (default: their source's root); `extra`: ready entries to place too (derived documents). The artifacts of one
188
+ object may come from several results; they are placed together."""
189
+ case, bundles, object_roots = case or {}, bundles or {}, object_roots or {}
190
+ root_files: dict[str, set] = {}
191
+ for f, r in object_roots.items():
192
+ root_files.setdefault(r, set()).add(f)
193
+ placed: list[tuple[list[str], dict]] = [([s for s in e["path"].split("/") if s], dict(e)) for e in extra]
194
+ objects: dict[tuple, dict] = {} # (file, pathId) -> {obj, records, src}
195
+ for src in sorted(sources, key=lambda s: s.task):
196
+ owners: dict[str, list[dict]] = {}
197
+ for a in src.result["artifacts"]:
198
+ owners.setdefault(contract.parse_artifact_id(a["id"])[0], []).append(a)
199
+ for owner, records in sorted(owners.items()):
200
+ obj = next((r["provenance"]["object"] for r in records if r.get("provenance", {}).get("object")), None)
201
+ if obj is None: # artifacts of no object
202
+ for r in records:
203
+ role = contract.parse_artifact_id(r["id"])[1]
204
+ segs = src.root_segments() + [s for s in role.split("/") if s]
205
+ placed.append((_with_ext(segs, r["content"]["ext"]), _entry("", r)))
206
+ else:
207
+ o = objects.setdefault((obj["file"], int(obj["pathId"])), {"obj": obj, "records": [], "src": src})
208
+ o["records"] += records
209
+ files: dict[str, set] = {} # task -> serialized files of its objects
210
+ for o in objects.values():
211
+ files.setdefault(o["src"].task, set()).add(o["obj"]["file"])
212
+ groups: dict[str, list] = {} # container (folded) -> [(object facts, records, container, bundle)]
213
+ for k in sorted(objects):
214
+ obj, records, src = objects[k]["obj"], objects[k]["records"], objects[k]["src"]
215
+ container = obj.get("container")
216
+ if container:
217
+ container = case.get(container.lower(), container)
218
+ bundle = bundles.get(obj["file"]) or contract.parse_task_id(src.task)[1]
219
+ groups.setdefault(container.casefold(), []).append((obj, records, container, bundle))
220
+ else:
221
+ root = object_roots.get(obj["file"])
222
+ if root is None:
223
+ segs, several = src.root_segments(), len(files[src.task]) > 1 # scene bundles: shared path ids
224
+ else:
225
+ segs, several = [s for s in root.split("/") if s], len(root_files[root]) > 1
226
+ name = f"{obj.get('name') or ''}~{obj['pathId']}"
227
+ base = segs + ([obj["file"]] if several else []) + [obj.get("class") or "Object", name]
228
+ placed += _object_files(base, records, append_ext=True)
229
+ shared = []
230
+ for key in sorted(groups):
231
+ members = groups[key]
232
+ labels = sorted({m[3] for m in members})
233
+ if len(labels) == 1:
234
+ placed += _container_files(members)
235
+ continue
236
+ shared.append({"container": min(m[2] for m in members), "sources": labels})
237
+ for label in labels:
238
+ placed += _container_files([m for m in members if m[3] == label], qualifier=label)
239
+ out, report = _resolve(placed)
240
+ report["shared"] = shared
241
+ return out, report
242
+
243
+
244
+ def _container_files(members: list, qualifier: str | None = None) -> list:
245
+ """Files of the objects sharing one container path (see the module documentation); `qualifier`: the source
246
+ label that tells apart the same container path in several sources (stem@<label>.ext)."""
247
+ container = min(m[2] for m in members)
248
+ segs = [s for s in container.split("/") if s]
249
+ stem, ext = _split_ext(segs[-1])
250
+ if qualifier is not None:
251
+ stem = f"{stem}@{qualifier}"
252
+ segs = segs[:-1] + [stem + ext]
253
+ classes = EXT_CLASSES.get(ext[1:].lower(), ())
254
+ order = sorted(members, key=lambda m: (m[0].get("class") not in classes, m[0]["file"], int(m[0]["pathId"])))
255
+ out = _object_files(segs, order[0][1], append_ext=False)
256
+ subs: dict[str, list] = {}
257
+ for obj, records, *_ in order[1:]:
258
+ name = obj.get("name") or ""
259
+ last = f"{stem}[{name}]{ext}" if name else f"{stem}~{obj['pathId']}{ext}"
260
+ subs.setdefault(last.casefold(), []).append((last, obj, records))
261
+ for _, same in sorted(subs.items()):
262
+ for last, obj, records in same:
263
+ if len(same) > 1:
264
+ s, e = _split_ext(last)
265
+ last = f"{s}~{obj['pathId']}{e}"
266
+ out += _object_files(segs[:-1] + [last], records, append_ext=False)
267
+ return out
268
+
269
+
270
+ def _object_files(base: list[str], records: list[dict], append_ext: bool) -> list:
271
+ """(segments, entry) of one object's artifacts: the primary at base (+ extension), the others beside it."""
272
+ primary = contract.primary_artifact(records)
273
+ out = []
274
+ for r in sorted(records, key=lambda r: r["id"]):
275
+ ext = r["content"]["ext"]
276
+ if r is primary:
277
+ segs = base[:-1] + [f"{base[-1]}.{ext}"] if append_ext else _with_ext(base, ext)
278
+ else:
279
+ role = contract.parse_artifact_id(r["id"])[1]
280
+ segs = base[:-1] + [f"{base[-1]}.{_role_segment(role)}.{ext}"]
281
+ out.append((segs, _entry("", r)))
282
+ return out
283
+
284
+
285
+ def _suffixed(path: str, aid: str) -> str:
286
+ head, _, last = path.rpartition("/")
287
+ s, e = _split_ext(last)
288
+ last = f"{s}~{contract.sha256(aid.encode('utf-8'))[:8]}{e}"
289
+ return f"{head}/{last}" if head else last
290
+
291
+
292
+ def _resolve(placed: list) -> tuple[list[dict], dict]:
293
+ """Escaped paths, with collisions (ignoring case; file versus directory; the reserved names) renamed."""
294
+ entries = []
295
+ for segs, e in placed:
296
+ e["path"] = escape_path(segs)
297
+ entries.append(e)
298
+ collisions = []
299
+ for _ in range(8):
300
+ entries.sort(key=lambda e: (e["path"], e["id"]))
301
+ taken: dict[str, tuple] = {MANIFEST.casefold(): (None, None), JOURNAL.casefold(): (None, None)}
302
+ dirs = set()
303
+ for e in entries:
304
+ parts = e["path"].casefold().split("/")
305
+ dirs.update("/".join(parts[:i]) for i in range(1, len(parts)))
306
+ changed = False
307
+ for e in entries:
308
+ f = e["path"].casefold()
309
+ owner = taken.get(f)
310
+ if f not in dirs and (owner is None or owner == (e["path"], e["sha256"])):
311
+ taken[f] = (e["path"], e["sha256"])
312
+ continue
313
+ new = _suffixed(e["path"], e["id"])
314
+ collisions.append({"path": e["path"], "id": e["id"], "renamed": new})
315
+ e["path"] = new
316
+ changed = True
317
+ if not changed:
318
+ break
319
+ else:
320
+ raise LayoutError("path collisions do not resolve")
321
+ exact: dict[str, str] = {}
322
+ for e in entries: # one path, one content (equal case: shared file)
323
+ if exact.setdefault(e["path"], e["sha256"]) != e["sha256"]:
324
+ raise LayoutError(f"two contents at {e['path']}")
325
+ return sorted(entries, key=lambda e: (e["path"], e["id"])), {"collisions": sorted(
326
+ collisions, key=lambda c: (c["path"], c["id"]))}
327
+
328
+
329
+ def entries(name: str, sources, **kw) -> tuple[list[dict], dict]:
330
+ """The entries and report of layout `name`."""
331
+ if name == "cas":
332
+ return cas_entries(sources, kw.get("extra", ())), {"collisions": [], "shared": []}
333
+ if name == "original":
334
+ return original_entries(sources, **kw)
335
+ raise ValueError(f"unknown layout {name!r} (one of {', '.join(LAYOUTS)})")
336
+
337
+
338
+ def paths(entries_: list[dict]) -> dict[str, str]:
339
+ """{artifact id: path} of layout entries."""
340
+ return {e["id"]: e["path"] for e in entries_}
341
+
342
+
343
+ # ---------------------------------------------------------------- writing
344
+ def from_store(store):
345
+ """A byte source reading the store's cas/."""
346
+ return store.path
347
+
348
+
349
+ def from_layout(directory):
350
+ """A byte source reading another layout's directory through its manifest."""
351
+ d = Path(directory)
352
+ doc = read_manifest(d)
353
+ if doc is None:
354
+ raise LayoutError(f"{d} has no layout manifest")
355
+ by_sha = {}
356
+ for e in doc["entries"]:
357
+ by_sha.setdefault(e["sha256"], d / e["path"])
358
+ return lambda sha: by_sha[sha]
359
+
360
+
361
+ def read_manifest(directory) -> dict | None:
362
+ try:
363
+ return contract.loads((Path(directory) / MANIFEST).read_bytes())
364
+ except OSError:
365
+ return None
366
+
367
+
368
+ def _files(entries_: list[dict]) -> dict[str, tuple[str, int]]:
369
+ files: dict[str, tuple[str, int]] = {}
370
+ for e in entries_:
371
+ if files.setdefault(e["path"], (e["sha256"], e["size"])) != (e["sha256"], e["size"]):
372
+ raise LayoutError(f"two contents at {e['path']}")
373
+ return files
374
+
375
+
376
+ def _owned(out: Path) -> tuple[dict[str, tuple[str, int]], set[str]]:
377
+ old = read_manifest(out)
378
+ owned = {e["path"]: (e["sha256"], e["size"]) for e in old["entries"]} if old else {}
379
+ journal = set()
380
+ try:
381
+ j = contract.loads((out / JOURNAL).read_bytes())
382
+ journal = set(j.get("write", [])) | set(j.get("remove", []))
383
+ except (OSError, ValueError):
384
+ pass
385
+ return owned, journal
386
+
387
+
388
+ def _present(p: Path, size: int) -> bool:
389
+ try:
390
+ return p.stat().st_size == size
391
+ except OSError:
392
+ return False
393
+
394
+
395
+ def diff(out_dir, entries_: list[dict]) -> dict:
396
+ """What materialize would do in `out_dir`: {"write", "remove", "keep"} file counts."""
397
+ out = Path(out_dir)
398
+ files = _files(entries_)
399
+ owned, journal = _owned(out)
400
+ write = sum(1 for p, v in files.items() if owned.get(p) != v or not _present(out / p, v[1]))
401
+ remove = len((set(owned) | journal) - set(files))
402
+ return {"write": write, "remove": remove, "keep": len(files) - write}
403
+
404
+
405
+ def materialize(out_dir, name: str, params: dict, entries_: list[dict], source, *, link: str = "copy") -> dict:
406
+ """Write layout `name` into `out_dir` (see the module documentation). `source(sha256) -> Path` gives the bytes
407
+ (from_store, from_layout); `link`: the placement (PLACEMENTS). -> {"manifest": the layout document (without
408
+ the files not placed), "sha256": of its file, "write", "remove", "keep", "cloned", "linked", "copied",
409
+ "failed": [{path, message}] of the files not placed}."""
410
+ if link not in PLACEMENTS:
411
+ raise ValueError(f"unknown placement {link!r}")
412
+ out = Path(out_dir)
413
+ out.mkdir(parents=True, exist_ok=True)
414
+ doc = contract.layout_doc(name, params, entries_)
415
+ files = _files(doc["entries"])
416
+ owned, journal = _owned(out)
417
+ mine = set(owned) | journal
418
+ write = sorted(p for p, v in files.items() if owned.get(p) != v or not _present(out / p, v[1]))
419
+ remove = sorted(mine - set(files))
420
+ blocked = [p for p in write if p not in mine and (out / p).exists() and not _same(out / p, files[p])]
421
+ if blocked:
422
+ raise LayoutError(f"{len(blocked)} files in {out} are not this layout's: {', '.join(blocked[:5])}")
423
+ write_file(out / JOURNAL, contract.encode({"write": write, "remove": remove}))
424
+ stats = {"cloned": 0, "linked": 0, "copied": 0}
425
+ failed = []
426
+
427
+ def restore(p): # a replaced or removed hard link's store object: read-only again
428
+ if p in owned:
429
+ try:
430
+ _read_only(Path(source(owned[p][0])))
431
+ except (KeyError, OSError):
432
+ pass
433
+
434
+ for p in write:
435
+ sha, size = files[p]
436
+ dst = out / p
437
+ if not (p not in mine and _present(dst, size) and _same(dst, files[p])):
438
+ try:
439
+ _place(Path(source(sha)), dst, link, stats, lambda p=p: restore(p))
440
+ except OSError as e:
441
+ failed.append({"path": p, "message": f"{type(e).__name__}: {e.strerror or e}"})
442
+ for p in remove:
443
+ _remove(out, p, lambda p=p: restore(p))
444
+ lost = {f["path"] for f in failed}
445
+ if lost:
446
+ doc = dict(doc, entries=[e for e in doc["entries"] if e["path"] not in lost])
447
+ sha = write_doc(out / MANIFEST, doc)
448
+ if lost: # still owned: the next run replaces them
449
+ write_file(out / JOURNAL, contract.encode({"write": sorted(lost), "remove": []}))
450
+ else:
451
+ (out / JOURNAL).unlink(missing_ok=True)
452
+ return {"manifest": doc, "sha256": sha, "write": len(write) - len(failed), "remove": len(remove),
453
+ "keep": len(files) - len(write), **stats, "failed": failed}
454
+
455
+
456
+ def _same(p: Path, v: tuple[str, int]) -> bool:
457
+ if not _present(p, v[1]):
458
+ return False
459
+ return contract.sha256(p.read_bytes()) == v[0]
460
+
461
+
462
+ def _place(src: Path, dst: Path, link: str, stats: dict, restore) -> None:
463
+ dst.parent.mkdir(parents=True, exist_ok=True)
464
+ tmp = temp_path(dst)
465
+ try:
466
+ if link == "clone":
467
+ clone(src, tmp)
468
+ elif link == "hard":
469
+ _read_only(src) # an object stored before the store made them read-only
470
+ os.link(src, tmp)
471
+ else:
472
+ shutil.copyfile(src, tmp)
473
+ again = _writable(dst)
474
+ os.replace(tmp, dst)
475
+ if again:
476
+ restore()
477
+ except BaseException:
478
+ tmp.unlink(missing_ok=True)
479
+ raise
480
+ stats[{"clone": "cloned", "hard": "linked", "copy": "copied"}[link]] += 1
481
+
482
+
483
+ def _read_only(p: Path) -> None:
484
+ """Make a store object read-only (its hard links in layouts are the object itself)."""
485
+ try:
486
+ mode = os.stat(p).st_mode
487
+ if mode & 0o222:
488
+ os.chmod(p, mode & ~0o222)
489
+ except OSError:
490
+ pass
491
+
492
+
493
+ def _writable(p: Path) -> bool:
494
+ """Clear the read-only attribute of a layout file about to be replaced or removed, where the system refuses
495
+ to do either to a read-only file (Windows; the attribute belongs to the file, so a hard link's store object
496
+ loses it too: True, the caller sets it again). Elsewhere nothing: False."""
497
+ if os.name != "nt":
498
+ return False
499
+ try:
500
+ mode = os.stat(p).st_mode
501
+ except OSError:
502
+ return False
503
+ if mode & stat.S_IWRITE:
504
+ return False
505
+ os.chmod(p, mode | stat.S_IWRITE)
506
+ return True
507
+
508
+
509
+ def clone(src: Path, dst: Path) -> None:
510
+ """Make `dst` (a new file) a clone of `src`: its own file, sharing src's blocks until either is written (Linux
511
+ FICLONE, macOS clonefile). OSError where the file system or the platform cannot."""
512
+ if sys.platform.startswith("linux"):
513
+ import fcntl
514
+ fd = os.open(dst, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o666)
515
+ try:
516
+ with open(src, "rb") as f:
517
+ fcntl.ioctl(fd, FICLONE, f.fileno())
518
+ except OSError:
519
+ os.close(fd)
520
+ os.unlink(dst)
521
+ raise
522
+ os.close(fd)
523
+ elif sys.platform == "darwin":
524
+ import ctypes
525
+ import ctypes.util
526
+ libc = ctypes.CDLL(ctypes.util.find_library("c"), use_errno=True)
527
+ if libc.clonefile(os.fsencode(src), os.fsencode(dst), 0) != 0:
528
+ e = ctypes.get_errno()
529
+ raise OSError(e, os.strerror(e), str(dst))
530
+ else:
531
+ raise OSError(errno.EOPNOTSUPP, "files cannot be cloned on this platform", str(dst))
532
+
533
+
534
+ _ELSEWHERE = (errno.EXDEV,)
535
+
536
+
537
+ def _why(e: OSError, what: str) -> str:
538
+ if e.errno in _ELSEWHERE:
539
+ return "store on another file system"
540
+ if "platform" in str(e):
541
+ return f"{what} unsupported on this platform"
542
+ return f"{what} unsupported on this file system ({errno.errorcode.get(e.errno, e.errno)})"
543
+
544
+
545
+ def probe(out_dir, store_dir, want: str = "auto") -> tuple[str, str]:
546
+ """The placement of a layout in `out_dir` whose files come from the store objects in `store_dir`, for `want`
547
+ (LINKS), tried once on a temporary file in each: (placement, reason). auto: a clone where the file system makes
548
+ them, else a hard link on the same file system, else a copy. An explicit clone or hard link that does not work
549
+ raises LayoutError."""
550
+ if want not in LINKS:
551
+ raise ValueError(f"unknown link mode {want!r} (one of {', '.join(LINKS)})")
552
+ if want == "copy":
553
+ return "copy", "copy: as asked"
554
+ out, store = Path(out_dir), Path(store_dir)
555
+ out.mkdir(parents=True, exist_ok=True)
556
+ store.mkdir(parents=True, exist_ok=True)
557
+ src, made = temp_path(store / "probe"), []
558
+ try:
559
+ with open(src, "xb") as f:
560
+ f.write(b"nnnotes placement probe\n")
561
+ why = "as asked"
562
+ if want in ("auto", "clone"):
563
+ made.append(temp_path(out / "probe"))
564
+ try:
565
+ clone(src, made[-1])
566
+ return "clone", "clone: the file system shares the store objects' blocks"
567
+ except OSError as e:
568
+ why = _why(e, "clone")
569
+ if want == "clone":
570
+ raise LayoutError(f"--link clone: {why}") from None
571
+ made.append(temp_path(out / "probe"))
572
+ try:
573
+ os.link(src, made[-1])
574
+ except OSError as e:
575
+ if want == "hard":
576
+ raise LayoutError(f"--link hard: {_why(e, 'hard links')}") from None
577
+ return "copy", f"copy: {_why(e, 'hard links')}"
578
+ return "hard", f"hard links: {why}"
579
+ finally:
580
+ for p in (src, *made):
581
+ p.unlink(missing_ok=True)
582
+
583
+
584
+ def _remove(out: Path, rel: str, restore=None) -> None:
585
+ p = out / rel
586
+ again = _writable(p)
587
+ p.unlink(missing_ok=True)
588
+ if again and restore is not None:
589
+ restore()
590
+ d = p.parent
591
+ while d != out and out in d.parents:
592
+ try:
593
+ d.rmdir() # only when empty
594
+ except OSError:
595
+ break
596
+ d = d.parent