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.
Files changed (80) hide show
  1. pysci/__init__.py +77 -0
  2. pysci/__main__.py +8 -0
  3. pysci/_cli/__init__.py +5 -0
  4. pysci/_cli/checks.py +173 -0
  5. pysci/_cli/clients/__init__.py +0 -0
  6. pysci/_cli/clients/archive.py +456 -0
  7. pysci/_cli/clients/credentials.py +54 -0
  8. pysci/_cli/clients/github.py +734 -0
  9. pysci/_cli/clients/hosting.py +238 -0
  10. pysci/_cli/clients/http.py +481 -0
  11. pysci/_cli/clients/huggingface.py +492 -0
  12. pysci/_cli/clients/index.py +435 -0
  13. pysci/_cli/clients/tsa.py +223 -0
  14. pysci/_cli/commands/__init__.py +44 -0
  15. pysci/_cli/commands/check.py +115 -0
  16. pysci/_cli/commands/claim.py +179 -0
  17. pysci/_cli/commands/common.py +91 -0
  18. pysci/_cli/commands/folders.py +83 -0
  19. pysci/_cli/commands/index.py +51 -0
  20. pysci/_cli/commands/keygen.py +58 -0
  21. pysci/_cli/commands/license.py +75 -0
  22. pysci/_cli/commands/protocol.py +72 -0
  23. pysci/_cli/commands/publish.py +282 -0
  24. pysci/_cli/commands/pull.py +337 -0
  25. pysci/_cli/commands/read.py +364 -0
  26. pysci/_cli/commands/revoke.py +207 -0
  27. pysci/_cli/commands/search.py +400 -0
  28. pysci/_cli/commands/sign.py +284 -0
  29. pysci/_cli/commands/submit.py +172 -0
  30. pysci/_cli/commands/timestamp.py +196 -0
  31. pysci/_cli/commands/verify.py +163 -0
  32. pysci/_cli/config.py +233 -0
  33. pysci/_cli/context.py +79 -0
  34. pysci/_cli/destinations.py +163 -0
  35. pysci/_cli/errors.py +168 -0
  36. pysci/_cli/main.py +229 -0
  37. pysci/_cli/output.py +80 -0
  38. pysci/_cli/retrieval.py +51 -0
  39. pysci/_cli/spec.py +113 -0
  40. pysci/_cli/storage/__init__.py +0 -0
  41. pysci/_cli/storage/catalog.py +371 -0
  42. pysci/_cli/storage/files.py +252 -0
  43. pysci/_cli/storage/inputs.py +205 -0
  44. pysci/_cli/storage/keys.py +132 -0
  45. pysci/_cli/storage/local_publish.py +88 -0
  46. pysci/accounts.py +135 -0
  47. pysci/baseline.py +142 -0
  48. pysci/bundle.py +878 -0
  49. pysci/citations.py +233 -0
  50. pysci/data/freetsa-cacert.pem +45 -0
  51. pysci/data/sci-baseline-LICENSE.txt +457 -0
  52. pysci/data/sci-protocol.md +613 -0
  53. pysci/der.py +146 -0
  54. pysci/discovery.py +461 -0
  55. pysci/draft.py +374 -0
  56. pysci/ed25519.py +153 -0
  57. pysci/errors.py +204 -0
  58. pysci/ids.py +61 -0
  59. pysci/jsonstrict.py +225 -0
  60. pysci/keys.py +247 -0
  61. pysci/manifest.py +302 -0
  62. pysci/metadata.py +406 -0
  63. pysci/paths.py +136 -0
  64. pysci/png.py +217 -0
  65. pysci/py.typed +0 -0
  66. pysci/records.py +384 -0
  67. pysci/rfc3339.py +66 -0
  68. pysci/schemas.py +75 -0
  69. pysci/source.py +166 -0
  70. pysci/specification.py +58 -0
  71. pysci/submission.py +225 -0
  72. pysci/templates.py +129 -0
  73. pysci/text.py +226 -0
  74. pysci/timestamps.py +867 -0
  75. pysci/zipformat.py +400 -0
  76. sci_protocol-0.2.0.dist-info/METADATA +112 -0
  77. sci_protocol-0.2.0.dist-info/RECORD +80 -0
  78. sci_protocol-0.2.0.dist-info/WHEEL +4 -0
  79. sci_protocol-0.2.0.dist-info/entry_points.txt +2 -0
  80. 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