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