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/lock.py ADDED
@@ -0,0 +1,119 @@
1
+ from __future__ import annotations
2
+
3
+ import errno
4
+ import fcntl
5
+ import os
6
+ import socket
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+
10
+ import yaml
11
+
12
+ from .state import utc_now
13
+
14
+
15
+ LOCK_FILE = ".verification.lock"
16
+ REMOVAL_MARKER = ".model-mirror-removal.json"
17
+
18
+
19
+ class ModelBusyError(RuntimeError):
20
+ def __init__(self, root: Path, info: dict):
21
+ self.root = root
22
+ self.info = info
23
+ detail = lock_label(info)
24
+ super().__init__(f"model mirror is busy: {root} ({detail})")
25
+
26
+
27
+ @dataclass(slots=True)
28
+ class ModelLock:
29
+ root: Path
30
+ command: str
31
+ repo_id: str
32
+ repo_type: str = "model"
33
+ wait: bool = False
34
+ handle: object | None = None
35
+
36
+ def __enter__(self):
37
+ self.root.mkdir(parents=True, exist_ok=True)
38
+ path = lock_path(self.root)
39
+ self.handle = path.open("a+", encoding="utf-8")
40
+ flags = fcntl.LOCK_EX if self.wait else fcntl.LOCK_EX | fcntl.LOCK_NB
41
+ try:
42
+ fcntl.flock(self.handle.fileno(), flags)
43
+ except OSError as exc:
44
+ if exc.errno not in {errno.EACCES, errno.EAGAIN}:
45
+ raise
46
+ self.handle.seek(0)
47
+ info = yaml.safe_load(self.handle.read()) or {}
48
+ raise ModelBusyError(self.root, info) from exc
49
+
50
+ marker = self.root / REMOVAL_MARKER
51
+ if self.command != "remove" and (marker.exists() or marker.is_symlink()):
52
+ info = {
53
+ "command": "remove-pending",
54
+ "repo_id": self.repo_id,
55
+ "repo_type": self.repo_type,
56
+ }
57
+ fcntl.flock(self.handle.fileno(), fcntl.LOCK_UN)
58
+ self.handle.close()
59
+ self.handle = None
60
+ raise ModelBusyError(self.root, info)
61
+
62
+ info = {
63
+ "pid": os.getpid(),
64
+ "host": socket.gethostname(),
65
+ "command": self.command,
66
+ "repo_id": self.repo_id,
67
+ "repo_type": self.repo_type,
68
+ "started_at_utc": utc_now(),
69
+ }
70
+ self.handle.seek(0)
71
+ self.handle.truncate()
72
+ self.handle.write(yaml.safe_dump(info, sort_keys=True))
73
+ self.handle.flush()
74
+ os.fsync(self.handle.fileno())
75
+ return self
76
+
77
+ def __exit__(self, exc_type, exc, tb):
78
+ if self.handle is None:
79
+ return False
80
+ self.handle.seek(0)
81
+ self.handle.truncate()
82
+ self.handle.flush()
83
+ os.fsync(self.handle.fileno())
84
+ fcntl.flock(self.handle.fileno(), fcntl.LOCK_UN)
85
+ self.handle.close()
86
+ self.handle = None
87
+ return False
88
+
89
+
90
+ def lock_path(root: Path) -> Path:
91
+ return root / LOCK_FILE
92
+
93
+
94
+ def read_active_lock(root: Path) -> dict | None:
95
+ path = lock_path(root)
96
+ if not path.exists():
97
+ return None
98
+ with path.open("r", encoding="utf-8") as handle:
99
+ try:
100
+ fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
101
+ except OSError as exc:
102
+ if exc.errno not in {errno.EACCES, errno.EAGAIN}:
103
+ raise
104
+ handle.seek(0)
105
+ return yaml.safe_load(handle.read()) or {}
106
+ else:
107
+ fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
108
+ return None
109
+
110
+
111
+ def lock_label(info: dict | None) -> str:
112
+ if not info:
113
+ return "lock held"
114
+ parts = []
115
+ for key in ("command", "pid", "host", "started_at_utc"):
116
+ value = info.get(key)
117
+ if value:
118
+ parts.append(f"{key}={value}")
119
+ return ", ".join(parts) if parts else "lock held"
model_mirror/mirror.py ADDED
@@ -0,0 +1,201 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+
6
+ from .checksums import write_checksums
7
+ from .config import Config, archive_path
8
+ from .hub import HuggingFaceHub, cached_manifest_verifies, compatible_snapshot_plan, get_snapshot, write_snapshot_plan
9
+ from .lock import ModelLock
10
+ from .repair import derive_state
11
+ from .state import VerificationState, read_verification_state, write_verification_state
12
+ from .verify import verify_remote
13
+
14
+
15
+ @dataclass(slots=True)
16
+ class MirrorResult:
17
+ status: str
18
+ path: Path
19
+ files: int
20
+
21
+
22
+ def mirror(
23
+ config: Config,
24
+ repo_id: str,
25
+ *,
26
+ hub=None,
27
+ repo_type: str | None = None,
28
+ revision: str | None = None,
29
+ force: bool = False,
30
+ verify_after: bool = True,
31
+ stall_timeout_seconds: int | None = None,
32
+ ) -> MirrorResult:
33
+ selected_type = repo_type or config.repo_type
34
+ selected_revision = revision or config.revision
35
+ selected_hub = hub or HuggingFaceHub(config)
36
+ destination = archive_path(config, repo_id, selected_type)
37
+ with ModelLock(destination, "mirror", repo_id, selected_type):
38
+ existing_state = read_verification_state(destination)
39
+ if existing_state is None:
40
+ write_verification_state(
41
+ destination,
42
+ VerificationState(
43
+ status="in_progress",
44
+ repo_id=repo_id,
45
+ repo_type=selected_type,
46
+ requested_revision=selected_revision,
47
+ issues=["mirror started"],
48
+ ),
49
+ )
50
+ return mirror_locked(
51
+ config,
52
+ repo_id,
53
+ selected_hub,
54
+ selected_type,
55
+ selected_revision,
56
+ destination,
57
+ existing_state=existing_state,
58
+ force=force,
59
+ verify_after=verify_after,
60
+ stall_timeout_seconds=stall_timeout_seconds,
61
+ )
62
+
63
+
64
+ def mirror_locked(
65
+ config: Config,
66
+ repo_id: str,
67
+ selected_hub,
68
+ selected_type: str,
69
+ selected_revision: str,
70
+ destination: Path,
71
+ *,
72
+ existing_state: VerificationState | None,
73
+ force: bool,
74
+ verify_after: bool,
75
+ stall_timeout_seconds: int | None,
76
+ ) -> MirrorResult:
77
+ snapshot = select_mirror_snapshot(
78
+ selected_hub,
79
+ repo_id,
80
+ selected_type,
81
+ selected_revision,
82
+ destination,
83
+ existing_state=existing_state,
84
+ force=force,
85
+ )
86
+ from .torrent_publication import assert_commit_update_allowed, load_fenced_publication
87
+
88
+ assert_commit_update_allowed(destination, snapshot.resolved_commit)
89
+ metadata = snapshot.files
90
+
91
+ if not force and verify_remote(destination, metadata, check_hashes=False).ok:
92
+ if not verify_after:
93
+ return MirrorResult("complete", destination, len(metadata))
94
+ if (
95
+ existing_state is not None
96
+ and existing_state.clean
97
+ and existing_state.resolved_commit == snapshot.resolved_commit
98
+ ):
99
+ return MirrorResult("complete", destination, len(metadata))
100
+ checksums_written = cached_manifest_verifies(destination, metadata)
101
+ if config.checksum and not checksums_written:
102
+ write_checksums(destination, max_workers=config.checksum_workers)
103
+ checksums_written = True
104
+ state = derive_state(
105
+ config,
106
+ repo_id,
107
+ selected_hub,
108
+ selected_type,
109
+ selected_revision,
110
+ destination,
111
+ snapshot=snapshot,
112
+ upstream_commit=snapshot.resolved_commit,
113
+ cached=False,
114
+ from_manifest=checksums_written,
115
+ )
116
+ return MirrorResult("complete" if state.clean else "downloaded-unverified", destination, len(metadata))
117
+
118
+ fenced = load_fenced_publication(destination)
119
+ if fenced is not None:
120
+ raise RuntimeError(
121
+ f"published payload cannot be replaced by mirror: {fenced[0].publication_id}; "
122
+ "use same-commit repair or retire the publication"
123
+ )
124
+
125
+ write_verification_state(
126
+ destination,
127
+ VerificationState(
128
+ status="in_progress",
129
+ repo_id=repo_id,
130
+ repo_type=selected_type,
131
+ requested_revision=selected_revision,
132
+ resolved_commit=snapshot.resolved_commit,
133
+ upstream_commit=snapshot.resolved_commit,
134
+ upstream_status="current",
135
+ issues=["mirror in progress"],
136
+ ),
137
+ )
138
+ destination.mkdir(parents=True, exist_ok=True)
139
+ write_snapshot_plan(destination, snapshot)
140
+ download_snapshot(selected_hub, snapshot, destination, stall_timeout_seconds=stall_timeout_seconds)
141
+ checksums_written = cached_manifest_verifies(destination, metadata)
142
+ if config.checksum and not checksums_written:
143
+ write_checksums(destination, max_workers=config.checksum_workers)
144
+ checksums_written = True
145
+ if verify_after:
146
+ state = derive_state(
147
+ config,
148
+ repo_id,
149
+ selected_hub,
150
+ selected_type,
151
+ selected_revision,
152
+ destination,
153
+ snapshot=snapshot,
154
+ upstream_commit=snapshot.resolved_commit,
155
+ cached=False,
156
+ from_manifest=checksums_written,
157
+ )
158
+ return MirrorResult("downloaded" if state.clean else "downloaded-unverified", destination, len(metadata))
159
+ write_verification_state(
160
+ destination,
161
+ VerificationState(
162
+ status="dirty",
163
+ repo_id=repo_id,
164
+ repo_type=selected_type,
165
+ requested_revision=selected_revision,
166
+ resolved_commit=snapshot.resolved_commit,
167
+ upstream_commit=snapshot.resolved_commit,
168
+ upstream_status="current",
169
+ issues=["verification skipped"],
170
+ ),
171
+ )
172
+ return MirrorResult("downloaded", destination, len(metadata))
173
+
174
+
175
+ def select_mirror_snapshot(
176
+ selected_hub,
177
+ repo_id: str,
178
+ selected_type: str,
179
+ selected_revision: str,
180
+ destination: Path,
181
+ *,
182
+ existing_state: VerificationState | None,
183
+ force: bool,
184
+ ):
185
+ if not force and existing_state is not None and existing_state.status == "in_progress":
186
+ frozen = compatible_snapshot_plan(
187
+ destination,
188
+ repo_id=repo_id,
189
+ repo_type=selected_type,
190
+ requested_revision=selected_revision,
191
+ )
192
+ if frozen is not None:
193
+ return frozen
194
+ return get_snapshot(selected_hub, repo_id, selected_type, selected_revision)
195
+
196
+
197
+ def download_snapshot(selected_hub, snapshot, destination: Path, *, stall_timeout_seconds: int | None = None) -> None:
198
+ if hasattr(selected_hub, "download_snapshot"):
199
+ selected_hub.download_snapshot(snapshot, destination, stall_timeout_seconds=stall_timeout_seconds)
200
+ return
201
+ selected_hub.snapshot_download(snapshot.repo_id, snapshot.repo_type, snapshot.resolved_commit, destination)
@@ -0,0 +1,260 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import threading
6
+ import time
7
+ from dataclasses import dataclass
8
+ from datetime import datetime, timezone
9
+ from pathlib import Path
10
+
11
+
12
+ PROGRESS_DIR = ".model-mirror"
13
+ PROGRESS_FILE = "progress.json"
14
+ PROGRESS_SCHEMA = "model-mirror-progress"
15
+ PROGRESS_VERSION = 1
16
+ DEFAULT_STALL_TIMEOUT_SECONDS = 600
17
+ DEFAULT_STALL_RETRIES = 3
18
+ SKIP_DIRS = {".model-mirror", ".cache", ".archive"}
19
+
20
+
21
+ @dataclass(frozen=True, slots=True)
22
+ class ProgressEntry:
23
+ path: str
24
+ stage: str
25
+ bytes_done: int
26
+ bytes_total: int | None
27
+ updated_at_utc: str
28
+ idle_seconds: int | None
29
+ stalled: bool
30
+ source: str
31
+ rate_bytes_per_second: float | None = None
32
+
33
+
34
+ @dataclass(frozen=True, slots=True)
35
+ class ProgressSnapshot:
36
+ entries: list[ProgressEntry]
37
+ source: str
38
+
39
+ @property
40
+ def active(self) -> bool:
41
+ return bool(self.entries)
42
+
43
+ @property
44
+ def stalled_count(self) -> int:
45
+ return sum(1 for entry in self.entries if entry.stalled)
46
+
47
+ @property
48
+ def any_stalled(self) -> bool:
49
+ return self.stalled_count > 0
50
+
51
+
52
+ class ProgressRecorder:
53
+ def __init__(
54
+ self,
55
+ root: Path,
56
+ *,
57
+ min_interval_seconds: float = 5.0,
58
+ min_bytes: int = 64 * 1024 * 1024,
59
+ ):
60
+ self.root = root
61
+ self.min_interval_seconds = min_interval_seconds
62
+ self.min_bytes = min_bytes
63
+ self._active: dict[str, dict] = {}
64
+ self._lock = threading.Lock()
65
+
66
+ def track(self, path: str, *, total: int | None, stage: str, bytes_done: int = 0) -> FileProgress:
67
+ progress = FileProgress(self, path, total=total)
68
+ progress.update(bytes_done, stage=stage, force=True)
69
+ return progress
70
+
71
+ def update(
72
+ self,
73
+ path: str,
74
+ *,
75
+ total: int | None,
76
+ stage: str,
77
+ bytes_done: int,
78
+ rate_bytes_per_second: float | None,
79
+ ) -> None:
80
+ now = utc_now_iso()
81
+ with self._lock:
82
+ entry = self._active.get(path)
83
+ if entry is None:
84
+ entry = {
85
+ "path": path,
86
+ "bytes_total": total,
87
+ "started_at_utc": now,
88
+ }
89
+ entry.update(
90
+ {
91
+ "stage": stage,
92
+ "bytes_done": bytes_done,
93
+ "bytes_total": total,
94
+ "updated_at_utc": now,
95
+ "rate_bytes_per_second": rate_bytes_per_second,
96
+ }
97
+ )
98
+ self._active[path] = entry
99
+ self._write_locked()
100
+
101
+ def finish(self, path: str) -> None:
102
+ with self._lock:
103
+ self._active.pop(path, None)
104
+ if self._active:
105
+ self._write_locked()
106
+ else:
107
+ progress_path(self.root).unlink(missing_ok=True)
108
+
109
+ def _write_locked(self) -> None:
110
+ path = progress_path(self.root)
111
+ path.parent.mkdir(parents=True, exist_ok=True)
112
+ payload = {
113
+ "schema": PROGRESS_SCHEMA,
114
+ "version": PROGRESS_VERSION,
115
+ "pid": os.getpid(),
116
+ "updated_at_utc": utc_now_iso(),
117
+ "active_files": self._active,
118
+ }
119
+ tmp = path.with_name(f"{path.name}.tmp")
120
+ tmp.write_text(json.dumps(payload, sort_keys=True, indent=2) + "\n", encoding="utf-8")
121
+ tmp.replace(path)
122
+
123
+
124
+ class FileProgress:
125
+ def __init__(self, recorder: ProgressRecorder, path: str, *, total: int | None):
126
+ self.recorder = recorder
127
+ self.path = path
128
+ self.total = total
129
+ self.stage = "starting"
130
+ self.last_emit_time: float | None = None
131
+ self.last_emit_bytes: int | None = None
132
+
133
+ def update(self, bytes_done: int, *, stage: str | None = None, force: bool = False) -> None:
134
+ now = time.monotonic()
135
+ selected_stage = stage or self.stage
136
+ should_emit = force or self.last_emit_time is None
137
+ if not should_emit and now - self.last_emit_time >= self.recorder.min_interval_seconds:
138
+ should_emit = True
139
+ if (
140
+ not should_emit
141
+ and self.last_emit_bytes is not None
142
+ and bytes_done - self.last_emit_bytes >= self.recorder.min_bytes
143
+ ):
144
+ should_emit = True
145
+ if not should_emit:
146
+ self.stage = selected_stage
147
+ return
148
+
149
+ rate = None
150
+ if self.last_emit_time is not None and self.last_emit_bytes is not None:
151
+ elapsed = now - self.last_emit_time
152
+ if elapsed > 0:
153
+ rate = max(0, bytes_done - self.last_emit_bytes) / elapsed
154
+ self.last_emit_time = now
155
+ self.last_emit_bytes = bytes_done
156
+ self.stage = selected_stage
157
+ self.recorder.update(
158
+ self.path,
159
+ total=self.total,
160
+ stage=selected_stage,
161
+ bytes_done=bytes_done,
162
+ rate_bytes_per_second=rate,
163
+ )
164
+
165
+ def finish(self) -> None:
166
+ self.recorder.finish(self.path)
167
+
168
+
169
+ def progress_path(root: Path) -> Path:
170
+ return root / PROGRESS_DIR / PROGRESS_FILE
171
+
172
+
173
+ def progress_snapshot(
174
+ root: Path,
175
+ *,
176
+ stall_timeout_seconds: int = DEFAULT_STALL_TIMEOUT_SECONDS,
177
+ now: datetime | None = None,
178
+ scan_incomplete: bool = True,
179
+ ) -> ProgressSnapshot:
180
+ now = now or datetime.now(timezone.utc)
181
+ entries = progress_file_entries(root, stall_timeout_seconds=stall_timeout_seconds, now=now)
182
+ if entries:
183
+ return ProgressSnapshot(entries=entries, source="heartbeat")
184
+ if not scan_incomplete:
185
+ return ProgressSnapshot(entries=[], source="heartbeat")
186
+ return ProgressSnapshot(
187
+ entries=incomplete_file_entries(root, stall_timeout_seconds=stall_timeout_seconds, now=now),
188
+ source="partial-file",
189
+ )
190
+
191
+
192
+ def progress_file_entries(root: Path, *, stall_timeout_seconds: int, now: datetime) -> list[ProgressEntry]:
193
+ path = progress_path(root)
194
+ if not path.exists():
195
+ return []
196
+ data = json.loads(path.read_text(encoding="utf-8"))
197
+ if data.get("schema") != PROGRESS_SCHEMA or data.get("version") != PROGRESS_VERSION:
198
+ return []
199
+ entries = []
200
+ for rel, item in sorted((data.get("active_files") or {}).items()):
201
+ updated_at = str(item.get("updated_at_utc") or "")
202
+ idle = idle_seconds(updated_at, now)
203
+ entries.append(
204
+ ProgressEntry(
205
+ path=str(item.get("path") or rel),
206
+ stage=str(item.get("stage") or "unknown"),
207
+ bytes_done=int(item.get("bytes_done") or 0),
208
+ bytes_total=item.get("bytes_total"),
209
+ updated_at_utc=updated_at,
210
+ idle_seconds=idle,
211
+ stalled=is_stalled(idle, stall_timeout_seconds),
212
+ source="heartbeat",
213
+ rate_bytes_per_second=item.get("rate_bytes_per_second"),
214
+ )
215
+ )
216
+ return entries
217
+
218
+
219
+ def incomplete_file_entries(root: Path, *, stall_timeout_seconds: int, now: datetime) -> list[ProgressEntry]:
220
+ entries = []
221
+ for path in sorted(root.rglob("*.incomplete")):
222
+ if not path.is_file():
223
+ continue
224
+ rel = path.relative_to(root)
225
+ if rel.parts and rel.parts[0] in SKIP_DIRS:
226
+ continue
227
+ updated = datetime.fromtimestamp(path.stat().st_mtime, timezone.utc).isoformat(timespec="seconds")
228
+ idle = idle_seconds(updated, now)
229
+ rel_path = rel.as_posix()[: -len(".incomplete")]
230
+ entries.append(
231
+ ProgressEntry(
232
+ path=rel_path,
233
+ stage="partial",
234
+ bytes_done=path.stat().st_size,
235
+ bytes_total=None,
236
+ updated_at_utc=updated,
237
+ idle_seconds=idle,
238
+ stalled=is_stalled(idle, stall_timeout_seconds),
239
+ source="partial-file",
240
+ )
241
+ )
242
+ return entries
243
+
244
+
245
+ def idle_seconds(updated_at_utc: str, now: datetime) -> int | None:
246
+ try:
247
+ updated = datetime.fromisoformat(updated_at_utc)
248
+ except ValueError:
249
+ return None
250
+ if updated.tzinfo is None:
251
+ updated = updated.replace(tzinfo=timezone.utc)
252
+ return max(0, int((now - updated).total_seconds()))
253
+
254
+
255
+ def is_stalled(idle: int | None, stall_timeout_seconds: int) -> bool:
256
+ return stall_timeout_seconds > 0 and idle is not None and idle >= stall_timeout_seconds
257
+
258
+
259
+ def utc_now_iso() -> str:
260
+ return datetime.now(timezone.utc).isoformat(timespec="seconds")