wvc 0.1.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.
- wvc/__init__.py +3 -0
- wvc/__main__.py +4 -0
- wvc/bisect.py +298 -0
- wvc/canonical.py +53 -0
- wvc/card.py +87 -0
- wvc/checkout.py +386 -0
- wvc/cli.py +656 -0
- wvc/cli_bisect.py +80 -0
- wvc/cli_common.py +63 -0
- wvc/cli_fsck.py +40 -0
- wvc/cli_peft.py +70 -0
- wvc/cli_remote.py +136 -0
- wvc/codec.py +282 -0
- wvc/diff.py +351 -0
- wvc/errors.py +27 -0
- wvc/extras.py +258 -0
- wvc/file_layout.py +212 -0
- wvc/fsck.py +197 -0
- wvc/fsio.py +361 -0
- wvc/hashing.py +149 -0
- wvc/hub.py +301 -0
- wvc/ids.py +33 -0
- wvc/lowrank.py +156 -0
- wvc/merge_adapter.py +167 -0
- wvc/output.py +43 -0
- wvc/peft.py +214 -0
- wvc/py.typed +0 -0
- wvc/quote.py +30 -0
- wvc/records.py +310 -0
- wvc/refs.py +194 -0
- wvc/remote.py +614 -0
- wvc/repo.py +732 -0
- wvc/reuse.py +184 -0
- wvc/safetensors_io.py +508 -0
- wvc/sources.py +99 -0
- wvc/store.py +672 -0
- wvc/store_config.py +109 -0
- wvc/store_objects.py +133 -0
- wvc/transport.py +355 -0
- wvc/verify.py +126 -0
- wvc-0.1.0.dist-info/METADATA +216 -0
- wvc-0.1.0.dist-info/RECORD +45 -0
- wvc-0.1.0.dist-info/WHEEL +4 -0
- wvc-0.1.0.dist-info/entry_points.txt +2 -0
- wvc-0.1.0.dist-info/licenses/LICENSE +202 -0
wvc/__init__.py
ADDED
wvc/__main__.py
ADDED
wvc/bisect.py
ADDED
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
"""``wvc bisect``: find the first bad commit on a first-parent chain (spec section 4.1).
|
|
2
|
+
|
|
3
|
+
The candidates are the commits after GOOD up to and including BAD, oldest first, found by
|
|
4
|
+
walking first parents back from BAD, which must descend from GOOD. Each tested commit is
|
|
5
|
+
checked out byte-for-byte into ``WORKDIR/<first 12 hex characters of its id>`` and a test
|
|
6
|
+
command runs on it through the shell: exit 0 means good, exit 1 means bad, and any other exit
|
|
7
|
+
stops the run. ``bisect`` binary-searches the candidates, assuming one good-to-bad transition;
|
|
8
|
+
``sweep`` tests every candidate in order and reports whether the curve is monotone, which is
|
|
9
|
+
how that assumption is checked. A checkout is removed after its test, and a temporary WORKDIR
|
|
10
|
+
at the end of the run, unless ``keep``.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import os
|
|
16
|
+
import shlex
|
|
17
|
+
import shutil
|
|
18
|
+
import subprocess
|
|
19
|
+
import tempfile
|
|
20
|
+
import time
|
|
21
|
+
from collections.abc import Generator
|
|
22
|
+
from contextlib import contextmanager
|
|
23
|
+
from dataclasses import asdict, dataclass
|
|
24
|
+
from itertools import pairwise
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import Literal
|
|
27
|
+
|
|
28
|
+
from wvc.checkout import checkout_commit
|
|
29
|
+
from wvc.ids import short
|
|
30
|
+
from wvc.quote import printable
|
|
31
|
+
from wvc.records import StoreError
|
|
32
|
+
from wvc.repo import log_entries
|
|
33
|
+
from wvc.store import ObjectStore
|
|
34
|
+
|
|
35
|
+
GOOD, BAD = "good", "bad"
|
|
36
|
+
RESULT_OF_EXIT = {0: GOOD, 1: BAD} # any other exit code stops the run
|
|
37
|
+
CHECKOUT_ENV, COMMIT_ENV = "WVC_CHECKOUT", "WVC_COMMIT"
|
|
38
|
+
WORKDIR_PREFIX = "wvc-bisect-"
|
|
39
|
+
STDERR_FD = 2 # where a test's standard output goes (see ``run_test``)
|
|
40
|
+
Shell = Literal["sh", "cmd"]
|
|
41
|
+
Mode = Literal["bisect", "sweep"]
|
|
42
|
+
# What cmd.exe cannot take literally inside double quotes: '"' ends them, '%' expands variables
|
|
43
|
+
# there (and '!' does when delayed expansion is on), and a line break ends the command.
|
|
44
|
+
CMD_REFUSED = frozenset('"%!\r\n')
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class BisectError(StoreError):
|
|
48
|
+
"""A bisect cannot start or go on.
|
|
49
|
+
|
|
50
|
+
Raised when BAD does not descend from GOOD, when a test exits with neither 0 nor 1, and when
|
|
51
|
+
the test command or the working directory is unusable.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(frozen=True)
|
|
56
|
+
class BisectStep:
|
|
57
|
+
"""One tested commit."""
|
|
58
|
+
|
|
59
|
+
commit: str
|
|
60
|
+
result: str # GOOD or BAD
|
|
61
|
+
exit_code: int
|
|
62
|
+
test_seconds: float
|
|
63
|
+
checkout_seconds: float
|
|
64
|
+
|
|
65
|
+
def to_dict(self) -> dict:
|
|
66
|
+
return asdict(self)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass(frozen=True)
|
|
70
|
+
class BisectResult:
|
|
71
|
+
"""What a bisect or a sweep found, with every test it ran."""
|
|
72
|
+
|
|
73
|
+
first_bad: str | None # None when every candidate is good
|
|
74
|
+
candidates: tuple[str, ...] # oldest first
|
|
75
|
+
steps: tuple[BisectStep, ...] # in the order the tests ran
|
|
76
|
+
mode: Mode
|
|
77
|
+
workdir: str | None # None when the temporary workdir was removed
|
|
78
|
+
monotone: bool | None = None # sweep only: no bad commit is followed by a good one
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def tests(self) -> int:
|
|
82
|
+
return len(self.steps)
|
|
83
|
+
|
|
84
|
+
def to_dict(self) -> dict:
|
|
85
|
+
return {
|
|
86
|
+
"mode": self.mode,
|
|
87
|
+
"first_bad": self.first_bad,
|
|
88
|
+
"tests": self.tests,
|
|
89
|
+
"monotone": self.monotone,
|
|
90
|
+
"candidates": list(self.candidates),
|
|
91
|
+
"steps": [step.to_dict() for step in self.steps],
|
|
92
|
+
"workdir": self.workdir,
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def candidates(store: ObjectStore, good_ref: str, bad_ref: str) -> list[str]:
|
|
97
|
+
"""The commits after GOOD up to and including BAD along BAD's first parents, oldest first.
|
|
98
|
+
|
|
99
|
+
BisectError when GOOD and BAD are the same commit, or when the first-parent chain from BAD
|
|
100
|
+
ends without reaching GOOD.
|
|
101
|
+
"""
|
|
102
|
+
good, bad = store.resolve(good_ref), store.resolve(bad_ref)
|
|
103
|
+
if good == bad:
|
|
104
|
+
raise BisectError(f"GOOD and BAD are the same commit {good}; BAD must descend from GOOD")
|
|
105
|
+
newest_first: list[str] = []
|
|
106
|
+
for commit_id, _ in log_entries(store, bad):
|
|
107
|
+
if commit_id == good:
|
|
108
|
+
return newest_first[::-1]
|
|
109
|
+
newest_first.append(commit_id)
|
|
110
|
+
raise BisectError(
|
|
111
|
+
f"GOOD {good_ref!r} is not an ancestor of BAD {bad_ref!r} along first parents; "
|
|
112
|
+
"BAD must descend from GOOD"
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def quote_argument(value: str, shell: Shell | None = None) -> str:
|
|
117
|
+
"""``value`` as one argument of ``shell`` (default: the platform shell, ``cmd`` on Windows).
|
|
118
|
+
|
|
119
|
+
``sh``: ``shlex.quote``. ``cmd``: always in double quotes, inside which ``cmd.exe`` takes
|
|
120
|
+
``&``, ``|``, ``<``, ``>``, ``^``, ``(``, and ``)`` literally, with trailing backslashes
|
|
121
|
+
doubled so that the program's argument parser does not read the closing quote as escaped.
|
|
122
|
+
BisectError for a value ``cmd.exe`` cannot be given: one holding ``"`` (no escape exists
|
|
123
|
+
inside quotes), ``%`` (expands even inside quotes, as ``!`` does under delayed expansion),
|
|
124
|
+
``!``, or a line break.
|
|
125
|
+
"""
|
|
126
|
+
if shell is None:
|
|
127
|
+
shell = "cmd" if os.name == "nt" else "sh"
|
|
128
|
+
if shell == "sh":
|
|
129
|
+
return shlex.quote(value)
|
|
130
|
+
if shell != "cmd":
|
|
131
|
+
raise ValueError(f"unknown shell {shell!r}; expected 'sh' or 'cmd'")
|
|
132
|
+
refused = sorted(set(value) & CMD_REFUSED)
|
|
133
|
+
if refused:
|
|
134
|
+
listed = ", ".join(repr(ch) for ch in refused)
|
|
135
|
+
raise BisectError(
|
|
136
|
+
f"cannot pass the checkout directory {printable(value)} to cmd.exe: it holds {listed}, "
|
|
137
|
+
"which cmd.exe does not take literally even inside double quotes; "
|
|
138
|
+
"choose a --workdir without them"
|
|
139
|
+
)
|
|
140
|
+
trailing = len(value) - len(value.rstrip("\\"))
|
|
141
|
+
return '"' + value + "\\" * trailing + '"'
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def run_test(command: str, checkout_dir: str | Path, commit_id: str) -> tuple[int, float]:
|
|
145
|
+
"""Run ``command`` through the shell on one checkout; return its exit code and seconds.
|
|
146
|
+
|
|
147
|
+
The command gets the parent environment plus ``WVC_CHECKOUT`` (the directory) and
|
|
148
|
+
``WVC_COMMIT`` (the id), and the directory, quoted for the platform shell by
|
|
149
|
+
``quote_argument``, as its last argument. Its standard output goes to
|
|
150
|
+
standard error, so that what wvc prints itself (such as ``--json``) stays parseable. A
|
|
151
|
+
non-zero exit code is returned, never raised.
|
|
152
|
+
"""
|
|
153
|
+
directory = str(checkout_dir)
|
|
154
|
+
env = {**os.environ, CHECKOUT_ENV: directory, COMMIT_ENV: commit_id}
|
|
155
|
+
started = time.perf_counter()
|
|
156
|
+
# The test command is the user's own, run through their shell.
|
|
157
|
+
completed = subprocess.run( # noqa: S602
|
|
158
|
+
f"{command} {quote_argument(directory)}",
|
|
159
|
+
shell=True,
|
|
160
|
+
env=env,
|
|
161
|
+
stdout=STDERR_FD,
|
|
162
|
+
check=False,
|
|
163
|
+
)
|
|
164
|
+
return completed.returncode, time.perf_counter() - started
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def bisect(
|
|
168
|
+
store: ObjectStore,
|
|
169
|
+
good_ref: str,
|
|
170
|
+
bad_ref: str,
|
|
171
|
+
command: str,
|
|
172
|
+
*,
|
|
173
|
+
workdir: str | Path | None = None,
|
|
174
|
+
keep: bool = False,
|
|
175
|
+
) -> BisectResult:
|
|
176
|
+
"""Binary-search the candidates for the first bad commit.
|
|
177
|
+
|
|
178
|
+
Invariant: the candidates below ``lo`` are good and those at or above ``hi`` are bad. ``hi``
|
|
179
|
+
starts past BAD, so BAD itself is tested when every earlier test finds good, and a run that
|
|
180
|
+
finds no bad candidate reports ``first_bad`` None. Assumes one good-to-bad transition;
|
|
181
|
+
``sweep`` checks that assumption.
|
|
182
|
+
"""
|
|
183
|
+
commits = _prepare(store, good_ref, bad_ref, command)
|
|
184
|
+
steps: list[BisectStep] = []
|
|
185
|
+
with _workspace(store, workdir, keep=keep) as root:
|
|
186
|
+
lo, hi = 0, len(commits)
|
|
187
|
+
while lo < hi:
|
|
188
|
+
mid = (lo + hi) // 2
|
|
189
|
+
step = _check_out_and_test(store, command, root, commits[mid], keep=keep)
|
|
190
|
+
steps.append(step)
|
|
191
|
+
if step.result == GOOD:
|
|
192
|
+
lo = mid + 1 # every candidate up to mid is good
|
|
193
|
+
else:
|
|
194
|
+
hi = mid # every candidate from mid on is bad
|
|
195
|
+
first_bad = commits[lo] if lo < len(commits) else None
|
|
196
|
+
workdir_left = _reported(root, workdir, keep=keep)
|
|
197
|
+
return BisectResult(first_bad, tuple(commits), tuple(steps), "bisect", workdir_left)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def sweep(
|
|
201
|
+
store: ObjectStore,
|
|
202
|
+
good_ref: str,
|
|
203
|
+
bad_ref: str,
|
|
204
|
+
command: str,
|
|
205
|
+
*,
|
|
206
|
+
workdir: str | Path | None = None,
|
|
207
|
+
keep: bool = False,
|
|
208
|
+
) -> BisectResult:
|
|
209
|
+
"""Test every candidate, oldest first.
|
|
210
|
+
|
|
211
|
+
Reports the first bad candidate and whether the curve is monotone, that is whether no bad
|
|
212
|
+
commit is followed by a good one.
|
|
213
|
+
"""
|
|
214
|
+
commits = _prepare(store, good_ref, bad_ref, command)
|
|
215
|
+
with _workspace(store, workdir, keep=keep) as root:
|
|
216
|
+
steps = tuple(
|
|
217
|
+
_check_out_and_test(store, command, root, commit_id, keep=keep) for commit_id in commits
|
|
218
|
+
)
|
|
219
|
+
first_bad = next((step.commit for step in steps if step.result == BAD), None)
|
|
220
|
+
monotone = not any(a.result == BAD and b.result == GOOD for a, b in pairwise(steps))
|
|
221
|
+
workdir_left = _reported(root, workdir, keep=keep)
|
|
222
|
+
return BisectResult(first_bad, tuple(commits), steps, "sweep", workdir_left, monotone)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _prepare(store: ObjectStore, good_ref: str, bad_ref: str, command: object) -> list[str]:
|
|
226
|
+
"""Check the test command, then return the candidates."""
|
|
227
|
+
if not isinstance(command, str) or not command.strip():
|
|
228
|
+
raise BisectError("the test command is empty")
|
|
229
|
+
return candidates(store, good_ref, bad_ref)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
@contextmanager
|
|
233
|
+
def _workspace(
|
|
234
|
+
store: ObjectStore, workdir: str | Path | None, *, keep: bool
|
|
235
|
+
) -> Generator[Path, None, None]:
|
|
236
|
+
"""The directory checkouts go into.
|
|
237
|
+
|
|
238
|
+
``workdir`` when given (created when missing, never removed), else a new temporary
|
|
239
|
+
directory, which is removed on the way out unless ``keep``. BisectError, before any
|
|
240
|
+
checkout, when the platform shell cannot be given the directory (see ``quote_argument``).
|
|
241
|
+
"""
|
|
242
|
+
if workdir is None:
|
|
243
|
+
root = Path(tempfile.mkdtemp(prefix=WORKDIR_PREFIX))
|
|
244
|
+
else:
|
|
245
|
+
root = _given_workdir(store, Path(workdir))
|
|
246
|
+
try:
|
|
247
|
+
quote_argument(str(root)) # a checkout directory adds only hex characters to this
|
|
248
|
+
yield root
|
|
249
|
+
finally:
|
|
250
|
+
if workdir is None and not keep and root.is_dir():
|
|
251
|
+
shutil.rmtree(root)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _given_workdir(store: ObjectStore, workdir: Path) -> Path:
|
|
255
|
+
"""``workdir`` made absolute and created when missing.
|
|
256
|
+
|
|
257
|
+
Refused inside the store and when it exists but is not a directory.
|
|
258
|
+
"""
|
|
259
|
+
root = workdir.absolute()
|
|
260
|
+
quote_argument(str(root)) # refused here, before the directory is created
|
|
261
|
+
if root.resolve().is_relative_to(store.dir.resolve()):
|
|
262
|
+
raise BisectError(f"refusing to check out into the store itself ({root})")
|
|
263
|
+
if os.path.lexists(root) and not root.is_dir():
|
|
264
|
+
raise BisectError(f"workdir {root} exists and is not a directory")
|
|
265
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
266
|
+
return root
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _check_out_and_test(
|
|
270
|
+
store: ObjectStore, command: str, root: Path, commit_id: str, *, keep: bool
|
|
271
|
+
) -> BisectStep:
|
|
272
|
+
"""Check out one commit into a new directory under ``root`` and run the test on it.
|
|
273
|
+
|
|
274
|
+
The directory is removed after the test unless ``keep``; then BisectError when the test
|
|
275
|
+
exited with neither 0 nor 1. A directory that already exists is refused, so that a run
|
|
276
|
+
never removes anything it did not create.
|
|
277
|
+
"""
|
|
278
|
+
directory = root / short(commit_id)
|
|
279
|
+
if os.path.lexists(directory):
|
|
280
|
+
raise BisectError(f"{directory} already exists; bisect checks out into new directories")
|
|
281
|
+
try:
|
|
282
|
+
checkout = checkout_commit(store, commit_id, directory)
|
|
283
|
+
exit_code, test_seconds = run_test(command, directory, commit_id)
|
|
284
|
+
finally:
|
|
285
|
+
if not keep and directory.is_dir():
|
|
286
|
+
shutil.rmtree(directory)
|
|
287
|
+
result = RESULT_OF_EXIT.get(exit_code)
|
|
288
|
+
if result is None:
|
|
289
|
+
raise BisectError(
|
|
290
|
+
f"the test command exited with {exit_code} on commit {commit_id}; "
|
|
291
|
+
"0 means good, 1 means bad, and any other exit stops the run"
|
|
292
|
+
)
|
|
293
|
+
return BisectStep(commit_id, result, exit_code, test_seconds, checkout.seconds)
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _reported(root: Path, workdir: str | Path | None, *, keep: bool) -> str | None:
|
|
297
|
+
"""The workdir a result names: None for a temporary one that was removed."""
|
|
298
|
+
return None if workdir is None and not keep else str(root)
|
wvc/canonical.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Canonical JSON for hashed records (spec section 3.5).
|
|
2
|
+
|
|
3
|
+
UTF-8, keys sorted, separators ``,`` and ``:`` with no whitespace, ``ensure_ascii`` off, and
|
|
4
|
+
no floats anywhere: sizes are integers and times are strings. Ids are always computed over
|
|
5
|
+
this form; files on disk may be pretty-printed.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import hashlib
|
|
11
|
+
import json
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def canonical_bytes(obj: object) -> bytes:
|
|
15
|
+
"""Serialise ``obj`` canonically; raise ValueError on a float or a non-JSON value."""
|
|
16
|
+
try:
|
|
17
|
+
text = json.dumps(obj, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
|
|
18
|
+
except (TypeError, ValueError) as exc: # unsupported type, unsortable keys, or a cycle
|
|
19
|
+
raise ValueError(f"not JSON-serialisable: {exc}") from exc
|
|
20
|
+
_reject_floats(obj, "$")
|
|
21
|
+
try:
|
|
22
|
+
return text.encode("utf-8")
|
|
23
|
+
except UnicodeEncodeError as exc: # a lone surrogate in some string
|
|
24
|
+
raise ValueError(f"not encodable as UTF-8: {exc}") from exc
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def object_id(obj: object) -> str:
|
|
28
|
+
"""Lowercase hex SHA-256 of ``canonical_bytes(obj)``."""
|
|
29
|
+
return hashlib.sha256(canonical_bytes(obj)).hexdigest()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _reject_floats(value: object, path: str) -> None:
|
|
33
|
+
"""Raise ValueError on any float or non-string key; ``path`` locates the value.
|
|
34
|
+
|
|
35
|
+
Depth-first in document order, as a recursive walk would report the first problem, but
|
|
36
|
+
with an explicit stack, so a deeply nested record cannot overflow the interpreter's stack.
|
|
37
|
+
"""
|
|
38
|
+
stack: list[tuple[bool, object, str]] = [(False, value, path)] # (is a key, item, where)
|
|
39
|
+
while stack:
|
|
40
|
+
is_key, item, where = stack.pop()
|
|
41
|
+
if is_key:
|
|
42
|
+
if not isinstance(item, str):
|
|
43
|
+
raise ValueError(f"canonical JSON needs string keys; found {item!r} at {where}")
|
|
44
|
+
continue
|
|
45
|
+
if isinstance(item, float):
|
|
46
|
+
raise ValueError(f"canonical JSON forbids floats; found {item!r} at {where}")
|
|
47
|
+
children: list[tuple[bool, object, str]] = []
|
|
48
|
+
if isinstance(item, dict):
|
|
49
|
+
for key, child in item.items():
|
|
50
|
+
children += [(True, key, where), (False, child, f"{where}.{key}")]
|
|
51
|
+
elif isinstance(item, list | tuple):
|
|
52
|
+
children = [(False, child, f"{where}[{index}]") for index, child in enumerate(item)]
|
|
53
|
+
stack.extend(reversed(children))
|
wvc/card.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Model-card front matter for a commit (spec section 5.1).
|
|
2
|
+
|
|
3
|
+
``model_card`` renders the YAML front matter for publishing a committed checkpoint on the
|
|
4
|
+
Hugging Face Hub: ``base_model`` is the Hub repo of the nearest commit on REF's first-parent
|
|
5
|
+
chain, starting with REF itself, whose source is ``hf``; the tags are ``wvc`` and
|
|
6
|
+
``wvc:op:<kind>``; comment lines after the front matter carry the manifest root and the commit
|
|
7
|
+
id. ``write_manifest`` writes the manifest exactly as ``wvc hash`` prints it, for publishing
|
|
8
|
+
as a sidecar file (conventionally ``wvc-manifest.json``).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from wvc.records import StoreError
|
|
17
|
+
from wvc.repo import log_entries
|
|
18
|
+
from wvc.sources import is_hub_repo_id
|
|
19
|
+
from wvc.store import ObjectStore, manifest_json
|
|
20
|
+
|
|
21
|
+
CARD_TAG = "wvc"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class CardResult:
|
|
26
|
+
"""The model-card front matter of one commit."""
|
|
27
|
+
|
|
28
|
+
commit_id: str
|
|
29
|
+
manifest_root: str
|
|
30
|
+
operation_kind: str
|
|
31
|
+
base_model: str | None # the nearest hf source on the first-parent chain, if any
|
|
32
|
+
yaml: str # the front matter and the comment lines, ending with a newline
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def model_card(store: ObjectStore, ref: str) -> CardResult:
|
|
36
|
+
"""The model-card front matter of commit ``ref`` (StoreError when it does not resolve)."""
|
|
37
|
+
commit_id = store.resolve(ref)
|
|
38
|
+
record = store.get_commit(commit_id)
|
|
39
|
+
root = record["manifest"]
|
|
40
|
+
store.get_manifest(root) # the card states this root, so it must be stored and valid
|
|
41
|
+
kind = record["operation"]["kind"]
|
|
42
|
+
base_model = nearest_hub_source(store, commit_id)
|
|
43
|
+
text = render_card(
|
|
44
|
+
base_model=base_model, operation_kind=kind, manifest_root=root, commit_id=commit_id
|
|
45
|
+
)
|
|
46
|
+
return CardResult(commit_id, root, kind, base_model, text)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def nearest_hub_source(store: ObjectStore, commit_id: str) -> str | None:
|
|
50
|
+
"""The ``source.repo`` of the nearest commit whose source kind is ``hf``, or None.
|
|
51
|
+
|
|
52
|
+
The search follows first parents from ``commit_id`` itself. A matching source whose repo
|
|
53
|
+
is not an ``owner/name`` Hub repo id is a StoreError rather than text put into YAML.
|
|
54
|
+
"""
|
|
55
|
+
for current_id, record in log_entries(store, commit_id):
|
|
56
|
+
source = record.get("source")
|
|
57
|
+
if isinstance(source, dict) and source.get("kind") == "hf":
|
|
58
|
+
repo = source.get("repo")
|
|
59
|
+
if not is_hub_repo_id(repo):
|
|
60
|
+
raise StoreError(f"commit {current_id}: source.repo is not OWNER/NAME")
|
|
61
|
+
return repo
|
|
62
|
+
return None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def render_card(
|
|
66
|
+
*, base_model: str | None, operation_kind: str, manifest_root: str, commit_id: str
|
|
67
|
+
) -> str:
|
|
68
|
+
"""YAML front matter (``base_model`` only when known), then the provenance comments."""
|
|
69
|
+
lines = [
|
|
70
|
+
"---",
|
|
71
|
+
*([] if base_model is None else [f"base_model: {base_model}"]),
|
|
72
|
+
"tags:",
|
|
73
|
+
f"- {CARD_TAG}",
|
|
74
|
+
f"- {CARD_TAG}:op:{operation_kind}",
|
|
75
|
+
"---",
|
|
76
|
+
"# wvc provenance",
|
|
77
|
+
f"# manifest root: {manifest_root}",
|
|
78
|
+
f"# commit: {commit_id}",
|
|
79
|
+
]
|
|
80
|
+
return "\n".join(lines) + "\n"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def write_manifest(store: ObjectStore, manifest_root: str, path: str | Path) -> Path:
|
|
84
|
+
"""Write a stored manifest to ``path`` exactly as ``wvc hash`` prints it; return the path."""
|
|
85
|
+
target = Path(path)
|
|
86
|
+
target.write_bytes(manifest_json(store.get_manifest(manifest_root)))
|
|
87
|
+
return target
|