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 ADDED
@@ -0,0 +1,3 @@
1
+ """wvc: version control primitives for model weights."""
2
+
3
+ __version__ = "0.1.0"
wvc/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from wvc.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
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