darsay 0.6.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.
darsay/__init__.py ADDED
@@ -0,0 +1,23 @@
1
+ """darsay: the genesis machine of archives."""
2
+
3
+ __version__ = "0.6.0"
4
+
5
+ # Version of the manifest.json schema, independent of the tool version.
6
+ # Bump the major component on breaking layout changes; consumers should
7
+ # check this before parsing.
8
+ # 1.1.0: defined the shape of runtime.tested_hardware entries (written by
9
+ # `darsay run`); previously always null.
10
+ # 1.2.0: dataset artifact type (payload under data/, dataset_metadata section,
11
+ # smoke_tests.structure, dataset relationships) and the model-side
12
+ # relationships.training_datasets field. Additive.
13
+ # 1.3.0: source.access (Hub gate status at archive time); structured lineage —
14
+ # relationships.base_models (all parents) and base_model_relation (the
15
+ # model-tree edge label), with finetuned_from now set only when the
16
+ # declared relation is `finetune`; licensing needs_manual_review/notes
17
+ # are gate-aware. Additive.
18
+ # 1.4.0: source.transfer session/accounting summary; source.mirrors_used is
19
+ # populated for verified sibling-bundle copies; archive-time checksum
20
+ # verification records its per-file timing. Additive.
21
+ # 1.5.0: source.provider and source.address (canonical provider-qualified
22
+ # ref). origin remains the provider id. Additive.
23
+ SCHEMA_VERSION = "1.5.0"
darsay/archiver.py ADDED
@@ -0,0 +1,493 @@
1
+ """Archive a source into a reproducible, auditable bundle.
2
+
3
+ Bundle layout:
4
+
5
+ vault/<bundle-name>/<revision12>/
6
+ model/ immutable payload: exact snapshot of the upstream repo
7
+ (data/ for dataset bundles — the registry's payload_root)
8
+ manifest.json machine-readable record (schema.py / SCHEMA_VERSION)
9
+ README.md human-readable summary, regenerable from the manifest
10
+ VERIFICATION.md latest verification report
11
+ verification.json verification history
12
+ curation.md curator notes; the only file meant to be hand-edited
13
+
14
+ The payload is treated as immutable after archiving; the bundle hash covers it
15
+ alone. Metadata at the bundle root is mutable by design.
16
+
17
+ Acquisition is provider-dispatched (``sources.parse_source``). Hugging Face is
18
+ the first provider, not the archive format.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import json
24
+ import platform
25
+ import re
26
+ import shutil
27
+ import socket
28
+ from datetime import datetime, timezone
29
+ from pathlib import Path
30
+
31
+ from . import SCHEMA_VERSION, __version__
32
+ from .hashing import bundle_hash
33
+ from .licensing import build_licensing_record
34
+ from .metadata import estimate_runtime, extract_dataset_metadata, extract_model_metadata
35
+ from .schema import ARTIFACT_TYPES, BUNDLE_METADATA_FILES, check_completeness, payload_root_for
36
+ from .sources import (
37
+ SourceGatedError,
38
+ SourceNotFoundError,
39
+ SourceRef,
40
+ get_provider,
41
+ parse_source,
42
+ source_from_ledger,
43
+ )
44
+
45
+
46
+ def utc_now() -> str:
47
+ return datetime.now(timezone.utc).isoformat(timespec="seconds")
48
+
49
+
50
+ def parse_repo_ref(ref: str) -> tuple[str, str]:
51
+ """Return ``(artifact_type, locator)``. Prefer ``parse_source`` for new code."""
52
+ source = parse_source(ref)
53
+ return source.artifact_type, source.locator
54
+
55
+
56
+ def hub_url(repo_id: str, repo_type: str = "model") -> str:
57
+ """Hugging Face URL for a locator. Prefer ``parse_source(...).url``."""
58
+ path = f"datasets/{repo_id}" if repo_type == "dataset" else repo_id
59
+ return f"https://huggingface.co/{path}"
60
+
61
+
62
+ def bundle_name_for(repo_id: str, repo_type: str = "model") -> str:
63
+ """Vault directory name for a Hugging Face-shaped locator.
64
+
65
+ Prefer ``parse_source(...).bundle_name``. Hugging Face bundle names are
66
+ unchanged (``owner--name``, ``datasets--owner--name``) so existing vaults
67
+ keep resuming. Other providers include their id in the name.
68
+ """
69
+ loc = f"datasets/{repo_id}" if repo_type == "dataset" else repo_id
70
+ return parse_source(loc).bundle_name
71
+
72
+
73
+ def bundle_dir_for(vault: Path, source: str | SourceRef, revision: str, repo_type: str | None = None) -> Path:
74
+ """Return the bundle directory for a source + pinned revision.
75
+
76
+ ``repo_type`` is accepted only so older callers that passed a bare
77
+ ``repo_id`` string still work; new code passes a SourceRef or source ref.
78
+ """
79
+ if isinstance(source, SourceRef):
80
+ ref = source
81
+ elif repo_type is not None:
82
+ loc = f"datasets/{source}" if repo_type == "dataset" else source
83
+ ref = parse_source(loc)
84
+ else:
85
+ ref = parse_source(source)
86
+ return vault / ref.bundle_name / revision[:12]
87
+
88
+
89
+ def write_manifest(bundle_dir: Path, manifest: dict) -> None:
90
+ path = bundle_dir / "manifest.json"
91
+ path.write_text(json.dumps(manifest, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
92
+
93
+
94
+ def load_manifest(bundle_dir: Path) -> dict:
95
+ return json.loads((bundle_dir / "manifest.json").read_text(encoding="utf-8"))
96
+
97
+
98
+ def _guess_version(repo_name: str, model_type: str | None) -> str | None:
99
+ # "Qwen3-0.6B" with model_type "qwen3" -> "3"
100
+ if model_type:
101
+ m = re.search(r"(\d+(?:\.\d+)?)$", model_type)
102
+ if m:
103
+ return m.group(1)
104
+ m = re.search(r"[a-zA-Z](\d+(?:\.\d+)?)[-_]", repo_name)
105
+ return m.group(1) if m else None
106
+
107
+
108
+ def archive(
109
+ source: str | SourceRef,
110
+ revision: str | None = None,
111
+ vault: Path = Path("vault"),
112
+ force: bool = False,
113
+ dry_run: bool = False,
114
+ max_bytes: int | None = None,
115
+ max_minutes: float | None = None,
116
+ rehash: bool = False,
117
+ jobs: int = 4,
118
+ shard: tuple[int, int] | None = None,
119
+ progress=print,
120
+ ) -> Path | None:
121
+ """Archive a source through pin → reconcile → transfer → register."""
122
+ from .transfer import (
123
+ LedgerError,
124
+ CleanStop,
125
+ PartialTransfer,
126
+ StopController,
127
+ add_disk_preflight,
128
+ begin_session,
129
+ find_resume,
130
+ finish_session,
131
+ load_ledger,
132
+ new_ledger,
133
+ reconcile,
134
+ record_event,
135
+ save_ledger,
136
+ print_plan,
137
+ print_shard_plan,
138
+ transfer_all,
139
+ transfer_lock,
140
+ transfer_plan,
141
+ )
142
+
143
+ ref = source if isinstance(source, SourceRef) else parse_source(source)
144
+ provider = get_provider(ref.provider)
145
+ root = payload_root_for(ref.artifact_type)
146
+ resume = find_resume(vault, ref, revision, root)
147
+ pinned = resume[1] if resume else None
148
+ orphan_dir = resume[0] if resume and pinned is None else None
149
+
150
+ if pinned is not None:
151
+ bundle_dir = resume[0]
152
+ progress(
153
+ f"Resuming {ref.artifact_type} {ref.canonical} @ pinned revision "
154
+ f"{pinned['revision'][:12]} (no metadata refresh) ..."
155
+ )
156
+ snapshot = None
157
+ else:
158
+ pin_revision = orphan_dir.name if orphan_dir is not None else revision
159
+ progress(f"Resolving {ref.canonical} @ {pin_revision or provider.default_revision} ...")
160
+ try:
161
+ snapshot = provider.pin(ref, pin_revision, require_access=True)
162
+ except SourceGatedError as exc:
163
+ if orphan_dir is not None:
164
+ shutil.rmtree(orphan_dir, ignore_errors=True)
165
+ raise SystemExit(str(exc)) from None
166
+ except SourceNotFoundError as exc:
167
+ raise SystemExit(str(exc)) from None
168
+ bundle_dir = bundle_dir_for(vault, ref, snapshot.revision)
169
+
170
+ payload_dir = bundle_dir / root
171
+ with transfer_lock(bundle_dir, progress=progress):
172
+ manifest_path = bundle_dir / "manifest.json"
173
+ if manifest_path.exists() and not force and not dry_run:
174
+ raise SystemExit(f"Bundle already exists: {bundle_dir} (use --force to re-archive)")
175
+
176
+ if pinned is not None:
177
+ ledger = load_ledger(bundle_dir)
178
+ else:
179
+ if manifest_path.exists() and dry_run and not force:
180
+ try:
181
+ ledger = load_ledger(bundle_dir)
182
+ except LedgerError:
183
+ assert snapshot is not None
184
+ ledger = new_ledger(snapshot)
185
+ elif force:
186
+ assert snapshot is not None
187
+ ledger = new_ledger(snapshot)
188
+ save_ledger(bundle_dir, ledger)
189
+ else:
190
+ try:
191
+ ledger = load_ledger(bundle_dir)
192
+ except LedgerError:
193
+ assert snapshot is not None
194
+ ledger = new_ledger(snapshot)
195
+ save_ledger(bundle_dir, ledger)
196
+ if force and manifest_path.exists():
197
+ manifest_path.unlink()
198
+
199
+ if dry_run:
200
+ import copy
201
+
202
+ dry_ledger = copy.deepcopy(ledger)
203
+ dry_session = {"bytes_adopted": 0, "files_completed": 0}
204
+ plan = reconcile(
205
+ bundle_dir,
206
+ payload_dir,
207
+ dry_ledger,
208
+ dry_session,
209
+ progress=progress,
210
+ apply=False,
211
+ rehash=rehash,
212
+ )
213
+ add_disk_preflight(bundle_dir, plan)
214
+ print_plan(plan, progress=progress)
215
+ if shard is not None:
216
+ print_shard_plan(ledger, shard, progress=progress)
217
+ return None
218
+
219
+ stop_controller = StopController(max_bytes=max_bytes, max_minutes=max_minutes)
220
+ stop_controller.start()
221
+ with stop_controller.sigint_handler():
222
+ session = begin_session(bundle_dir, ledger, shard=shard)
223
+ session_finished = False
224
+ try:
225
+ plan = reconcile(
226
+ bundle_dir,
227
+ payload_dir,
228
+ ledger,
229
+ session,
230
+ progress=progress,
231
+ rehash=rehash,
232
+ )
233
+ add_disk_preflight(bundle_dir, plan)
234
+ print_plan(plan, progress=progress)
235
+ if shard is not None:
236
+ print_shard_plan(ledger, shard, progress=progress)
237
+ if plan["disk"]["verdict"] == "insufficient":
238
+ progress("WARNING: disk preflight is insufficient; transfer may end with ENOSPC")
239
+ stop_controller.check(session)
240
+ plan = transfer_all(
241
+ bundle_dir,
242
+ payload_dir,
243
+ ledger,
244
+ session,
245
+ progress=progress,
246
+ stop_controller=stop_controller,
247
+ jobs=jobs,
248
+ shard=shard,
249
+ )
250
+ if not plan["complete"]:
251
+ raise RuntimeError("transfer ended without verifying every pinned file")
252
+
253
+ finish_session(bundle_dir, ledger, session, "complete")
254
+ session_finished = True
255
+ return _register_bundle(bundle_dir, payload_dir, ledger, progress)
256
+ except CleanStop as stop:
257
+ plan = add_disk_preflight(bundle_dir, transfer_plan(payload_dir, ledger))
258
+ if plan["complete"]:
259
+ finish_session(bundle_dir, ledger, session, "complete")
260
+ session_finished = True
261
+ return _register_bundle(bundle_dir, payload_dir, ledger, progress)
262
+ session["stop_detail"] = stop.detail
263
+ finish_session(bundle_dir, ledger, session, stop.reason)
264
+ session_finished = True
265
+ print_plan(plan, progress=progress)
266
+ raise PartialTransfer(bundle_dir, stop.reason, stop.detail, plan)
267
+ except SourceGatedError as exc:
268
+ record_event(
269
+ ledger,
270
+ None,
271
+ "gated",
272
+ "upstream access was denied during transfer; partial archive retained",
273
+ )
274
+ finish_session(bundle_dir, ledger, session, "error")
275
+ session_finished = True
276
+ raise SystemExit(str(exc)) from None
277
+ except BaseException:
278
+ if not session_finished:
279
+ finish_session(bundle_dir, ledger, session, "error")
280
+ raise
281
+
282
+
283
+ def archive_model(
284
+ repo_id: str,
285
+ revision: str | None = None,
286
+ vault: Path = Path("vault"),
287
+ force: bool = False,
288
+ repo_type: str = "model",
289
+ **kwargs,
290
+ ) -> Path | None:
291
+ """Backward-compatible wrapper: ``repo_id`` + ``repo_type`` → ``archive(source)``."""
292
+ loc = f"datasets/{repo_id}" if repo_type == "dataset" else repo_id
293
+ return archive(loc, revision=revision, vault=vault, force=force, **kwargs)
294
+
295
+
296
+ def _register_bundle(bundle_dir: Path, payload_dir: Path, ledger: dict, progress) -> Path:
297
+ """Run completion-time extraction and register from pinned ledger facts."""
298
+ from .transfer import file_records as ledger_file_records, local_mirrors, transfer_summary
299
+
300
+ source = source_from_ledger(ledger)
301
+ provider = get_provider(source.provider)
302
+ repo_type = source.artifact_type
303
+ commit = ledger["revision"]
304
+ root = payload_root_for(repo_type)
305
+ metadata = ledger["metadata"]
306
+ card = metadata.get("card_data") or {}
307
+ tags = list(metadata.get("tags") or [])
308
+ gated = metadata.get("gated", False)
309
+
310
+ # Provider transfer caches own resumable partials until every expected
311
+ # file is verified. They are not archival payload.
312
+ shutil.rmtree(payload_dir / ".cache", ignore_errors=True)
313
+ file_records, upstream_mismatches = ledger_file_records(ledger, root)
314
+ inventory_paths = [r["path"] for r in file_records]
315
+ total_size = sum(r["size"] for r in file_records)
316
+ completeness = check_completeness(repo_type, inventory_paths)
317
+ if repo_type == "dataset":
318
+ model_metadata = runtime = None
319
+ dataset_metadata = extract_dataset_metadata(payload_dir, card, file_records)
320
+ else:
321
+ dataset_metadata = None
322
+ model_metadata = extract_model_metadata(payload_dir, card)
323
+ runtime = estimate_runtime(payload_dir, model_metadata)
324
+ license_id = card.get("license")
325
+ if isinstance(license_id, list):
326
+ license_id = license_id[0] if license_id else None
327
+ licensing = build_licensing_record(license_id, payload_dir, gated=bool(gated))
328
+
329
+ for lf in licensing["license_files"]:
330
+ name = Path(lf).name
331
+ if name.upper().startswith(("LICENSE", "LICENCE", "COPYING")):
332
+ shutil.copy2(bundle_dir / lf, bundle_dir / "LICENSE")
333
+ break
334
+
335
+ progress("Querying downstream ecosystem ...")
336
+ relationships = provider.relationships(source, metadata)
337
+
338
+ now = utc_now()
339
+ bundle_id = f"{source.bundle_name}@{commit[:12]}"
340
+ model_type = model_metadata.get("model_type") if model_metadata else None
341
+ aliases = [source.canonical, source.locator]
342
+ if source.url not in aliases:
343
+ aliases.append(source.url)
344
+
345
+ downloader = {
346
+ "tool": "darsay",
347
+ "version": __version__,
348
+ "python": platform.python_version(),
349
+ "platform": platform.platform(),
350
+ "provider": provider.name,
351
+ **provider.downloader_versions(),
352
+ }
353
+
354
+ manifest = {
355
+ "schema_version": SCHEMA_VERSION,
356
+ "artifact_type": repo_type,
357
+ "bundle_id": bundle_id,
358
+ "identity": {
359
+ "model_name": source.name,
360
+ "family": model_type or source.name.split("-")[0].lower(),
361
+ "publisher": source.publisher,
362
+ "version": _guess_version(source.name, model_type),
363
+ "release_date": metadata.get("created_at"),
364
+ "aliases": aliases,
365
+ },
366
+ "source": {
367
+ "origin": provider.name,
368
+ "provider": provider.name,
369
+ "address": source.canonical,
370
+ "repo_id": source.locator,
371
+ "upstream_url": source.url,
372
+ "revision": commit,
373
+ "revision_ref": ledger["revision_ref"],
374
+ "last_modified_upstream": metadata.get("last_modified"),
375
+ "download_timestamp": now,
376
+ "transfer": transfer_summary(ledger),
377
+ "downloader": downloader,
378
+ "mirrors_used": local_mirrors(ledger),
379
+ "signatures": None,
380
+ "access": provider.access_record(metadata),
381
+ "upstream_stats_at_archive": {
382
+ "downloads_last_month": metadata.get("downloads"),
383
+ "likes": metadata.get("likes"),
384
+ },
385
+ "upstream_tags": tags or None,
386
+ },
387
+ "licensing": licensing,
388
+ "inventory": {
389
+ "file_count": len(file_records),
390
+ "total_size_bytes": total_size,
391
+ "bundle_hash": bundle_hash(file_records, root),
392
+ "layout": {
393
+ "payload_root": ARTIFACT_TYPES[repo_type]["payload_root"],
394
+ "mutable_metadata": [*BUNDLE_METADATA_FILES, "LICENSE"],
395
+ },
396
+ "files": file_records,
397
+ },
398
+ **({"dataset_metadata": dataset_metadata} if repo_type == "dataset"
399
+ else {"model_metadata": model_metadata, "runtime": runtime}),
400
+ "validation": {
401
+ "checksum_verification": {
402
+ "at": now,
403
+ "method": "per-file at download completion",
404
+ "status": "pass" if not upstream_mismatches else "fail",
405
+ "files_checked": len(file_records),
406
+ "upstream_mismatches": upstream_mismatches,
407
+ },
408
+ "completeness": completeness,
409
+ "smoke_tests": ({"structure": {"status": "not-run"}} if repo_type == "dataset"
410
+ else {"tokenizer": {"status": "not-run"},
411
+ "inference": {"status": "not-run"}}),
412
+ },
413
+ "relationships": relationships,
414
+ "archive": {
415
+ "date_archived": now,
416
+ "archived_by": None,
417
+ "location": str(bundle_dir.resolve()),
418
+ "host": socket.gethostname(),
419
+ "storage_tier": "local-disk",
420
+ "backup_status": "none",
421
+ "replicas": [],
422
+ "last_integrity_check": now,
423
+ "last_accessed": now,
424
+ },
425
+ "security": {
426
+ "integrity_status": "verified-against-upstream" if not upstream_mismatches else "upstream-mismatch",
427
+ "unexpected_changes": [],
428
+ "trust_level": "unreviewed",
429
+ "reviewed_by": None,
430
+ "review_notes": None,
431
+ },
432
+ "curation": {
433
+ "historical_significance": None,
434
+ "major_capabilities": None,
435
+ "known_limitations": None,
436
+ "successor_models": None,
437
+ "personal_notes": None,
438
+ "curation_file": "curation.md",
439
+ },
440
+ }
441
+
442
+ write_manifest(bundle_dir, manifest)
443
+ _write_curation_template(bundle_dir, manifest)
444
+
445
+ from .readme_gen import write_bundle_readme
446
+ write_bundle_readme(bundle_dir, manifest)
447
+
448
+ from .verify import write_verification_report
449
+ write_verification_report(bundle_dir, manifest["validation"]["checksum_verification"], completeness, first_run=True)
450
+
451
+ progress(f"Archived {bundle_id}: {len(file_records)} files, {total_size / 1024**2:.1f} MiB")
452
+ if upstream_mismatches:
453
+ progress(f"WARNING: {len(upstream_mismatches)} files did not match upstream checksums!")
454
+ return bundle_dir
455
+
456
+
457
+ def _write_curation_template(bundle_dir: Path, manifest: dict) -> None:
458
+ path = bundle_dir / "curation.md"
459
+ if path.exists():
460
+ return
461
+ path.write_text(
462
+ f"""# Curation notes — {manifest['bundle_id']}
463
+
464
+ _This is the curator's file: edit it freely. `darsay regen` folds it into
465
+ README.md; nothing here is machine-generated after this template._
466
+
467
+ ## Historical significance
468
+
469
+ _Why this model matters._
470
+
471
+ ## Derivation & alignment changes
472
+
473
+ _How this artifact differs from its base — finetune, quantization, merge,
474
+ alignment modifications (e.g. abliteration) — beyond what upstream tags say._
475
+
476
+ ## Major capabilities
477
+
478
+ _What it was known to do well._
479
+
480
+ ## Known limitations
481
+
482
+ _Where it fell short._
483
+
484
+ ## Successor models
485
+
486
+ _What replaced it._
487
+
488
+ ## Personal notes
489
+
490
+ _Anything else worth remembering._
491
+ """,
492
+ encoding="utf-8",
493
+ )