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.
- model_mirror/__init__.py +7 -0
- model_mirror/audit.py +214 -0
- model_mirror/checksums.py +371 -0
- model_mirror/cli.py +2436 -0
- model_mirror/config.py +300 -0
- model_mirror/hub.py +901 -0
- model_mirror/lock.py +119 -0
- model_mirror/mirror.py +201 -0
- model_mirror/progress.py +260 -0
- model_mirror/removal.py +164 -0
- model_mirror/repair.py +399 -0
- model_mirror/state.py +150 -0
- model_mirror/torrent.py +453 -0
- model_mirror/torrent_coverage.py +400 -0
- model_mirror/torrent_hashes.py +168 -0
- model_mirror/torrent_import.py +620 -0
- model_mirror/torrent_publication.py +582 -0
- model_mirror/torrent_seed.py +228 -0
- model_mirror/verify.py +153 -0
- model_mirror_cli-0.2.0.dist-info/METADATA +550 -0
- model_mirror_cli-0.2.0.dist-info/RECORD +24 -0
- model_mirror_cli-0.2.0.dist-info/WHEEL +4 -0
- model_mirror_cli-0.2.0.dist-info/entry_points.txt +2 -0
- model_mirror_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
model_mirror/removal.py
ADDED
|
@@ -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
|