sci-protocol 0.2.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.
- pysci/__init__.py +77 -0
- pysci/__main__.py +8 -0
- pysci/_cli/__init__.py +5 -0
- pysci/_cli/checks.py +173 -0
- pysci/_cli/clients/__init__.py +0 -0
- pysci/_cli/clients/archive.py +456 -0
- pysci/_cli/clients/credentials.py +54 -0
- pysci/_cli/clients/github.py +734 -0
- pysci/_cli/clients/hosting.py +238 -0
- pysci/_cli/clients/http.py +481 -0
- pysci/_cli/clients/huggingface.py +492 -0
- pysci/_cli/clients/index.py +435 -0
- pysci/_cli/clients/tsa.py +223 -0
- pysci/_cli/commands/__init__.py +44 -0
- pysci/_cli/commands/check.py +115 -0
- pysci/_cli/commands/claim.py +179 -0
- pysci/_cli/commands/common.py +91 -0
- pysci/_cli/commands/folders.py +83 -0
- pysci/_cli/commands/index.py +51 -0
- pysci/_cli/commands/keygen.py +58 -0
- pysci/_cli/commands/license.py +75 -0
- pysci/_cli/commands/protocol.py +72 -0
- pysci/_cli/commands/publish.py +282 -0
- pysci/_cli/commands/pull.py +337 -0
- pysci/_cli/commands/read.py +364 -0
- pysci/_cli/commands/revoke.py +207 -0
- pysci/_cli/commands/search.py +400 -0
- pysci/_cli/commands/sign.py +284 -0
- pysci/_cli/commands/submit.py +172 -0
- pysci/_cli/commands/timestamp.py +196 -0
- pysci/_cli/commands/verify.py +163 -0
- pysci/_cli/config.py +233 -0
- pysci/_cli/context.py +79 -0
- pysci/_cli/destinations.py +163 -0
- pysci/_cli/errors.py +168 -0
- pysci/_cli/main.py +229 -0
- pysci/_cli/output.py +80 -0
- pysci/_cli/retrieval.py +51 -0
- pysci/_cli/spec.py +113 -0
- pysci/_cli/storage/__init__.py +0 -0
- pysci/_cli/storage/catalog.py +371 -0
- pysci/_cli/storage/files.py +252 -0
- pysci/_cli/storage/inputs.py +205 -0
- pysci/_cli/storage/keys.py +132 -0
- pysci/_cli/storage/local_publish.py +88 -0
- pysci/accounts.py +135 -0
- pysci/baseline.py +142 -0
- pysci/bundle.py +878 -0
- pysci/citations.py +233 -0
- pysci/data/freetsa-cacert.pem +45 -0
- pysci/data/sci-baseline-LICENSE.txt +457 -0
- pysci/data/sci-protocol.md +613 -0
- pysci/der.py +146 -0
- pysci/discovery.py +461 -0
- pysci/draft.py +374 -0
- pysci/ed25519.py +153 -0
- pysci/errors.py +204 -0
- pysci/ids.py +61 -0
- pysci/jsonstrict.py +225 -0
- pysci/keys.py +247 -0
- pysci/manifest.py +302 -0
- pysci/metadata.py +406 -0
- pysci/paths.py +136 -0
- pysci/png.py +217 -0
- pysci/py.typed +0 -0
- pysci/records.py +384 -0
- pysci/rfc3339.py +66 -0
- pysci/schemas.py +75 -0
- pysci/source.py +166 -0
- pysci/specification.py +58 -0
- pysci/submission.py +225 -0
- pysci/templates.py +129 -0
- pysci/text.py +226 -0
- pysci/timestamps.py +867 -0
- pysci/zipformat.py +400 -0
- sci_protocol-0.2.0.dist-info/METADATA +112 -0
- sci_protocol-0.2.0.dist-info/RECORD +80 -0
- sci_protocol-0.2.0.dist-info/WHEEL +4 -0
- sci_protocol-0.2.0.dist-info/entry_points.txt +2 -0
- sci_protocol-0.2.0.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
"""``catalog.sqlite``: the local search cache keyed by verified publication ID.
|
|
2
|
+
|
|
3
|
+
File state (device, inode, size, modification and change times) detects
|
|
4
|
+
changes; it is not a content proof. Unchanged files reuse their rows without
|
|
5
|
+
opening the ZIP. Other commands verify their inputs independently.
|
|
6
|
+
|
|
7
|
+
Several processes on one machine can share a catalog (WAL mode). WAL does not
|
|
8
|
+
work across machines, so keep the state directory on a local disk.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import contextlib
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
import random
|
|
17
|
+
import sqlite3
|
|
18
|
+
from collections.abc import Iterator, Sequence
|
|
19
|
+
from dataclasses import dataclass, field
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
from pysci.bundle import read_zip_entry, verify_zip
|
|
24
|
+
from pysci.errors import Outcome, SciError
|
|
25
|
+
from pysci.manifest import README_PATH
|
|
26
|
+
from pysci.source import FileSource
|
|
27
|
+
|
|
28
|
+
from ..errors import issue_json
|
|
29
|
+
|
|
30
|
+
SCHEMA_VERSION = "1"
|
|
31
|
+
BUSY_TIMEOUT_SECONDS = 30.0
|
|
32
|
+
_CORRUPT_CODES = (sqlite3.SQLITE_CORRUPT, sqlite3.SQLITE_NOTADB)
|
|
33
|
+
_BUSY_CODES = (sqlite3.SQLITE_BUSY, sqlite3.SQLITE_LOCKED)
|
|
34
|
+
|
|
35
|
+
_SCHEMA = """
|
|
36
|
+
CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);
|
|
37
|
+
CREATE TABLE IF NOT EXISTS files (
|
|
38
|
+
path TEXT PRIMARY KEY,
|
|
39
|
+
folder TEXT NOT NULL,
|
|
40
|
+
dev INTEGER NOT NULL,
|
|
41
|
+
ino INTEGER NOT NULL,
|
|
42
|
+
size INTEGER NOT NULL,
|
|
43
|
+
mtime_ns INTEGER NOT NULL,
|
|
44
|
+
ctime_ns INTEGER NOT NULL,
|
|
45
|
+
publication_id TEXT,
|
|
46
|
+
status TEXT NOT NULL,
|
|
47
|
+
issues TEXT NOT NULL DEFAULT '[]'
|
|
48
|
+
);
|
|
49
|
+
CREATE INDEX IF NOT EXISTS files_folder ON files(folder);
|
|
50
|
+
CREATE INDEX IF NOT EXISTS files_publication ON files(publication_id);
|
|
51
|
+
CREATE TABLE IF NOT EXISTS publications (
|
|
52
|
+
id TEXT PRIMARY KEY,
|
|
53
|
+
title TEXT NOT NULL,
|
|
54
|
+
abstract TEXT NOT NULL,
|
|
55
|
+
keywords TEXT NOT NULL,
|
|
56
|
+
categories TEXT NOT NULL,
|
|
57
|
+
citations TEXT NOT NULL,
|
|
58
|
+
signer_x TEXT NOT NULL,
|
|
59
|
+
baseline INTEGER NOT NULL,
|
|
60
|
+
search_text TEXT NOT NULL
|
|
61
|
+
);
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
SEARCHED_FIELDS = ("title", "abstract", "keywords", "README.md")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass(slots=True)
|
|
68
|
+
class ScanReport:
|
|
69
|
+
available: list[str] = field(default_factory=list)
|
|
70
|
+
unavailable: list[dict[str, str]] = field(default_factory=list)
|
|
71
|
+
verified: int = 0
|
|
72
|
+
reused: int = 0
|
|
73
|
+
invalid: list[dict[str, Any]] = field(default_factory=list)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass(frozen=True, slots=True)
|
|
77
|
+
class LocalHit:
|
|
78
|
+
publication_id: str
|
|
79
|
+
title: str
|
|
80
|
+
abstract: str
|
|
81
|
+
signer_x: str
|
|
82
|
+
citations: list[Any]
|
|
83
|
+
categories: list[Any]
|
|
84
|
+
keywords: list[str]
|
|
85
|
+
baseline: bool
|
|
86
|
+
paths: list[str]
|
|
87
|
+
|
|
88
|
+
def to_json(self) -> dict[str, Any]:
|
|
89
|
+
return {
|
|
90
|
+
"id": self.publication_id,
|
|
91
|
+
"title": self.title,
|
|
92
|
+
"abstract": self.abstract,
|
|
93
|
+
"signer": {"kty": "OKP", "crv": "Ed25519", "x": self.signer_x},
|
|
94
|
+
"categories": self.categories,
|
|
95
|
+
"keywords": self.keywords,
|
|
96
|
+
"citations": self.citations,
|
|
97
|
+
"baseline": self.baseline,
|
|
98
|
+
"paths": self.paths,
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class _SchemaMismatch(Exception):
|
|
103
|
+
"""The file is a database from another catalog schema."""
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def is_corrupt(exc: sqlite3.Error) -> bool:
|
|
107
|
+
"""True only for damage to the file; a busy or locked database is not damaged."""
|
|
108
|
+
return (exc.sqlite_errorcode & 0xFF) in _CORRUPT_CODES
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _stat_key(info: os.stat_result) -> tuple[int, int, int, int, int]:
|
|
112
|
+
return (info.st_dev, info.st_ino, info.st_size, info.st_mtime_ns, info.st_ctime_ns)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class Catalog:
|
|
116
|
+
def __init__(self, path: Path) -> None:
|
|
117
|
+
self.path = path
|
|
118
|
+
self.rebuilt = False
|
|
119
|
+
self.db = self._open()
|
|
120
|
+
|
|
121
|
+
def _open(self) -> sqlite3.Connection:
|
|
122
|
+
self.path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
123
|
+
try:
|
|
124
|
+
return self._connect()
|
|
125
|
+
except _SchemaMismatch:
|
|
126
|
+
pass
|
|
127
|
+
except sqlite3.DatabaseError as exc:
|
|
128
|
+
if not is_corrupt(exc):
|
|
129
|
+
raise
|
|
130
|
+
self._discard()
|
|
131
|
+
return self._connect()
|
|
132
|
+
|
|
133
|
+
def _discard(self) -> None:
|
|
134
|
+
for suffix in ("", "-wal", "-shm", "-journal"):
|
|
135
|
+
with contextlib.suppress(FileNotFoundError):
|
|
136
|
+
os.unlink(str(self.path) + suffix)
|
|
137
|
+
self.rebuilt = True
|
|
138
|
+
|
|
139
|
+
def _connect(self) -> sqlite3.Connection:
|
|
140
|
+
db = sqlite3.connect(self.path, timeout=BUSY_TIMEOUT_SECONDS, isolation_level=None)
|
|
141
|
+
try:
|
|
142
|
+
self._enable_wal(db)
|
|
143
|
+
# An existing catalog is opened without taking the write lock.
|
|
144
|
+
if db.execute("SELECT 1 FROM sqlite_master WHERE name='meta'").fetchone() is None:
|
|
145
|
+
db.executescript(
|
|
146
|
+
f"BEGIN IMMEDIATE;{_SCHEMA}INSERT OR IGNORE INTO meta(key, value) "
|
|
147
|
+
f"VALUES ('schema', '{SCHEMA_VERSION}');COMMIT;"
|
|
148
|
+
)
|
|
149
|
+
row = db.execute("SELECT value FROM meta WHERE key='schema'").fetchone()
|
|
150
|
+
if row is None or row[0] != SCHEMA_VERSION:
|
|
151
|
+
raise _SchemaMismatch
|
|
152
|
+
except BaseException:
|
|
153
|
+
db.close()
|
|
154
|
+
raise
|
|
155
|
+
return db
|
|
156
|
+
|
|
157
|
+
@staticmethod
|
|
158
|
+
def _enable_wal(db: sqlite3.Connection) -> None:
|
|
159
|
+
try:
|
|
160
|
+
db.execute("PRAGMA journal_mode=WAL").fetchone()
|
|
161
|
+
except sqlite3.OperationalError as exc:
|
|
162
|
+
# Another process holds the lock; a later open switches the mode.
|
|
163
|
+
if (exc.sqlite_errorcode & 0xFF) not in _BUSY_CODES:
|
|
164
|
+
raise
|
|
165
|
+
|
|
166
|
+
def reset(self) -> None:
|
|
167
|
+
"""Replace a catalog found to be corrupt after opening."""
|
|
168
|
+
self.db.close()
|
|
169
|
+
self._discard()
|
|
170
|
+
self.db = self._connect()
|
|
171
|
+
|
|
172
|
+
def close(self) -> None:
|
|
173
|
+
self.db.close()
|
|
174
|
+
|
|
175
|
+
@contextlib.contextmanager
|
|
176
|
+
def _write(self) -> Iterator[sqlite3.Connection]:
|
|
177
|
+
self.db.execute("BEGIN IMMEDIATE")
|
|
178
|
+
try:
|
|
179
|
+
yield self.db
|
|
180
|
+
except BaseException:
|
|
181
|
+
self.db.execute("ROLLBACK")
|
|
182
|
+
raise
|
|
183
|
+
self.db.execute("COMMIT")
|
|
184
|
+
|
|
185
|
+
# ------------------------------------------------------------------ scanning
|
|
186
|
+
|
|
187
|
+
def scan(self, folders: Sequence[Path]) -> ScanReport:
|
|
188
|
+
report = ScanReport()
|
|
189
|
+
changed = False
|
|
190
|
+
for folder in folders:
|
|
191
|
+
key = str(folder)
|
|
192
|
+
try:
|
|
193
|
+
entries = list(os.scandir(folder))
|
|
194
|
+
except OSError as exc:
|
|
195
|
+
report.unavailable.append({"folder": key, "error": str(exc)})
|
|
196
|
+
continue
|
|
197
|
+
current: dict[str, os.stat_result] = {}
|
|
198
|
+
for entry in entries:
|
|
199
|
+
if not entry.name.endswith(".zip"):
|
|
200
|
+
continue
|
|
201
|
+
try:
|
|
202
|
+
info = os.stat(entry.path)
|
|
203
|
+
except OSError:
|
|
204
|
+
continue
|
|
205
|
+
if not os.path.isfile(entry.path):
|
|
206
|
+
continue
|
|
207
|
+
current[entry.path] = info
|
|
208
|
+
known = {
|
|
209
|
+
row[0]: tuple(row[1:6])
|
|
210
|
+
for row in self.db.execute(
|
|
211
|
+
"SELECT path, dev, ino, size, mtime_ns, ctime_ns FROM files WHERE folder=?",
|
|
212
|
+
(key,),
|
|
213
|
+
)
|
|
214
|
+
}
|
|
215
|
+
# Random order spreads processes that start together over different files.
|
|
216
|
+
for path, info in random.sample(sorted(current.items()), len(current)):
|
|
217
|
+
state = _stat_key(info)
|
|
218
|
+
if known.get(path) == state or self._indexed(path, state):
|
|
219
|
+
report.reused += 1
|
|
220
|
+
continue
|
|
221
|
+
self._index_file(Path(path), key, info, report)
|
|
222
|
+
# A path missing from this listing may have been added since it was taken.
|
|
223
|
+
absent = [p for p in known if p not in current and not os.path.exists(p)]
|
|
224
|
+
if absent:
|
|
225
|
+
changed = True
|
|
226
|
+
with self._write() as db:
|
|
227
|
+
db.executemany("DELETE FROM files WHERE path=?", [(p,) for p in absent])
|
|
228
|
+
report.available.append(key)
|
|
229
|
+
for row in self.db.execute(
|
|
230
|
+
"SELECT path, status, publication_id, issues FROM files "
|
|
231
|
+
"WHERE folder=? AND status != 'valid' ORDER BY path",
|
|
232
|
+
(key,),
|
|
233
|
+
):
|
|
234
|
+
report.invalid.append(
|
|
235
|
+
{
|
|
236
|
+
"path": row[0],
|
|
237
|
+
"status": row[1],
|
|
238
|
+
"publication_id": row[2],
|
|
239
|
+
"issues": json.loads(row[3]),
|
|
240
|
+
}
|
|
241
|
+
)
|
|
242
|
+
if changed or report.verified:
|
|
243
|
+
with self._write() as db:
|
|
244
|
+
db.execute(
|
|
245
|
+
"DELETE FROM publications WHERE id NOT IN "
|
|
246
|
+
"(SELECT publication_id FROM files WHERE publication_id IS NOT NULL)"
|
|
247
|
+
)
|
|
248
|
+
return report
|
|
249
|
+
|
|
250
|
+
def _indexed(self, path: str, state: tuple[int, ...]) -> bool:
|
|
251
|
+
"""True if another process has indexed this file state since the scan began."""
|
|
252
|
+
row = self.db.execute(
|
|
253
|
+
"SELECT dev, ino, size, mtime_ns, ctime_ns FROM files WHERE path=?", (path,)
|
|
254
|
+
).fetchone()
|
|
255
|
+
return row is not None and tuple(row) == state
|
|
256
|
+
|
|
257
|
+
def _index_file(
|
|
258
|
+
self, path: Path, folder: str, info: os.stat_result, report: ScanReport
|
|
259
|
+
) -> None:
|
|
260
|
+
result = verify_zip(path)
|
|
261
|
+
report.verified += 1
|
|
262
|
+
if result.outcome is Outcome.INCOMPLETE:
|
|
263
|
+
# Not cached: an unavailable file or local limit is retried next time.
|
|
264
|
+
report.invalid.append(
|
|
265
|
+
{
|
|
266
|
+
"path": str(path),
|
|
267
|
+
"status": str(result.outcome),
|
|
268
|
+
"publication_id": result.publication_id,
|
|
269
|
+
"issues": [issue_json(i) for i in result.issues],
|
|
270
|
+
}
|
|
271
|
+
)
|
|
272
|
+
return
|
|
273
|
+
publication: tuple[Any, ...] | None = None
|
|
274
|
+
status = str(result.outcome)
|
|
275
|
+
issues = [issue_json(i) for i in result.issues]
|
|
276
|
+
if result.ok and result.metadata is not None and result.manifest is not None:
|
|
277
|
+
try:
|
|
278
|
+
with FileSource(path) as source:
|
|
279
|
+
_, readme = read_zip_entry(
|
|
280
|
+
source, README_PATH, expected_id=result.publication_id
|
|
281
|
+
)
|
|
282
|
+
except (SciError, OSError) as exc:
|
|
283
|
+
status, issues = "incomplete", [{"code": "read_failed", "message": str(exc)}]
|
|
284
|
+
report.invalid.append({"path": str(path), "status": status, "issues": issues})
|
|
285
|
+
return
|
|
286
|
+
meta = result.metadata
|
|
287
|
+
raw = meta.raw
|
|
288
|
+
search_text = "\n".join(
|
|
289
|
+
[meta.title, meta.abstract, " ".join(meta.keywords), readme.decode("utf-8")]
|
|
290
|
+
).casefold()
|
|
291
|
+
publication = (
|
|
292
|
+
result.manifest.publication_id,
|
|
293
|
+
meta.title,
|
|
294
|
+
meta.abstract,
|
|
295
|
+
json.dumps(list(meta.keywords)),
|
|
296
|
+
json.dumps(raw.get("categories", [])),
|
|
297
|
+
json.dumps(raw.get("citations", [])),
|
|
298
|
+
result.manifest.public_key.x,
|
|
299
|
+
1 if result.baseline else 0,
|
|
300
|
+
search_text,
|
|
301
|
+
)
|
|
302
|
+
with self._write() as db:
|
|
303
|
+
if publication is not None:
|
|
304
|
+
db.execute(
|
|
305
|
+
"INSERT OR REPLACE INTO publications VALUES (?,?,?,?,?,?,?,?,?)", publication
|
|
306
|
+
)
|
|
307
|
+
db.execute(
|
|
308
|
+
"INSERT OR REPLACE INTO files VALUES (?,?,?,?,?,?,?,?,?,?)",
|
|
309
|
+
(
|
|
310
|
+
str(path),
|
|
311
|
+
folder,
|
|
312
|
+
*_stat_key(info),
|
|
313
|
+
result.publication_id if publication is not None else None,
|
|
314
|
+
status,
|
|
315
|
+
json.dumps(issues),
|
|
316
|
+
),
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
# ------------------------------------------------------------------ queries
|
|
320
|
+
|
|
321
|
+
def search(
|
|
322
|
+
self,
|
|
323
|
+
folders: Sequence[str],
|
|
324
|
+
terms: Sequence[str],
|
|
325
|
+
*,
|
|
326
|
+
category: tuple[str, str] | None,
|
|
327
|
+
key_x: str | None,
|
|
328
|
+
after: str | None,
|
|
329
|
+
limit: int,
|
|
330
|
+
) -> tuple[list[LocalHit], bool]:
|
|
331
|
+
"""Matches in ID order after ``after``; returns (hits, more available)."""
|
|
332
|
+
if not folders:
|
|
333
|
+
return [], False
|
|
334
|
+
marks = ",".join("?" for _ in folders)
|
|
335
|
+
sql = (
|
|
336
|
+
"SELECT p.id, p.title, p.abstract, p.signer_x, p.citations, p.categories, "
|
|
337
|
+
"p.keywords, p.baseline, group_concat(f.path, char(10)) "
|
|
338
|
+
"FROM publications p JOIN files f ON f.publication_id = p.id "
|
|
339
|
+
f"WHERE f.folder IN ({marks}) AND p.id > ?"
|
|
340
|
+
)
|
|
341
|
+
params: list[Any] = [*folders, after or ""]
|
|
342
|
+
for term in terms:
|
|
343
|
+
sql += " AND instr(p.search_text, ?) > 0"
|
|
344
|
+
params.append(term)
|
|
345
|
+
if key_x is not None:
|
|
346
|
+
sql += " AND p.signer_x = ?"
|
|
347
|
+
params.append(key_x)
|
|
348
|
+
sql += " GROUP BY p.id ORDER BY p.id"
|
|
349
|
+
hits: list[LocalHit] = []
|
|
350
|
+
for row in self.db.execute(sql, params):
|
|
351
|
+
categories = json.loads(row[5])
|
|
352
|
+
if category is not None and not any(
|
|
353
|
+
c.get("scheme") == category[0] and c.get("code") == category[1] for c in categories
|
|
354
|
+
):
|
|
355
|
+
continue
|
|
356
|
+
hits.append(
|
|
357
|
+
LocalHit(
|
|
358
|
+
row[0],
|
|
359
|
+
row[1],
|
|
360
|
+
row[2],
|
|
361
|
+
row[3],
|
|
362
|
+
json.loads(row[4]),
|
|
363
|
+
categories,
|
|
364
|
+
json.loads(row[6]),
|
|
365
|
+
bool(row[7]),
|
|
366
|
+
sorted(row[8].split("\n")),
|
|
367
|
+
)
|
|
368
|
+
)
|
|
369
|
+
if len(hits) > limit:
|
|
370
|
+
break
|
|
371
|
+
return hits[:limit], len(hits) > limit
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
"""Local file operations: atomic writes, no-clobber stores, and recoverable replacement."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextlib
|
|
6
|
+
import hashlib
|
|
7
|
+
import os
|
|
8
|
+
import secrets
|
|
9
|
+
import shutil
|
|
10
|
+
from collections.abc import Callable, Iterator, Mapping
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from enum import StrEnum
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any, BinaryIO
|
|
15
|
+
|
|
16
|
+
TEMP_MARK = ".sci-tmp-"
|
|
17
|
+
"""Prefix of temporary and backup names; never a final output name."""
|
|
18
|
+
|
|
19
|
+
CHUNK = 1 << 20
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def temp_name(target: Path) -> Path:
|
|
23
|
+
return target.with_name(f"{TEMP_MARK}{secrets.token_hex(6)}-{target.name}")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _fsync_dir(directory: Path) -> None:
|
|
27
|
+
with contextlib.suppress(OSError):
|
|
28
|
+
fd = os.open(directory, os.O_RDONLY)
|
|
29
|
+
try:
|
|
30
|
+
os.fsync(fd)
|
|
31
|
+
finally:
|
|
32
|
+
os.close(fd)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def atomic_write_bytes(path: Path, data: bytes, *, mode: int = 0o644) -> None:
|
|
36
|
+
"""Write ``data`` to ``path`` through a temporary file and rename."""
|
|
37
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
38
|
+
temp = temp_name(path)
|
|
39
|
+
fd = os.open(temp, os.O_WRONLY | os.O_CREAT | os.O_EXCL, mode)
|
|
40
|
+
try:
|
|
41
|
+
with os.fdopen(fd, "wb") as handle:
|
|
42
|
+
handle.write(data)
|
|
43
|
+
handle.flush()
|
|
44
|
+
os.fsync(handle.fileno())
|
|
45
|
+
os.replace(temp, path)
|
|
46
|
+
except BaseException:
|
|
47
|
+
with contextlib.suppress(OSError):
|
|
48
|
+
os.unlink(temp)
|
|
49
|
+
raise
|
|
50
|
+
_fsync_dir(path.parent)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def sha256_file(path: Path) -> str:
|
|
54
|
+
digest = hashlib.sha256()
|
|
55
|
+
with open(path, "rb") as handle:
|
|
56
|
+
while chunk := handle.read(CHUNK):
|
|
57
|
+
digest.update(chunk)
|
|
58
|
+
return digest.hexdigest()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class StoreOutcome(StrEnum):
|
|
62
|
+
WRITTEN = "written"
|
|
63
|
+
UNCHANGED = "unchanged"
|
|
64
|
+
CONFLICT = "conflict"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass(frozen=True, slots=True)
|
|
68
|
+
class StoreResult:
|
|
69
|
+
outcome: StoreOutcome
|
|
70
|
+
path: Path
|
|
71
|
+
sha256: str
|
|
72
|
+
existing_sha256: str | None = None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class InputChanged(Exception):
|
|
76
|
+
"""Bytes written did not match the expected digest (input changed)."""
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def store_no_clobber(
|
|
80
|
+
target: Path, write: Callable[[Any], object], expected_sha256: str
|
|
81
|
+
) -> StoreResult:
|
|
82
|
+
"""Store bytes produced by ``write`` at ``target`` unless a file is already there.
|
|
83
|
+
|
|
84
|
+
An identical existing file is kept; a different one is a conflict and is
|
|
85
|
+
left untouched. The new file is written to a temporary name, hashed, and
|
|
86
|
+
linked into place without replacing anything (rename fallback if links
|
|
87
|
+
are unsupported). Raises :class:`InputChanged` if the produced bytes do
|
|
88
|
+
not hash to ``expected_sha256``.
|
|
89
|
+
"""
|
|
90
|
+
if target.exists():
|
|
91
|
+
existing = sha256_file(target)
|
|
92
|
+
outcome = StoreOutcome.UNCHANGED if existing == expected_sha256 else StoreOutcome.CONFLICT
|
|
93
|
+
return StoreResult(outcome, target, expected_sha256, existing)
|
|
94
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
95
|
+
temp = temp_name(target)
|
|
96
|
+
try:
|
|
97
|
+
hasher = _HashingWriter(open(temp, "xb")) # noqa: SIM115 - closed below
|
|
98
|
+
try:
|
|
99
|
+
write(hasher)
|
|
100
|
+
hasher.handle.flush()
|
|
101
|
+
os.fsync(hasher.handle.fileno())
|
|
102
|
+
finally:
|
|
103
|
+
hasher.handle.close()
|
|
104
|
+
if hasher.hexdigest() != expected_sha256:
|
|
105
|
+
raise InputChanged(f"{target.name}: bytes changed after verification")
|
|
106
|
+
try:
|
|
107
|
+
os.link(temp, target)
|
|
108
|
+
except FileExistsError:
|
|
109
|
+
existing = sha256_file(target)
|
|
110
|
+
outcome = (
|
|
111
|
+
StoreOutcome.UNCHANGED if existing == expected_sha256 else StoreOutcome.CONFLICT
|
|
112
|
+
)
|
|
113
|
+
return StoreResult(outcome, target, expected_sha256, existing)
|
|
114
|
+
except OSError:
|
|
115
|
+
if target.exists():
|
|
116
|
+
existing = sha256_file(target)
|
|
117
|
+
outcome = (
|
|
118
|
+
StoreOutcome.UNCHANGED if existing == expected_sha256 else StoreOutcome.CONFLICT
|
|
119
|
+
)
|
|
120
|
+
return StoreResult(outcome, target, expected_sha256, existing)
|
|
121
|
+
os.replace(temp, target)
|
|
122
|
+
finally:
|
|
123
|
+
with contextlib.suppress(OSError):
|
|
124
|
+
os.unlink(temp)
|
|
125
|
+
_fsync_dir(target.parent)
|
|
126
|
+
return StoreResult(StoreOutcome.WRITTEN, target, expected_sha256)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def store_bytes(target: Path, data: bytes) -> StoreResult:
|
|
130
|
+
return store_no_clobber(target, lambda h: h.write(data), hashlib.sha256(data).hexdigest())
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class _HashingWriter:
|
|
134
|
+
def __init__(self, handle: BinaryIO) -> None:
|
|
135
|
+
self.handle = handle
|
|
136
|
+
self._hash = hashlib.sha256()
|
|
137
|
+
|
|
138
|
+
def write(self, data: bytes) -> int:
|
|
139
|
+
self._hash.update(data)
|
|
140
|
+
return self.handle.write(data)
|
|
141
|
+
|
|
142
|
+
def hexdigest(self) -> str:
|
|
143
|
+
return self._hash.hexdigest()
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def copy_file_to(handle: BinaryIO, source: Path) -> None:
|
|
147
|
+
with open(source, "rb") as src:
|
|
148
|
+
shutil.copyfileobj(src, handle, CHUNK)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
# --------------------------------------------------------------------------- replacement
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def recover_replacements(directory: Path) -> list[str]:
|
|
155
|
+
"""Restore files left by an interrupted :func:`replace_together`.
|
|
156
|
+
|
|
157
|
+
Backups (``.sci-tmp-bak-<name>``) are restored when their final file is
|
|
158
|
+
absent, then all temporary files in ``directory`` are removed. Returns the
|
|
159
|
+
restored names.
|
|
160
|
+
"""
|
|
161
|
+
restored: list[str] = []
|
|
162
|
+
try:
|
|
163
|
+
names = os.listdir(directory)
|
|
164
|
+
except OSError:
|
|
165
|
+
return restored
|
|
166
|
+
for name in names:
|
|
167
|
+
if name.startswith(TEMP_MARK + "bak-"):
|
|
168
|
+
final = directory / name[len(TEMP_MARK + "bak-") :]
|
|
169
|
+
if not final.exists():
|
|
170
|
+
os.replace(directory / name, final)
|
|
171
|
+
restored.append(final.name)
|
|
172
|
+
for name in os.listdir(directory):
|
|
173
|
+
if name.startswith(TEMP_MARK):
|
|
174
|
+
with contextlib.suppress(OSError):
|
|
175
|
+
os.unlink(directory / name)
|
|
176
|
+
return restored
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def temp_names(directory: Path) -> set[str]:
|
|
180
|
+
try:
|
|
181
|
+
return {n for n in os.listdir(directory) if n.startswith(TEMP_MARK)}
|
|
182
|
+
except OSError:
|
|
183
|
+
return set()
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
@contextlib.contextmanager
|
|
187
|
+
def replace_together(directory: Path, files: Mapping[str, bytes | None]) -> Iterator[None]:
|
|
188
|
+
"""Replace several files in ``directory`` as one recoverable operation.
|
|
189
|
+
|
|
190
|
+
``files`` maps names to new bytes (or None to remove). New bytes are
|
|
191
|
+
written to temporary names; existing files are renamed to backups; the
|
|
192
|
+
new files are renamed into place. The body of the ``with`` block then
|
|
193
|
+
checks the result: if it raises, the backups are restored. Otherwise the
|
|
194
|
+
backups are deleted. An interruption leaves backups that
|
|
195
|
+
:func:`recover_replacements` restores.
|
|
196
|
+
"""
|
|
197
|
+
temps: dict[str, Path] = {}
|
|
198
|
+
backups: dict[str, Path] = {}
|
|
199
|
+
placed: list[str] = []
|
|
200
|
+
try:
|
|
201
|
+
for name, data in files.items():
|
|
202
|
+
if data is None:
|
|
203
|
+
continue
|
|
204
|
+
temp = directory / f"{TEMP_MARK}new-{name}"
|
|
205
|
+
with open(temp, "wb") as handle:
|
|
206
|
+
handle.write(data)
|
|
207
|
+
handle.flush()
|
|
208
|
+
os.fsync(handle.fileno())
|
|
209
|
+
temps[name] = temp
|
|
210
|
+
for name in files:
|
|
211
|
+
final = directory / name
|
|
212
|
+
if final.exists():
|
|
213
|
+
backup = directory / f"{TEMP_MARK}bak-{name}"
|
|
214
|
+
os.replace(final, backup)
|
|
215
|
+
backups[name] = backup
|
|
216
|
+
for name, temp in temps.items():
|
|
217
|
+
os.replace(temp, directory / name)
|
|
218
|
+
placed.append(name)
|
|
219
|
+
_fsync_dir(directory)
|
|
220
|
+
yield
|
|
221
|
+
except BaseException:
|
|
222
|
+
for name in placed:
|
|
223
|
+
with contextlib.suppress(OSError):
|
|
224
|
+
os.unlink(directory / name)
|
|
225
|
+
for name, backup in backups.items():
|
|
226
|
+
with contextlib.suppress(OSError):
|
|
227
|
+
os.replace(backup, directory / name)
|
|
228
|
+
for temp in temps.values():
|
|
229
|
+
with contextlib.suppress(OSError):
|
|
230
|
+
os.unlink(temp)
|
|
231
|
+
raise
|
|
232
|
+
for backup in backups.values():
|
|
233
|
+
with contextlib.suppress(OSError):
|
|
234
|
+
os.unlink(backup)
|
|
235
|
+
_fsync_dir(directory)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def is_within(path: Path, directory: Path) -> bool:
|
|
239
|
+
"""True if ``path`` is ``directory`` or inside it (after resolving)."""
|
|
240
|
+
try:
|
|
241
|
+
resolved = path.resolve()
|
|
242
|
+
base = directory.resolve()
|
|
243
|
+
except OSError:
|
|
244
|
+
return False
|
|
245
|
+
return resolved == base or base in resolved.parents
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def directory_is_empty(path: Path) -> bool:
|
|
249
|
+
try:
|
|
250
|
+
return not any(path.iterdir())
|
|
251
|
+
except FileNotFoundError:
|
|
252
|
+
return True
|