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,164 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ from dataclasses import asdict, dataclass
6
+ from pathlib import Path
7
+
8
+ from .checksums import SKIP_DIRS, SKIP_FILES
9
+ from .config import Config, REPO_TYPE_DIRS, safe_repo_path
10
+ from .lock import LOCK_FILE, REMOVAL_MARKER
11
+ from .state import utc_now
12
+
13
+
14
+ REMOVALS_DIR = ".model-mirror-removals"
15
+ REMOVAL_RECORD = REMOVAL_MARKER
16
+ REMOVAL_SCHEMA = "model-mirror-removal"
17
+ REMOVAL_VERSION = 1
18
+
19
+
20
+ @dataclass(slots=True)
21
+ class RemovalRecord:
22
+ repo_id: str
23
+ repo_type: str
24
+ original_path: str
25
+ status: str
26
+ exceptions: str
27
+ resolved_commit: str
28
+ checked_at_utc: str
29
+ check_age: str
30
+ payload_files: int
31
+ payload_size: int
32
+ started_at_utc: str = ""
33
+
34
+ def to_dict(self) -> dict:
35
+ return {
36
+ "schema": REMOVAL_SCHEMA,
37
+ "version": REMOVAL_VERSION,
38
+ **asdict(self),
39
+ "started_at_utc": self.started_at_utc or utc_now(),
40
+ }
41
+
42
+ @classmethod
43
+ def from_dict(cls, value: object, *, source: Path) -> RemovalRecord:
44
+ if (
45
+ not isinstance(value, dict)
46
+ or value.get("schema") != REMOVAL_SCHEMA
47
+ or value.get("version") != REMOVAL_VERSION
48
+ ):
49
+ raise ValueError(f"Unsupported removal record: {source}")
50
+ try:
51
+ return cls(
52
+ repo_id=str(value["repo_id"]),
53
+ repo_type=str(value["repo_type"]),
54
+ original_path=str(value["original_path"]),
55
+ status=str(value["status"]),
56
+ exceptions=str(value["exceptions"]),
57
+ resolved_commit=str(value["resolved_commit"]),
58
+ checked_at_utc=str(value["checked_at_utc"]),
59
+ check_age=str(value["check_age"]),
60
+ payload_files=int(value["payload_files"]),
61
+ payload_size=int(value["payload_size"]),
62
+ started_at_utc=str(value["started_at_utc"]),
63
+ )
64
+ except (KeyError, TypeError, ValueError) as exc:
65
+ raise ValueError(f"Malformed removal record: {source}") from exc
66
+
67
+
68
+ def removal_path(config: Config, repo_id: str, repo_type: str) -> Path:
69
+ return (
70
+ Path(config.directory)
71
+ / REMOVALS_DIR
72
+ / REPO_TYPE_DIRS[repo_type]
73
+ / safe_repo_path(repo_id)
74
+ )
75
+
76
+
77
+ def removal_record_path(root: Path) -> Path:
78
+ return root / REMOVAL_RECORD
79
+
80
+
81
+ def stage_removal(root: Path, target: Path, record: RemovalRecord) -> Path:
82
+ if target.exists() or target.is_symlink():
83
+ raise FileExistsError(f"interrupted removal already exists: {target}")
84
+ target.parent.mkdir(parents=True, exist_ok=True)
85
+ write_removal_record(root, record)
86
+ root.rename(target)
87
+ return target
88
+
89
+
90
+ def write_removal_record(root: Path, record: RemovalRecord) -> Path:
91
+ path = removal_record_path(root)
92
+ tmp = path.with_name(f"{path.name}.tmp")
93
+ tmp.write_text(json.dumps(record.to_dict(), sort_keys=True, indent=2) + "\n", encoding="utf-8")
94
+ tmp.replace(path)
95
+ return path
96
+
97
+
98
+ def read_removal_record(root: Path) -> RemovalRecord | None:
99
+ path = removal_record_path(root)
100
+ if not path.exists():
101
+ return None
102
+ try:
103
+ value = json.loads(path.read_text(encoding="utf-8"))
104
+ except Exception as exc:
105
+ raise ValueError(f"Malformed removal record: {path}") from exc
106
+ return RemovalRecord.from_dict(value, source=path)
107
+
108
+
109
+ def complete_removal(root: Path) -> None:
110
+ paths, directories = removable_paths(root)
111
+ payload = [path for path in paths if is_payload_path(root, path)]
112
+ metadata = [
113
+ path
114
+ for path in paths
115
+ if path not in payload and path.name not in {REMOVAL_RECORD, LOCK_FILE}
116
+ ]
117
+ for path in payload:
118
+ unlink_removal_path(path)
119
+ for path in metadata:
120
+ unlink_removal_path(path)
121
+ for path in directories:
122
+ path.rmdir()
123
+ removal_record_path(root).unlink(missing_ok=True)
124
+ (root / LOCK_FILE).unlink(missing_ok=True)
125
+ root.rmdir()
126
+
127
+
128
+ def removable_paths(root: Path) -> tuple[list[Path], list[Path]]:
129
+ paths: list[Path] = []
130
+ directories: list[Path] = []
131
+ for current, dirnames, filenames in os.walk(root, topdown=False, followlinks=False):
132
+ current_path = Path(current)
133
+ for name in filenames:
134
+ paths.append(current_path / name)
135
+ for name in dirnames:
136
+ path = current_path / name
137
+ if path.is_symlink():
138
+ paths.append(path)
139
+ else:
140
+ directories.append(path)
141
+ paths.sort(key=lambda path: path.relative_to(root).as_posix())
142
+ directories.sort(key=lambda path: len(path.relative_to(root).parts), reverse=True)
143
+ return paths, directories
144
+
145
+
146
+ def is_payload_path(root: Path, path: Path) -> bool:
147
+ rel = path.relative_to(root)
148
+ if rel.parts and rel.parts[0] in SKIP_DIRS:
149
+ return False
150
+ return rel.as_posix() not in SKIP_FILES and path.name != REMOVAL_RECORD
151
+
152
+
153
+ def unlink_removal_path(path: Path) -> None:
154
+ path.unlink()
155
+
156
+
157
+ def prune_empty_removal_parents(path: Path, *, stop: Path) -> None:
158
+ current = path
159
+ while current != stop and stop in current.parents:
160
+ try:
161
+ current.rmdir()
162
+ except OSError:
163
+ return
164
+ current = current.parent
model_mirror/repair.py ADDED
@@ -0,0 +1,399 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+
6
+ from .audit import audit_model
7
+ from .checksums import load_manifest, update_checksums, write_checksums
8
+ from .config import Config, archive_path
9
+ from .hub import (
10
+ HuggingFaceHub,
11
+ HubSnapshot,
12
+ cached_manifest_verifies,
13
+ get_snapshot,
14
+ read_snapshot_plan,
15
+ write_snapshot_plan,
16
+ )
17
+ from .lock import ModelBusyError, ModelLock
18
+ from .state import AuditState, read_audit_state, state_from_results, write_audit_state
19
+ from .verify import current_manifest_hash, metadata_blob_id, metadata_lfs_sha256, metadata_path, metadata_size, verify_remote
20
+
21
+
22
+ @dataclass(slots=True)
23
+ class RepairResult:
24
+ status: str
25
+ path: Path
26
+ paths: list[str]
27
+ upstream_status: str = "unknown"
28
+ resolved_commit: str = ""
29
+ upstream_commit: str = ""
30
+
31
+
32
+ def repair(
33
+ config: Config,
34
+ repo_id: str,
35
+ *,
36
+ hub=None,
37
+ repo_type: str | None = None,
38
+ revision: str | None = None,
39
+ update: bool = False,
40
+ force_partial: bool = False,
41
+ ) -> RepairResult:
42
+ selected_type = repo_type or config.repo_type
43
+ selected_revision = revision or config.revision
44
+ selected_hub = hub or HuggingFaceHub(config)
45
+ root = archive_path(config, repo_id, selected_type)
46
+ initial_state = read_audit_state(root)
47
+ from .torrent_publication import (
48
+ assert_commit_update_allowed,
49
+ begin_maintenance,
50
+ finish_maintenance,
51
+ wait_for_maintenance_detach,
52
+ )
53
+
54
+ if (
55
+ update
56
+ and initial_state is not None
57
+ and initial_state.upstream_status == "changed"
58
+ and initial_state.upstream_commit
59
+ ):
60
+ assert_commit_update_allowed(root, initial_state.upstream_commit)
61
+
62
+ maintenance = False
63
+ if (
64
+ initial_state is not None
65
+ and not initial_state.clean
66
+ and bool(initial_state.repair_paths)
67
+ and not update
68
+ ):
69
+ with ModelLock(root, "torrent-maintenance", repo_id, selected_type):
70
+ maintenance = begin_maintenance(root) is not None
71
+ if maintenance:
72
+ wait_for_maintenance_detach(root)
73
+
74
+ result = None
75
+ try:
76
+ with ModelLock(root, "repair", repo_id, selected_type):
77
+ result = repair_locked(
78
+ config,
79
+ repo_id,
80
+ selected_hub,
81
+ selected_type,
82
+ selected_revision,
83
+ root,
84
+ update=update,
85
+ force_partial=force_partial,
86
+ )
87
+ if maintenance:
88
+ finish_maintenance(
89
+ root,
90
+ healthy=result.status in {"complete", "repaired"},
91
+ )
92
+ return result
93
+ except Exception:
94
+ if maintenance:
95
+ try:
96
+ with ModelLock(root, "torrent-maintenance-failed", repo_id, selected_type):
97
+ finish_maintenance(root, healthy=False)
98
+ except ModelBusyError:
99
+ pass
100
+ raise
101
+
102
+
103
+ def repair_locked(
104
+ config: Config,
105
+ repo_id: str,
106
+ selected_hub,
107
+ selected_type: str,
108
+ selected_revision: str,
109
+ root: Path,
110
+ *,
111
+ update: bool,
112
+ force_partial: bool,
113
+ ) -> RepairResult:
114
+ state = read_audit_state(root)
115
+ if state is None:
116
+ return RepairResult("verify-required", root, [])
117
+ if state.offline_only:
118
+ return repair_result("offline-only", root, [], state)
119
+ if update and state.upstream_status == "changed" and state.upstream_commit:
120
+ return update_to_upstream(
121
+ config,
122
+ repo_id,
123
+ selected_hub,
124
+ selected_type,
125
+ selected_revision,
126
+ root,
127
+ state,
128
+ )
129
+ pinned_snapshot = read_snapshot_plan(root)
130
+ if (
131
+ state.clean
132
+ and state.resolved_commit
133
+ and pinned_snapshot is not None
134
+ and pinned_snapshot.resolved_commit != state.resolved_commit
135
+ ):
136
+ return reconcile_snapshot_plan(
137
+ config,
138
+ repo_id,
139
+ selected_hub,
140
+ selected_type,
141
+ selected_revision,
142
+ root,
143
+ state,
144
+ )
145
+ if state.clean:
146
+ return repair_result("complete", root, [], state)
147
+
148
+ paths = sorted(set(state.repair_paths))
149
+ if not paths:
150
+ return repair_result("no-repair-paths", root, [], state)
151
+
152
+ requested_revision = state.requested_revision or selected_revision
153
+ target_revision = state.resolved_commit
154
+ if not target_revision:
155
+ return verification_incomplete_result(root, paths, state)
156
+ snapshot = snapshot_for_requested_revision(
157
+ get_snapshot(selected_hub, repo_id, selected_type, target_revision),
158
+ requested_revision,
159
+ )
160
+ if (
161
+ config.checksum
162
+ and not force_partial
163
+ and missing_manifest_paths(root, snapshot.files, ignored_paths=set(paths))
164
+ ):
165
+ return verification_incomplete_result(root, paths, state)
166
+ root.mkdir(parents=True, exist_ok=True)
167
+ for rel in paths:
168
+ target = root / rel
169
+ if target.exists() and target.is_file():
170
+ target.unlink()
171
+
172
+ download_snapshot(selected_hub, snapshot, root, allow_patterns=paths)
173
+ checksums_available = cached_manifest_verifies(root, snapshot.files)
174
+ if config.checksum and not checksums_available:
175
+ update_checksums(root, paths, max_workers=config.checksum_workers)
176
+ checksums_available = True
177
+
178
+ final_state = derive_state(
179
+ config,
180
+ repo_id,
181
+ selected_hub,
182
+ selected_type,
183
+ state.requested_revision or requested_revision,
184
+ root,
185
+ snapshot=snapshot,
186
+ resolved_commit=target_revision,
187
+ upstream_commit=state.upstream_commit,
188
+ cached=checksums_available,
189
+ from_manifest=checksums_available,
190
+ persist=False,
191
+ )
192
+ persist_repair_state(root, snapshot, final_state)
193
+ status = repair_status(final_state, success="repaired")
194
+ return repair_result(status, root, paths, final_state)
195
+
196
+
197
+ def verification_incomplete_result(root: Path, paths: list[str], state: AuditState) -> RepairResult:
198
+ return RepairResult(
199
+ "verification-incomplete",
200
+ root,
201
+ paths,
202
+ state.upstream_status,
203
+ state.resolved_commit,
204
+ state.upstream_commit,
205
+ )
206
+
207
+
208
+ def update_to_upstream(
209
+ config: Config,
210
+ repo_id: str,
211
+ selected_hub,
212
+ selected_type: str,
213
+ selected_revision: str,
214
+ root: Path,
215
+ state: AuditState,
216
+ ) -> RepairResult:
217
+ requested_revision = state.requested_revision or selected_revision
218
+ target_revision = state.upstream_commit
219
+ root.mkdir(parents=True, exist_ok=True)
220
+ snapshot = snapshot_for_requested_revision(
221
+ get_snapshot(selected_hub, repo_id, selected_type, target_revision),
222
+ requested_revision,
223
+ )
224
+ download_snapshot(selected_hub, snapshot, root)
225
+ checksums_available = cached_manifest_verifies(root, snapshot.files)
226
+ if config.checksum and not checksums_available:
227
+ write_checksums(root, max_workers=config.checksum_workers)
228
+ checksums_available = cached_manifest_verifies(root, snapshot.files)
229
+ final_state = derive_state(
230
+ config,
231
+ repo_id,
232
+ selected_hub,
233
+ selected_type,
234
+ requested_revision,
235
+ root,
236
+ snapshot=snapshot,
237
+ resolved_commit=target_revision,
238
+ upstream_commit=target_revision,
239
+ cached=checksums_available,
240
+ from_manifest=checksums_available,
241
+ persist=False,
242
+ )
243
+ persist_repair_state(root, snapshot, final_state)
244
+ status = repair_status(final_state, success="updated")
245
+ return repair_result(status, root, [], final_state)
246
+
247
+
248
+ def reconcile_snapshot_plan(
249
+ config: Config,
250
+ repo_id: str,
251
+ selected_hub,
252
+ selected_type: str,
253
+ selected_revision: str,
254
+ root: Path,
255
+ state: AuditState,
256
+ ) -> RepairResult:
257
+ requested_revision = state.requested_revision or selected_revision
258
+ snapshot = snapshot_for_requested_revision(
259
+ get_snapshot(selected_hub, repo_id, selected_type, state.resolved_commit),
260
+ requested_revision,
261
+ )
262
+ checksums_available = cached_manifest_verifies(root, snapshot.files)
263
+ final_state = derive_state(
264
+ config,
265
+ repo_id,
266
+ selected_hub,
267
+ selected_type,
268
+ requested_revision,
269
+ root,
270
+ snapshot=snapshot,
271
+ resolved_commit=state.resolved_commit,
272
+ upstream_commit=state.upstream_commit,
273
+ cached=checksums_available,
274
+ from_manifest=checksums_available,
275
+ persist=False,
276
+ )
277
+ persist_repair_state(root, snapshot, final_state)
278
+ status = repair_status(final_state, success="repaired")
279
+ return repair_result(status, root, [], final_state)
280
+
281
+
282
+ def snapshot_for_requested_revision(snapshot: HubSnapshot, requested_revision: str) -> HubSnapshot:
283
+ return HubSnapshot(
284
+ repo_id=snapshot.repo_id,
285
+ repo_type=snapshot.repo_type,
286
+ requested_revision=requested_revision,
287
+ resolved_commit=snapshot.resolved_commit,
288
+ files=snapshot.files,
289
+ )
290
+
291
+
292
+ def persist_repair_state(root: Path, snapshot: HubSnapshot, state: AuditState) -> None:
293
+ if state.clean:
294
+ write_snapshot_plan(root, snapshot)
295
+ write_audit_state(root, state)
296
+
297
+
298
+ def repair_status(state: AuditState, *, success: str) -> str:
299
+ if state.clean:
300
+ return success
301
+ if state.status == "incomplete":
302
+ return "verification-incomplete"
303
+ return "incomplete"
304
+
305
+
306
+ def download_snapshot(selected_hub, snapshot: HubSnapshot, root: Path, *, allow_patterns: list[str] | None = None) -> None:
307
+ if hasattr(selected_hub, "download_snapshot"):
308
+ selected_hub.download_snapshot(snapshot, root, allow_patterns=allow_patterns)
309
+ return
310
+ selected_hub.snapshot_download(
311
+ snapshot.repo_id,
312
+ snapshot.repo_type,
313
+ snapshot.resolved_commit,
314
+ root,
315
+ allow_patterns=allow_patterns,
316
+ )
317
+
318
+
319
+ def missing_manifest_paths(root: Path, metadata, *, ignored_paths: set[str]) -> list[str]:
320
+ manifest = load_manifest(root)
321
+ missing = []
322
+ for item in metadata:
323
+ rel = metadata_path(item)
324
+ if rel in ignored_paths:
325
+ continue
326
+ if metadata_lfs_sha256(item) is None and metadata_blob_id(item) is None:
327
+ continue
328
+ path = root / rel
329
+ if not path.exists() or not path.is_file():
330
+ continue
331
+ expected_size = metadata_size(item)
332
+ stat = path.stat()
333
+ if expected_size is not None and stat.st_size != expected_size:
334
+ continue
335
+ if metadata_lfs_sha256(item) is not None:
336
+ hash_key = "sha256"
337
+ else:
338
+ hash_key = "git_blob_sha1"
339
+ if current_manifest_hash(manifest, rel, stat.st_size, stat.st_mtime_ns, hash_key) is None:
340
+ missing.append(rel)
341
+ return missing
342
+
343
+
344
+ def repair_result(
345
+ status: str,
346
+ root: Path,
347
+ paths: list[str],
348
+ state: AuditState,
349
+ ) -> RepairResult:
350
+ return RepairResult(
351
+ status,
352
+ root,
353
+ paths,
354
+ state.upstream_status,
355
+ state.resolved_commit,
356
+ state.upstream_commit,
357
+ )
358
+
359
+
360
+ def derive_state(
361
+ config: Config,
362
+ repo_id: str,
363
+ hub,
364
+ repo_type: str,
365
+ requested_revision: str,
366
+ root: Path,
367
+ *,
368
+ snapshot: HubSnapshot | None = None,
369
+ resolved_commit: str | None = None,
370
+ upstream_commit: str | None = None,
371
+ cached: bool = False,
372
+ from_manifest: bool = False,
373
+ persist: bool = True,
374
+ ) -> AuditState:
375
+ if snapshot is None:
376
+ snapshot = get_snapshot(hub, repo_id, repo_type, resolved_commit or requested_revision)
377
+ metadata = snapshot.files
378
+ remote_result = verify_remote(root, metadata, cached=cached, from_manifest=from_manifest)
379
+ expected_paths = {metadata_path(item) for item in metadata}
380
+ gguf_only = "config.json" not in expected_paths and any(
381
+ path.lower().endswith(".gguf") for path in expected_paths
382
+ )
383
+ audit_result = (
384
+ audit_model(root, skip_transformers=True, require_config=not gguf_only)
385
+ if repo_type == "model"
386
+ else None
387
+ )
388
+ state = state_from_results(
389
+ repo_id,
390
+ repo_type,
391
+ requested_revision,
392
+ remote_result,
393
+ audit_result,
394
+ resolved_commit=resolved_commit or snapshot.resolved_commit,
395
+ upstream_commit=upstream_commit or snapshot.resolved_commit,
396
+ )
397
+ if persist:
398
+ write_audit_state(root, state)
399
+ return state
model_mirror/state.py ADDED
@@ -0,0 +1,150 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from datetime import datetime, timezone
5
+ from pathlib import Path
6
+
7
+ import yaml
8
+
9
+
10
+ VERIFICATION_FILE = ".verification"
11
+
12
+
13
+ @dataclass(slots=True)
14
+ class VerificationState:
15
+ status: str
16
+ repo_id: str
17
+ repo_type: str = "model"
18
+ requested_revision: str = "main"
19
+ resolved_commit: str = ""
20
+ upstream_commit: str = ""
21
+ upstream_status: str = "unknown"
22
+ offline_only: bool = False
23
+ repair_paths: list[str] = field(default_factory=list)
24
+ issues: list[str] = field(default_factory=list)
25
+ checked_at_utc: str = ""
26
+
27
+ @property
28
+ def clean(self) -> bool:
29
+ return self.status == "clean"
30
+
31
+
32
+ def audit_state_path(root: Path) -> Path:
33
+ return verification_state_path(root)
34
+
35
+
36
+ def verification_state_path(root: Path) -> Path:
37
+ return root / VERIFICATION_FILE
38
+
39
+
40
+ def utc_now() -> str:
41
+ return datetime.now(timezone.utc).isoformat(timespec="seconds")
42
+
43
+
44
+ def read_verification_state(root: Path) -> VerificationState | None:
45
+ path = verification_state_path(root)
46
+ if not path.exists():
47
+ return None
48
+ data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
49
+ if not isinstance(data, dict):
50
+ raise ValueError(f"Verification state must contain a YAML mapping: {path}")
51
+ return VerificationState(
52
+ status=str(data.get("status", "dirty")),
53
+ repo_id=str(data.get("repo_id", "")),
54
+ repo_type=str(data.get("repo_type", "model")),
55
+ requested_revision=str(data.get("requested_revision", data.get("revision", "main"))),
56
+ resolved_commit=str(data.get("resolved_commit", "")),
57
+ upstream_commit=str(data.get("upstream_commit", "")),
58
+ upstream_status=str(data.get("upstream_status", "unknown")),
59
+ offline_only=bool(data.get("offline_only", False)),
60
+ repair_paths=sorted(str(item) for item in data.get("repair_paths", []) or []),
61
+ issues=[str(item) for item in data.get("issues", []) or []],
62
+ checked_at_utc=str(data.get("checked_at_utc", "")),
63
+ )
64
+
65
+
66
+ def write_verification_state(root: Path, state: VerificationState) -> Path:
67
+ path = verification_state_path(root)
68
+ path.parent.mkdir(parents=True, exist_ok=True)
69
+ payload = {
70
+ "status": state.status,
71
+ "repo_id": state.repo_id,
72
+ "repo_type": state.repo_type,
73
+ "requested_revision": state.requested_revision,
74
+ "resolved_commit": state.resolved_commit,
75
+ "upstream_commit": state.upstream_commit,
76
+ "upstream_status": state.upstream_status,
77
+ "offline_only": state.offline_only,
78
+ "repair_paths": sorted(set(state.repair_paths)),
79
+ "issues": state.issues,
80
+ "checked_at_utc": state.checked_at_utc or utc_now(),
81
+ }
82
+ tmp = path.with_name(f"{path.name}.tmp")
83
+ tmp.write_text(yaml.safe_dump(payload, sort_keys=True), encoding="utf-8")
84
+ tmp.replace(path)
85
+ return path
86
+
87
+
88
+ def repair_paths_from_results(remote_result, audit_result=None) -> list[str]:
89
+ paths = set()
90
+ paths.update(getattr(remote_result, "missing", []))
91
+ paths.update(getattr(remote_result, "size_mismatches", []))
92
+ paths.update(getattr(remote_result, "hash_mismatches", []))
93
+ if audit_result is not None:
94
+ paths.update(getattr(audit_result, "missing_files", []))
95
+ for failure in getattr(audit_result, "failures", []):
96
+ candidate = str(failure).split(":", 1)[0]
97
+ if candidate.endswith((".json", ".safetensors", ".bin", ".txt", ".model")):
98
+ paths.add(candidate)
99
+ return sorted(paths)
100
+
101
+
102
+ def state_from_results(
103
+ repo_id: str,
104
+ repo_type: str,
105
+ requested_revision: str,
106
+ remote_result,
107
+ audit_result=None,
108
+ *,
109
+ resolved_commit: str = "",
110
+ upstream_commit: str = "",
111
+ offline_only: bool = False,
112
+ ) -> VerificationState:
113
+ issues = []
114
+ for name in ("missing", "size_mismatches", "hash_mismatches", "cached_hash_missing", "extras"):
115
+ values = getattr(remote_result, name, [])
116
+ issues.extend(f"{name}: {value}" for value in values)
117
+ if audit_result is not None:
118
+ issues.extend(f"missing: {value}" for value in getattr(audit_result, "missing_files", []))
119
+ issues.extend(f"audit: {value}" for value in getattr(audit_result, "failures", []))
120
+
121
+ ok = getattr(remote_result, "ok", False) and (audit_result is None or getattr(audit_result, "ok", False))
122
+ repair_paths = repair_paths_from_results(remote_result, audit_result)
123
+ cache_incomplete = bool(getattr(remote_result, "cached_hash_missing", [])) and not repair_paths
124
+ return VerificationState(
125
+ status="clean" if ok else "incomplete" if cache_incomplete else "dirty",
126
+ repo_id=repo_id,
127
+ repo_type=repo_type,
128
+ requested_revision=requested_revision,
129
+ resolved_commit=resolved_commit,
130
+ upstream_commit=upstream_commit,
131
+ upstream_status=upstream_status(resolved_commit, upstream_commit),
132
+ offline_only=offline_only,
133
+ repair_paths=[] if ok else repair_paths,
134
+ issues=[] if ok else issues,
135
+ checked_at_utc=utc_now(),
136
+ )
137
+
138
+
139
+ def upstream_status(resolved_commit: str, upstream_commit: str) -> str:
140
+ if not upstream_commit:
141
+ return "unknown"
142
+ if not resolved_commit:
143
+ return "unknown"
144
+ return "current" if resolved_commit == upstream_commit else "changed"
145
+
146
+
147
+ # Backward-compatible internal aliases while the implementation is being split up.
148
+ AuditState = VerificationState
149
+ read_audit_state = read_verification_state
150
+ write_audit_state = write_verification_state