model-mirror-cli 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.
@@ -0,0 +1,582 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+
8
+ from .hub import read_snapshot_plan
9
+ from .state import read_verification_state, utc_now
10
+ from .torrent import (
11
+ PUBLICATION_PROFILE,
12
+ HybridMetainfo,
13
+ TorrentPublicationError,
14
+ build_publication_descriptor,
15
+ create_hybrid_metainfo_from_coverage,
16
+ )
17
+ from .torrent_coverage import load_coverage, upgrade_coverage
18
+
19
+
20
+ PUBLICATION_SCHEMA = "model-mirror-torrent-publication"
21
+ PUBLICATION_VERSION = 1
22
+ RECOVERY_SCHEMA = "model-mirror-torrent-recovery"
23
+ RECOVERY_VERSION = 1
24
+ FENCE_SCHEMA = "model-mirror-publication-fence"
25
+ FENCE_VERSION = 1
26
+ TORRENT_ROOT = Path(".model-mirror") / "torrent"
27
+ PUBLICATIONS_DIR = TORRENT_ROOT / "publications"
28
+ FENCE_FILE = TORRENT_ROOT / "fence.json"
29
+
30
+
31
+ @dataclass(slots=True)
32
+ class PublicationRecord:
33
+ repo_id: str
34
+ repo_type: str
35
+ resolved_commit: str
36
+ profile: str
37
+ descriptor_sha256: str
38
+ metainfo_sha256: str
39
+ infohash_v1: str
40
+ infohash_v2: str
41
+ magnet_uri: str
42
+ torrent_path: str
43
+ recovery_path: str
44
+ payload_fingerprints: dict[str, dict[str, int]]
45
+ lifecycle: str = "published"
46
+ desired_seed: bool = False
47
+ client_mode: str = "managed"
48
+ observed_backend: str = "stopped"
49
+ observed_detail: str = ""
50
+ maintenance_resume_seed: bool = False
51
+ content_verification: str = "upstream-verified"
52
+ publication_trust: str = "local-verified-publication"
53
+ upstream_provenance: str = "upstream-verified"
54
+ upstream_availability: str = "available"
55
+ created_at_utc: str = ""
56
+ updated_at_utc: str = ""
57
+
58
+ @property
59
+ def publication_id(self) -> str:
60
+ return f"huggingface:{self.repo_type}:{self.repo_id}@{self.resolved_commit}"
61
+
62
+ @property
63
+ def active(self) -> bool:
64
+ return self.lifecycle != "retired"
65
+
66
+ def to_dict(self) -> dict:
67
+ return {
68
+ "schema": PUBLICATION_SCHEMA,
69
+ "version": PUBLICATION_VERSION,
70
+ "publication_id": self.publication_id,
71
+ "repo_id": self.repo_id,
72
+ "repo_type": self.repo_type,
73
+ "resolved_commit": self.resolved_commit,
74
+ "profile": self.profile,
75
+ "descriptor_sha256": self.descriptor_sha256,
76
+ "metainfo_sha256": self.metainfo_sha256,
77
+ "infohash_v1": self.infohash_v1,
78
+ "infohash_v2": self.infohash_v2,
79
+ "magnet_uri": self.magnet_uri,
80
+ "torrent_path": self.torrent_path,
81
+ "recovery_path": self.recovery_path,
82
+ "payload_fingerprints": self.payload_fingerprints,
83
+ "lifecycle": self.lifecycle,
84
+ "desired_seed": self.desired_seed,
85
+ "client_mode": self.client_mode,
86
+ "observed_backend": self.observed_backend,
87
+ "observed_detail": self.observed_detail,
88
+ "maintenance_resume_seed": self.maintenance_resume_seed,
89
+ "content_verification": self.content_verification,
90
+ "publication_trust": self.publication_trust,
91
+ "upstream_provenance": self.upstream_provenance,
92
+ "upstream_availability": self.upstream_availability,
93
+ "created_at_utc": self.created_at_utc,
94
+ "updated_at_utc": self.updated_at_utc,
95
+ }
96
+
97
+ @classmethod
98
+ def from_dict(cls, value: object, *, source: Path) -> PublicationRecord:
99
+ if not isinstance(value, dict):
100
+ raise ValueError(f"Torrent publication record must contain an object: {source}")
101
+ if value.get("schema") != PUBLICATION_SCHEMA or value.get("version") != PUBLICATION_VERSION:
102
+ raise ValueError(
103
+ f"Unsupported torrent publication format in {source}: "
104
+ f"schema={value.get('schema')!r} version={value.get('version')!r}"
105
+ )
106
+ try:
107
+ fingerprints = value["payload_fingerprints"]
108
+ if not isinstance(fingerprints, dict):
109
+ raise TypeError
110
+ record = cls(
111
+ repo_id=str(value["repo_id"]),
112
+ repo_type=str(value["repo_type"]),
113
+ resolved_commit=str(value["resolved_commit"]),
114
+ profile=str(value["profile"]),
115
+ descriptor_sha256=require_digest(value["descriptor_sha256"], 64, source),
116
+ metainfo_sha256=require_digest(value["metainfo_sha256"], 64, source),
117
+ infohash_v1=require_digest(value["infohash_v1"], 40, source),
118
+ infohash_v2=require_digest(value["infohash_v2"], 64, source),
119
+ magnet_uri=str(value["magnet_uri"]),
120
+ torrent_path=str(value["torrent_path"]),
121
+ recovery_path=str(value["recovery_path"]),
122
+ payload_fingerprints={
123
+ str(path): {
124
+ "size": int(fingerprint["size"]),
125
+ "mtime_ns": int(fingerprint["mtime_ns"]),
126
+ }
127
+ for path, fingerprint in fingerprints.items()
128
+ },
129
+ lifecycle=str(value.get("lifecycle", "published")),
130
+ desired_seed=bool(value.get("desired_seed", False)),
131
+ client_mode=str(value.get("client_mode", "managed")),
132
+ observed_backend=str(value.get("observed_backend", "unknown")),
133
+ observed_detail=str(value.get("observed_detail", "")),
134
+ maintenance_resume_seed=bool(value.get("maintenance_resume_seed", False)),
135
+ content_verification=str(value.get("content_verification", "upstream-verified")),
136
+ publication_trust=str(
137
+ value.get("publication_trust", "local-verified-publication")
138
+ ),
139
+ upstream_provenance=str(value.get("upstream_provenance", "upstream-verified")),
140
+ upstream_availability=str(value.get("upstream_availability", "available")),
141
+ created_at_utc=str(value.get("created_at_utc", "")),
142
+ updated_at_utc=str(value.get("updated_at_utc", "")),
143
+ )
144
+ except (KeyError, TypeError, ValueError) as exc:
145
+ raise ValueError(f"Malformed torrent publication record: {source}") from exc
146
+ if value.get("publication_id") not in {None, record.publication_id}:
147
+ raise ValueError(f"Torrent publication identity mismatch: {source}")
148
+ if record.lifecycle not in {"published", "maintenance", "unhealthy", "retired"}:
149
+ raise ValueError(f"Malformed torrent publication lifecycle in {source}")
150
+ if record.client_mode not in {"managed", "external"}:
151
+ raise ValueError(f"Malformed torrent publication client mode in {source}")
152
+ return record
153
+
154
+
155
+ @dataclass(frozen=True, slots=True)
156
+ class PublicationResult:
157
+ record: PublicationRecord
158
+ record_path: Path
159
+ torrent_path: Path
160
+ recovery_path: Path
161
+ created: bool
162
+ coverage_hashed_files: int
163
+ coverage_hashed_bytes: int
164
+
165
+
166
+ def publication_dir(root: Path, commit: str, profile: str = PUBLICATION_PROFILE) -> Path:
167
+ return root / PUBLICATIONS_DIR / f"{profile}--{commit}"
168
+
169
+
170
+ def publication_record_path(root: Path, commit: str, profile: str = PUBLICATION_PROFILE) -> Path:
171
+ return publication_dir(root, commit, profile) / "publication.json"
172
+
173
+
174
+ def fence_path(root: Path) -> Path:
175
+ return root / FENCE_FILE
176
+
177
+
178
+ def load_publication(path: Path) -> PublicationRecord | None:
179
+ if not path.exists():
180
+ return None
181
+ try:
182
+ value = json.loads(path.read_text(encoding="utf-8"))
183
+ except Exception as exc:
184
+ raise ValueError(f"Malformed torrent publication record: {path}") from exc
185
+ return PublicationRecord.from_dict(value, source=path)
186
+
187
+
188
+ def load_fenced_publication(root: Path) -> tuple[PublicationRecord, Path] | None:
189
+ path = fence_path(root)
190
+ if not path.exists():
191
+ return None
192
+ try:
193
+ value = json.loads(path.read_text(encoding="utf-8"))
194
+ except Exception as exc:
195
+ raise ValueError(f"Malformed publication fence: {path}") from exc
196
+ if (
197
+ not isinstance(value, dict)
198
+ or value.get("schema") != FENCE_SCHEMA
199
+ or value.get("version") != FENCE_VERSION
200
+ ):
201
+ raise ValueError(f"Unsupported publication fence format: {path}")
202
+ relative = Path(str(value.get("publication_record", "")))
203
+ if not relative.parts or relative.is_absolute() or ".." in relative.parts:
204
+ raise ValueError(f"Unsafe publication record path in fence: {path}")
205
+ record_path = root / relative
206
+ record = load_publication(record_path)
207
+ if record is None or not record.active:
208
+ raise ValueError(f"Publication fence points to a missing or retired record: {path}")
209
+ if value.get("publication_id") != record.publication_id:
210
+ raise ValueError(f"Publication fence identity mismatch: {path}")
211
+ return record, record_path
212
+
213
+
214
+ def create_publication(
215
+ root: Path,
216
+ *,
217
+ repo_id: str,
218
+ repo_type: str,
219
+ desired_seed: bool | None = None,
220
+ client_mode: str | None = None,
221
+ ) -> PublicationResult:
222
+ if client_mode not in {None, "managed", "external"}:
223
+ raise ValueError(f"unsupported torrent client mode: {client_mode}")
224
+ imported = reusable_imported_publication(
225
+ root,
226
+ repo_id=repo_id,
227
+ repo_type=repo_type,
228
+ desired_seed=desired_seed,
229
+ client_mode=client_mode,
230
+ )
231
+ if imported is not None:
232
+ return imported
233
+ descriptor = build_publication_descriptor(root, repo_id=repo_id, repo_type=repo_type)
234
+ existing_fence = load_fenced_publication(root)
235
+ if existing_fence is not None and existing_fence[0].resolved_commit != descriptor.resolved_commit:
236
+ raise TorrentPublicationError(
237
+ f"archive is fenced by publication {existing_fence[0].publication_id}; "
238
+ "retire it before publishing another commit"
239
+ )
240
+ snapshot = read_snapshot_plan(root)
241
+ if snapshot is None: # pragma: no cover - descriptor already enforces this
242
+ raise TorrentPublicationError("pinned snapshot is missing")
243
+ coverage_result = upgrade_coverage(root, snapshot)
244
+ coverage = load_coverage(coverage_result.path)
245
+ if coverage is None or not coverage_result.complete: # pragma: no cover - defensive
246
+ raise TorrentPublicationError("torrent hash coverage is incomplete after upgrade")
247
+ artifact = create_hybrid_metainfo_from_coverage(root, descriptor, coverage)
248
+
249
+ target_dir = publication_dir(root, descriptor.resolved_commit, descriptor.profile)
250
+ torrent_path = target_dir / f"{root.name}@{descriptor.resolved_commit}.torrent"
251
+ recovery_path = target_dir / "recovery.json"
252
+ record_path = target_dir / "publication.json"
253
+ existing = load_publication(record_path)
254
+ if existing is not None and (
255
+ existing.metainfo_sha256 != artifact.metainfo_sha256
256
+ or existing.descriptor_sha256 != artifact.descriptor_sha256
257
+ or existing.infohash_v1 != artifact.infohash_v1
258
+ or existing.infohash_v2 != artifact.infohash_v2
259
+ ):
260
+ raise TorrentPublicationError(
261
+ f"existing publication conflicts with deterministic output: {record_path}"
262
+ )
263
+ if torrent_path.exists() and hashlib.sha256(torrent_path.read_bytes()).hexdigest() != artifact.metainfo_sha256:
264
+ raise TorrentPublicationError(f"existing torrent artifact is corrupt or conflicting: {torrent_path}")
265
+
266
+ now = utc_now()
267
+ effective_mode = client_mode or (existing.client_mode if existing is not None else "managed")
268
+ effective_desired = (
269
+ desired_seed
270
+ if desired_seed is not None
271
+ else (existing.desired_seed if existing is not None else False)
272
+ )
273
+ record = PublicationRecord(
274
+ repo_id=descriptor.repo_id,
275
+ repo_type=descriptor.repo_type,
276
+ resolved_commit=descriptor.resolved_commit,
277
+ profile=descriptor.profile,
278
+ descriptor_sha256=artifact.descriptor_sha256,
279
+ metainfo_sha256=artifact.metainfo_sha256,
280
+ infohash_v1=artifact.infohash_v1,
281
+ infohash_v2=artifact.infohash_v2,
282
+ magnet_uri=artifact.magnet_uri,
283
+ torrent_path=torrent_path.relative_to(root).as_posix(),
284
+ recovery_path=recovery_path.relative_to(root).as_posix(),
285
+ payload_fingerprints={
286
+ item.path: {
287
+ "size": coverage.files[item.path].size,
288
+ "mtime_ns": coverage.files[item.path].mtime_ns,
289
+ }
290
+ for item in descriptor.files
291
+ },
292
+ lifecycle="published",
293
+ desired_seed=effective_desired,
294
+ client_mode=effective_mode,
295
+ observed_backend=(
296
+ existing.observed_backend
297
+ if existing is not None and existing.client_mode == effective_mode
298
+ else ("external" if effective_mode == "external" else "pending")
299
+ ),
300
+ observed_detail=existing.observed_detail if existing is not None else "",
301
+ maintenance_resume_seed=False,
302
+ created_at_utc=existing.created_at_utc if existing is not None else now,
303
+ updated_at_utc=now,
304
+ )
305
+ write_bytes_atomic(torrent_path, artifact.metainfo)
306
+ write_json_atomic(recovery_path, recovery_value(record))
307
+ write_json_atomic(record_path, record.to_dict())
308
+ write_json_atomic(
309
+ fence_path(root),
310
+ {
311
+ "schema": FENCE_SCHEMA,
312
+ "version": FENCE_VERSION,
313
+ "publication_id": record.publication_id,
314
+ "publication_record": record_path.relative_to(root).as_posix(),
315
+ },
316
+ )
317
+ return PublicationResult(
318
+ record=record,
319
+ record_path=record_path,
320
+ torrent_path=torrent_path,
321
+ recovery_path=recovery_path,
322
+ created=existing is None or existing.lifecycle == "retired",
323
+ coverage_hashed_files=coverage_result.hashed_files,
324
+ coverage_hashed_bytes=coverage_result.hashed_bytes,
325
+ )
326
+
327
+
328
+ def reusable_imported_publication(
329
+ root: Path,
330
+ *,
331
+ repo_id: str,
332
+ repo_type: str,
333
+ desired_seed: bool | None,
334
+ client_mode: str | None,
335
+ ) -> PublicationResult | None:
336
+ state = read_verification_state(root)
337
+ if (
338
+ state is None
339
+ or not state.resolved_commit
340
+ or state.repo_id != repo_id
341
+ or state.repo_type != repo_type
342
+ ):
343
+ return None
344
+ path = publication_record_path(root, state.resolved_commit)
345
+ record = load_publication(path)
346
+ if record is None or record.content_verification != "torrent-verified":
347
+ return None
348
+ matches, detail = payload_fingerprints_match(root, record)
349
+ if not matches:
350
+ raise TorrentPublicationError(f"cannot reuse imported publication: {detail}")
351
+ torrent_path = root / record.torrent_path
352
+ try:
353
+ metainfo = torrent_path.read_bytes()
354
+ except OSError as exc:
355
+ raise TorrentPublicationError(f"imported torrent artifact is unavailable: {torrent_path}") from exc
356
+ if hashlib.sha256(metainfo).hexdigest() != record.metainfo_sha256:
357
+ raise TorrentPublicationError(f"imported torrent artifact digest mismatch: {torrent_path}")
358
+
359
+ was_retired = record.lifecycle == "retired"
360
+ record.lifecycle = "published"
361
+ if client_mode is not None:
362
+ record.client_mode = client_mode
363
+ if desired_seed is not None:
364
+ record.desired_seed = desired_seed
365
+ record.observed_backend = (
366
+ "external"
367
+ if record.client_mode == "external"
368
+ else "pending" if record.desired_seed else "stopped"
369
+ )
370
+ record.observed_detail = ""
371
+ record.updated_at_utc = utc_now()
372
+ recovery_path = root / record.recovery_path
373
+ write_json_atomic(recovery_path, recovery_value(record))
374
+ write_json_atomic(path, record.to_dict())
375
+ write_json_atomic(
376
+ fence_path(root),
377
+ {
378
+ "schema": FENCE_SCHEMA,
379
+ "version": FENCE_VERSION,
380
+ "publication_id": record.publication_id,
381
+ "publication_record": path.relative_to(root).as_posix(),
382
+ },
383
+ )
384
+ return PublicationResult(
385
+ record=record,
386
+ record_path=path,
387
+ torrent_path=torrent_path,
388
+ recovery_path=recovery_path,
389
+ created=was_retired,
390
+ coverage_hashed_files=0,
391
+ coverage_hashed_bytes=0,
392
+ )
393
+
394
+
395
+ def set_seed_desired(
396
+ root: Path,
397
+ *,
398
+ desired: bool,
399
+ client_mode: str | None = None,
400
+ ) -> PublicationRecord:
401
+ fenced = load_fenced_publication(root)
402
+ if fenced is None:
403
+ raise TorrentPublicationError("archive has no active torrent publication")
404
+ record, path = fenced
405
+ if client_mode is not None:
406
+ if client_mode not in {"managed", "external"}:
407
+ raise ValueError(f"unsupported torrent client mode: {client_mode}")
408
+ record.client_mode = client_mode
409
+ record.desired_seed = desired
410
+ if record.client_mode == "external":
411
+ record.observed_backend = "external" if desired else "stopped"
412
+ elif desired:
413
+ record.observed_backend = "pending"
414
+ else:
415
+ record.observed_backend = (
416
+ "stopping"
417
+ if record.observed_backend in {"seeding", "stopping"}
418
+ else "stopped"
419
+ )
420
+ record.observed_detail = ""
421
+ record.maintenance_resume_seed = False
422
+ record.updated_at_utc = utc_now()
423
+ write_json_atomic(path, record.to_dict())
424
+ return record
425
+
426
+
427
+ def retire_publication(root: Path) -> PublicationRecord:
428
+ fenced = load_fenced_publication(root)
429
+ if fenced is None:
430
+ raise TorrentPublicationError("archive has no active torrent publication")
431
+ record, path = fenced
432
+ if record.observed_backend in {"seeding", "stopping"}:
433
+ raise TorrentPublicationError(
434
+ f"cannot retire while backend state is {record.observed_backend}; "
435
+ "stop the seed and wait for backend detachment"
436
+ )
437
+ record.lifecycle = "retired"
438
+ record.desired_seed = False
439
+ record.observed_backend = "stopped"
440
+ record.observed_detail = ""
441
+ record.maintenance_resume_seed = False
442
+ record.updated_at_utc = utc_now()
443
+ write_json_atomic(path, record.to_dict())
444
+ fence_path(root).unlink()
445
+ return record
446
+
447
+
448
+ def update_observed_backend(
449
+ root: Path,
450
+ *,
451
+ state: str,
452
+ detail: str = "",
453
+ lifecycle: str | None = None,
454
+ ) -> PublicationRecord:
455
+ fenced = load_fenced_publication(root)
456
+ if fenced is None:
457
+ raise TorrentPublicationError("archive has no active torrent publication")
458
+ record, path = fenced
459
+ record.observed_backend = state
460
+ record.observed_detail = detail
461
+ if lifecycle is not None:
462
+ record.lifecycle = lifecycle
463
+ record.updated_at_utc = utc_now()
464
+ write_json_atomic(path, record.to_dict())
465
+ return record
466
+
467
+
468
+ def begin_maintenance(root: Path) -> PublicationRecord | None:
469
+ fenced = load_fenced_publication(root)
470
+ if fenced is None:
471
+ return None
472
+ record, path = fenced
473
+ if record.client_mode == "external" and record.observed_backend != "stopped":
474
+ raise TorrentPublicationError(
475
+ "published payload is assigned to an external torrent client; "
476
+ "stop it, then run model-mirror torrent stop before repair"
477
+ )
478
+ if record.lifecycle != "maintenance":
479
+ record.maintenance_resume_seed = record.desired_seed
480
+ record.desired_seed = False
481
+ record.lifecycle = "maintenance"
482
+ if record.observed_backend == "seeding":
483
+ record.observed_backend = "stopping"
484
+ record.observed_detail = ""
485
+ record.updated_at_utc = utc_now()
486
+ write_json_atomic(path, record.to_dict())
487
+ return record
488
+
489
+
490
+ def wait_for_maintenance_detach(root: Path, *, timeout_seconds: float = 10.0) -> None:
491
+ import time
492
+
493
+ deadline = time.monotonic() + timeout_seconds
494
+ while True:
495
+ fenced = load_fenced_publication(root)
496
+ if fenced is None:
497
+ return
498
+ record = fenced[0]
499
+ if record.observed_backend not in {"seeding", "stopping"}:
500
+ return
501
+ if time.monotonic() >= deadline:
502
+ raise TorrentPublicationError(
503
+ f"managed seeder did not detach {record.publication_id}; "
504
+ "ensure model-mirror torrent serve is running or stop the backend before repair"
505
+ )
506
+ time.sleep(0.1)
507
+
508
+
509
+ def finish_maintenance(root: Path, *, healthy: bool) -> PublicationRecord | None:
510
+ fenced = load_fenced_publication(root)
511
+ if fenced is None:
512
+ return None
513
+ record, path = fenced
514
+ resume = record.maintenance_resume_seed
515
+ record.maintenance_resume_seed = False
516
+ record.lifecycle = "published" if healthy else "unhealthy"
517
+ record.desired_seed = resume if healthy else False
518
+ record.observed_backend = "pending" if record.desired_seed else "stopped"
519
+ if not healthy:
520
+ record.observed_detail = "same-commit maintenance did not finish cleanly"
521
+ else:
522
+ record.observed_detail = ""
523
+ record.updated_at_utc = utc_now()
524
+ write_json_atomic(path, record.to_dict())
525
+ return record
526
+
527
+
528
+ def assert_commit_update_allowed(root: Path, target_commit: str) -> None:
529
+ fenced = load_fenced_publication(root)
530
+ if fenced is None or fenced[0].resolved_commit == target_commit:
531
+ return
532
+ record = fenced[0]
533
+ raise TorrentPublicationError(
534
+ f"update blocked by active publication {record.publication_id}; "
535
+ f"run model-mirror torrent retire {record.repo_id} before updating"
536
+ )
537
+
538
+
539
+ def payload_fingerprints_match(root: Path, record: PublicationRecord) -> tuple[bool, str]:
540
+ for rel, expected in record.payload_fingerprints.items():
541
+ path = root / rel
542
+ if not path.exists() or not path.is_file() or path.is_symlink():
543
+ return False, f"missing or unsafe payload file: {rel}"
544
+ stat = path.stat()
545
+ if stat.st_size != expected["size"] or stat.st_mtime_ns != expected["mtime_ns"]:
546
+ return False, f"payload fingerprint changed: {rel}"
547
+ return True, ""
548
+
549
+
550
+ def recovery_value(record: PublicationRecord) -> dict:
551
+ return {
552
+ "schema": RECOVERY_SCHEMA,
553
+ "version": RECOVERY_VERSION,
554
+ "publication_id": record.publication_id,
555
+ "profile": record.profile,
556
+ "descriptor_sha256": record.descriptor_sha256,
557
+ "metainfo_sha256": record.metainfo_sha256,
558
+ "infohash_v1": record.infohash_v1,
559
+ "infohash_v2": record.infohash_v2,
560
+ "magnet_uri": record.magnet_uri,
561
+ }
562
+
563
+
564
+ def write_json_atomic(path: Path, value: dict) -> None:
565
+ write_bytes_atomic(
566
+ path,
567
+ (json.dumps(value, indent=2, sort_keys=True) + "\n").encode("utf-8"),
568
+ )
569
+
570
+
571
+ def write_bytes_atomic(path: Path, value: bytes) -> None:
572
+ path.parent.mkdir(parents=True, exist_ok=True)
573
+ temporary = path.with_name(f".{path.name}.tmp")
574
+ temporary.write_bytes(value)
575
+ temporary.replace(path)
576
+
577
+
578
+ def require_digest(value: object, length: int, source: Path) -> str:
579
+ text = str(value).lower()
580
+ if len(text) != length or any(character not in "0123456789abcdef" for character in text):
581
+ raise ValueError(f"Malformed digest in torrent publication record: {source}")
582
+ return text