dset-cli 0.1.0__tar.gz

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,96 @@
1
+ Metadata-Version: 2.4
2
+ Name: dset-cli
3
+ Version: 0.1.0
4
+ Summary: Version control for datasets — commit, diff, and roll back millions of files with semantic diffs.
5
+ Requires-Python: >=3.9
6
+ Description-Content-Type: text/markdown
7
+
8
+ # dset — version control for datasets
9
+
10
+ Commit, diff, and roll back millions of files the way you already do with
11
+ code — and see what actually changed: samples, classes, label distributions.
12
+ Not bytes.
13
+
14
+ Pure Python, zero dependencies, single file.
15
+
16
+ ## Install
17
+
18
+ ```
19
+ pip install .
20
+ ```
21
+
22
+ (from this folder — or just run `python dset/cli.py ...` directly)
23
+
24
+ ## Quick start
25
+
26
+ ```
27
+ cd my-dataset
28
+ dset init
29
+ dset add images/ labels.csv
30
+ dset commit "June survey, initial labels"
31
+
32
+ # ... collect more data, fix labels ...
33
+
34
+ dset add .
35
+ dset commit "July imagery + relabeling pass"
36
+
37
+ dset diff v1 v2
38
+ ```
39
+
40
+ Output:
41
+
42
+ ```
43
+ comparing v1 → v2 (301 → 406 files)
44
+
45
+ files +120 −15 ~1 modified
46
+ images +120 −15
47
+ labels ~1 modified
48
+ size 1.5 MB → 2.1 MB (+568.2 KB)
49
+
50
+ class acacia 19.7% → 38.8% (+19.1%)
51
+ class shrub 43.7% → 33.3% (−10.3%)
52
+ class bare 36.7% → 27.9% (−8.8%)
53
+ ```
54
+
55
+ Go back to any version, exactly:
56
+
57
+ ```
58
+ dset checkout v1
59
+ ```
60
+
61
+ ## Commands
62
+
63
+ | command | what it does |
64
+ |---|---|
65
+ | `dset init` | start tracking the current directory |
66
+ | `dset add <paths>` | stage files or folders (incremental — unchanged files are skipped) |
67
+ | `dset status` | staged / modified / deleted / untracked files |
68
+ | `dset commit "msg"` | snapshot as a new version (v1, v2, ...) with dataset stats |
69
+ | `dset log` | list versions with file counts and sizes |
70
+ | `dset diff <a> <b>` | semantic diff: file counts by type, size, class distribution shift |
71
+ | `dset checkout <ref>` | restore the working tree to a version (`--force` to discard changes) |
72
+
73
+ Refs can be a tag (`v3`), `HEAD`, or a commit-id prefix.
74
+
75
+ ## How it works
76
+
77
+ - **Content-addressed storage.** Every file is hashed (SHA-256) and stored
78
+ once under `.dset/objects/`, like git. A new version costs only what
79
+ changed; re-adding identical data costs nothing.
80
+ - **Commits are manifests** — JSON maps of path → hash. Checkout rebuilds
81
+ the working tree from objects, restoring and deleting as needed.
82
+ - **Semantic stats are computed at commit time** (file counts by type, total
83
+ size, class distribution parsed from CSV/TSV label files with a
84
+ `label`/`class`/`category`/`target` column), so `dset diff` is instant even
85
+ on huge datasets.
86
+ - **Safe by construction.** Objects are copied (never hard-linked), written
87
+ atomically, and stored read-only, so editing a working file can never
88
+ corrupt history. On btrfs/XFS the copy is a free copy-on-write clone.
89
+ - Uncommitted changes block `dset checkout` unless you pass `--force`.
90
+
91
+ ## Current limits (MVP)
92
+
93
+ - Class stats read CSV/TSV label files only (COCO/YOLO parsers are next).
94
+ - No remote yet — `dset push` / `dset pull` to a self-hosted server is the
95
+ next milestone.
96
+ - No `.dsetignore` yet; hidden files and dot-directories are always skipped.
@@ -0,0 +1,89 @@
1
+ # dset — version control for datasets
2
+
3
+ Commit, diff, and roll back millions of files the way you already do with
4
+ code — and see what actually changed: samples, classes, label distributions.
5
+ Not bytes.
6
+
7
+ Pure Python, zero dependencies, single file.
8
+
9
+ ## Install
10
+
11
+ ```
12
+ pip install .
13
+ ```
14
+
15
+ (from this folder — or just run `python dset/cli.py ...` directly)
16
+
17
+ ## Quick start
18
+
19
+ ```
20
+ cd my-dataset
21
+ dset init
22
+ dset add images/ labels.csv
23
+ dset commit "June survey, initial labels"
24
+
25
+ # ... collect more data, fix labels ...
26
+
27
+ dset add .
28
+ dset commit "July imagery + relabeling pass"
29
+
30
+ dset diff v1 v2
31
+ ```
32
+
33
+ Output:
34
+
35
+ ```
36
+ comparing v1 → v2 (301 → 406 files)
37
+
38
+ files +120 −15 ~1 modified
39
+ images +120 −15
40
+ labels ~1 modified
41
+ size 1.5 MB → 2.1 MB (+568.2 KB)
42
+
43
+ class acacia 19.7% → 38.8% (+19.1%)
44
+ class shrub 43.7% → 33.3% (−10.3%)
45
+ class bare 36.7% → 27.9% (−8.8%)
46
+ ```
47
+
48
+ Go back to any version, exactly:
49
+
50
+ ```
51
+ dset checkout v1
52
+ ```
53
+
54
+ ## Commands
55
+
56
+ | command | what it does |
57
+ |---|---|
58
+ | `dset init` | start tracking the current directory |
59
+ | `dset add <paths>` | stage files or folders (incremental — unchanged files are skipped) |
60
+ | `dset status` | staged / modified / deleted / untracked files |
61
+ | `dset commit "msg"` | snapshot as a new version (v1, v2, ...) with dataset stats |
62
+ | `dset log` | list versions with file counts and sizes |
63
+ | `dset diff <a> <b>` | semantic diff: file counts by type, size, class distribution shift |
64
+ | `dset checkout <ref>` | restore the working tree to a version (`--force` to discard changes) |
65
+
66
+ Refs can be a tag (`v3`), `HEAD`, or a commit-id prefix.
67
+
68
+ ## How it works
69
+
70
+ - **Content-addressed storage.** Every file is hashed (SHA-256) and stored
71
+ once under `.dset/objects/`, like git. A new version costs only what
72
+ changed; re-adding identical data costs nothing.
73
+ - **Commits are manifests** — JSON maps of path → hash. Checkout rebuilds
74
+ the working tree from objects, restoring and deleting as needed.
75
+ - **Semantic stats are computed at commit time** (file counts by type, total
76
+ size, class distribution parsed from CSV/TSV label files with a
77
+ `label`/`class`/`category`/`target` column), so `dset diff` is instant even
78
+ on huge datasets.
79
+ - **Safe by construction.** Objects are copied (never hard-linked), written
80
+ atomically, and stored read-only, so editing a working file can never
81
+ corrupt history. On btrfs/XFS the copy is a free copy-on-write clone.
82
+ - Uncommitted changes block `dset checkout` unless you pass `--force`.
83
+
84
+ ## Current limits (MVP)
85
+
86
+ - Class stats read CSV/TSV label files only (COCO/YOLO parsers are next).
87
+ - No remote yet — `dset push` / `dset pull` to a self-hosted server is the
88
+ next milestone.
89
+ - No `.dsetignore` yet; hidden files and dot-directories are always skipped.
File without changes
@@ -0,0 +1,609 @@
1
+ """dset — version control for datasets.
2
+
3
+ Content-addressed storage with semantic diffs. Zero dependencies.
4
+
5
+ Commands:
6
+ dset init start tracking the current directory
7
+ dset add <paths...> stage files or folders
8
+ dset status see what changed since the last commit
9
+ dset commit "message" snapshot the staged files as a new version
10
+ dset log list versions
11
+ dset diff <a> <b> semantic diff between two versions
12
+ dset checkout <ref> restore the working tree to a version
13
+ """
14
+
15
+ import argparse
16
+ import csv
17
+ import hashlib
18
+ import io
19
+ import json
20
+ import os
21
+ import shutil
22
+ import sys
23
+ import time
24
+ from pathlib import Path
25
+
26
+ DSET = ".dset"
27
+ CHUNK = 1024 * 1024
28
+
29
+ IMAGE_EXT = {".jpg", ".jpeg", ".png", ".tif", ".tiff", ".bmp", ".gif",
30
+ ".webp", ".heic", ".raw", ".dng", ".svg"}
31
+ LABEL_EXT = {".csv", ".tsv", ".json", ".jsonl", ".xml", ".yaml", ".yml"}
32
+ CLASS_COLUMNS = ("label", "class", "class_name", "category", "label_name", "target")
33
+ MAX_LABEL_BYTES = 100 * 1024 * 1024 # don't parse label files bigger than this
34
+
35
+
36
+ # ---------------------------------------------------------------- utilities
37
+
38
+ def _tty():
39
+ return sys.stdout.isatty() and os.environ.get("NO_COLOR") is None
40
+
41
+
42
+ def col(s, c):
43
+ codes = {"g": "32", "r": "31", "y": "33", "d": "90", "b": "1", "c": "36"}
44
+ return f"\033[{codes[c]}m{s}\033[0m" if _tty() else str(s)
45
+
46
+
47
+ def die(msg):
48
+ print(col("error: ", "r") + msg, file=sys.stderr)
49
+ sys.exit(1)
50
+
51
+
52
+ def human(n):
53
+ for unit in ("B", "KB", "MB", "GB", "TB"):
54
+ if n < 1024 or unit == "TB":
55
+ return f"{n:.1f} {unit}" if unit != "B" else f"{n} B"
56
+ n /= 1024
57
+
58
+
59
+ def category(path):
60
+ ext = Path(path).suffix.lower()
61
+ if ext in IMAGE_EXT:
62
+ return "images"
63
+ if ext in LABEL_EXT:
64
+ return "labels"
65
+ return "other"
66
+
67
+
68
+ def clone_or_copy(src, dst):
69
+ """Copy src to dst, using a copy-on-write clone when the filesystem
70
+ supports it (btrfs/xfs/APFS) — instant and space-free, but unlike a
71
+ hard link, safe against in-place edits."""
72
+ if sys.platform == "linux":
73
+ try:
74
+ import fcntl
75
+ with open(src, "rb") as s, open(dst, "wb") as d:
76
+ fcntl.ioctl(d.fileno(), 0x40049409, s.fileno()) # FICLONE
77
+ return
78
+ except OSError:
79
+ pass
80
+ shutil.copyfile(src, dst)
81
+
82
+
83
+ def hash_file(path):
84
+ h = hashlib.sha256()
85
+ with open(path, "rb") as f:
86
+ while True:
87
+ b = f.read(CHUNK)
88
+ if not b:
89
+ break
90
+ h.update(b)
91
+ return h.hexdigest()
92
+
93
+
94
+ # ---------------------------------------------------------------- repository
95
+
96
+ class Repo:
97
+ def __init__(self, root):
98
+ self.root = Path(root)
99
+ self.dir = self.root / DSET
100
+ self.objects = self.dir / "objects"
101
+ self.commits_dir = self.dir / "commits"
102
+
103
+ # -- discovery
104
+
105
+ @classmethod
106
+ def find(cls):
107
+ p = Path.cwd()
108
+ while True:
109
+ if (p / DSET).is_dir():
110
+ return cls(p)
111
+ if p.parent == p:
112
+ die(f"not a dset repository (run `dset init` first)")
113
+ p = p.parent
114
+
115
+ # -- state files
116
+
117
+ def _read_json(self, path, default):
118
+ try:
119
+ return json.loads(path.read_text())
120
+ except FileNotFoundError:
121
+ return default
122
+
123
+ @property
124
+ def index(self):
125
+ return self._read_json(self.dir / "index.json", {})
126
+
127
+ @index.setter
128
+ def index(self, value):
129
+ (self.dir / "index.json").write_text(json.dumps(value))
130
+
131
+ @property
132
+ def tags(self):
133
+ return self._read_json(self.dir / "tags.json", {})
134
+
135
+ @tags.setter
136
+ def tags(self, value):
137
+ (self.dir / "tags.json").write_text(json.dumps(value, indent=1))
138
+
139
+ @property
140
+ def head(self):
141
+ try:
142
+ return (self.dir / "HEAD").read_text().strip() or None
143
+ except FileNotFoundError:
144
+ return None
145
+
146
+ @head.setter
147
+ def head(self, value):
148
+ (self.dir / "HEAD").write_text(value or "")
149
+
150
+ # -- objects
151
+
152
+ def object_path(self, digest):
153
+ return self.objects / digest[:2] / digest[2:]
154
+
155
+ def store(self, src, digest):
156
+ dst = self.object_path(digest)
157
+ if dst.exists():
158
+ return False
159
+ dst.parent.mkdir(parents=True, exist_ok=True)
160
+ tmp = dst.with_suffix(".tmp")
161
+ clone_or_copy(src, tmp)
162
+ os.chmod(tmp, 0o444) # objects are immutable
163
+ os.replace(tmp, dst)
164
+ return True
165
+
166
+ def open_object(self, digest):
167
+ return open(self.object_path(digest), "rb")
168
+
169
+ # -- commits
170
+
171
+ def load_commit(self, cid):
172
+ return self._read_json(self.commits_dir / f"{cid}.json", None)
173
+
174
+ def resolve(self, ref):
175
+ """Resolve v3 / HEAD / commit-id-prefix to a commit id."""
176
+ if ref in ("HEAD", "head"):
177
+ if not self.head:
178
+ die("no commits yet")
179
+ return self.head
180
+ tags = self.tags
181
+ if ref in tags:
182
+ return tags[ref]
183
+ matches = [p.stem for p in self.commits_dir.glob(f"{ref}*.json")]
184
+ if len(matches) == 1:
185
+ return matches[0]
186
+ if len(matches) > 1:
187
+ die(f"ambiguous reference '{ref}'")
188
+ die(f"unknown version '{ref}' (try `dset log`)")
189
+
190
+ # -- working tree
191
+
192
+ def walk_files(self, paths=None):
193
+ """Yield relative paths of files under the given paths (or repo root)."""
194
+ roots = [self.root / p for p in paths] if paths else [self.root]
195
+ for r in roots:
196
+ r = r.resolve()
197
+ if r.is_file():
198
+ yield r.relative_to(self.root).as_posix()
199
+ continue
200
+ if not r.exists():
201
+ die(f"path not found: {r}")
202
+ for base, dirs, files in os.walk(r):
203
+ dirs[:] = [d for d in dirs if d != DSET and not d.startswith(".")]
204
+ for f in files:
205
+ if f.startswith("."):
206
+ continue
207
+ yield (Path(base) / f).relative_to(self.root).as_posix()
208
+
209
+
210
+ # ---------------------------------------------------------------- semantics
211
+
212
+ def analyze_classes(repo, manifest):
213
+ """Parse tabular label files in a manifest, return {class_name: count}."""
214
+ counts = {}
215
+ for rel, (digest, size) in manifest.items():
216
+ if Path(rel).suffix.lower() not in (".csv", ".tsv") or size > MAX_LABEL_BYTES:
217
+ continue
218
+ delim = "\t" if rel.endswith(".tsv") else ","
219
+ try:
220
+ with repo.open_object(digest) as f:
221
+ text = io.TextIOWrapper(f, encoding="utf-8", errors="replace")
222
+ reader = csv.reader(text, delimiter=delim)
223
+ header = next(reader, None)
224
+ if not header:
225
+ continue
226
+ lower = [h.strip().lower() for h in header]
227
+ idx = next((lower.index(c) for c in CLASS_COLUMNS if c in lower), None)
228
+ if idx is None:
229
+ continue
230
+ for row in reader:
231
+ if idx < len(row) and row[idx].strip():
232
+ v = row[idx].strip()
233
+ counts[v] = counts.get(v, 0) + 1
234
+ except OSError:
235
+ continue
236
+ return counts
237
+
238
+
239
+ def commit_stats(repo, manifest):
240
+ by_cat = {"images": 0, "labels": 0, "other": 0}
241
+ total = 0
242
+ for rel, (digest, size) in manifest.items():
243
+ by_cat[category(rel)] += 1
244
+ total += size
245
+ return {
246
+ "files": len(manifest),
247
+ "images": by_cat["images"],
248
+ "labels": by_cat["labels"],
249
+ "other": by_cat["other"],
250
+ "bytes": total,
251
+ "classes": analyze_classes(repo, manifest),
252
+ }
253
+
254
+
255
+ # ---------------------------------------------------------------- commands
256
+
257
+ def cmd_init(args):
258
+ root = Path.cwd()
259
+ if (root / DSET).exists():
260
+ die("already a dset repository")
261
+ for sub in ("objects", "commits"):
262
+ (root / DSET / sub).mkdir(parents=True)
263
+ Repo(root).head = ""
264
+ print(f"Initialized empty dset repository in {root / DSET}")
265
+
266
+
267
+ def cmd_add(args):
268
+ repo = Repo.find()
269
+ index = repo.index
270
+ files = sorted(set(repo.walk_files(args.paths)))
271
+ if not files:
272
+ die("no files found under the given paths")
273
+
274
+ added = reused = skipped = 0
275
+ total_bytes = 0
276
+ t0 = time.time()
277
+ for i, rel in enumerate(files, 1):
278
+ full = repo.root / rel
279
+ try:
280
+ st = full.stat()
281
+ except OSError:
282
+ continue
283
+ prev = index.get(rel)
284
+ # unchanged since last add? (same size + mtime) -> skip rehash
285
+ if prev and prev[1] == st.st_size and prev[2] == st.st_mtime_ns:
286
+ skipped += 1
287
+ else:
288
+ digest = hash_file(full)
289
+ new = repo.store(full, digest)
290
+ index[rel] = [digest, st.st_size, st.st_mtime_ns]
291
+ total_bytes += st.st_size
292
+ added += int(new)
293
+ reused += int(not new)
294
+ if i % 2000 == 0:
295
+ print(f"\r hashing {i:,}/{len(files):,} files...", end="", flush=True)
296
+ if len(files) >= 2000:
297
+ print("\r" + " " * 40 + "\r", end="")
298
+
299
+ repo.index = index
300
+ dt = time.time() - t0
301
+ parts = [f"{added:,} new"]
302
+ if reused:
303
+ parts.append(f"{reused:,} already stored")
304
+ if skipped:
305
+ parts.append(f"{skipped:,} unchanged")
306
+ print(f"Added {len(files):,} files ({', '.join(parts)}, "
307
+ f"{human(total_bytes)} in {dt:.1f}s)")
308
+
309
+
310
+ def _tree_changes(repo):
311
+ """Compare disk vs index. Returns (modified, deleted, untracked)."""
312
+ index = repo.index
313
+ on_disk = set(repo.walk_files())
314
+ tracked = set(index)
315
+ untracked = sorted(on_disk - tracked)
316
+ deleted = sorted(tracked - on_disk)
317
+ modified = []
318
+ for rel in sorted(tracked & on_disk):
319
+ st = (repo.root / rel).stat()
320
+ digest, size, mtime = index[rel]
321
+ if size != st.st_size or mtime != st.st_mtime_ns:
322
+ if hash_file(repo.root / rel) != digest:
323
+ modified.append(rel)
324
+ return modified, deleted, untracked
325
+
326
+
327
+ def _staged_changes(repo):
328
+ """Compare index vs HEAD manifest. Returns (added, removed, changed)."""
329
+ head_manifest = {}
330
+ if repo.head:
331
+ head_manifest = repo.load_commit(repo.head)["manifest"]
332
+ index = {rel: v[0] for rel, v in repo.index.items()}
333
+ head = {rel: v[0] for rel, v in head_manifest.items()}
334
+ added = sorted(set(index) - set(head))
335
+ removed = sorted(set(head) - set(index))
336
+ changed = sorted(r for r in set(index) & set(head) if index[r] != head[r])
337
+ return added, removed, changed
338
+
339
+
340
+ def _preview(items, verb, limit=8):
341
+ for rel in items[:limit]:
342
+ print(f" {verb} {rel}")
343
+ if len(items) > limit:
344
+ print(col(f" ... and {len(items) - limit:,} more", "d"))
345
+
346
+
347
+ def cmd_status(args):
348
+ repo = Repo.find()
349
+ added, removed, changed = _staged_changes(repo)
350
+ modified, deleted, untracked = _tree_changes(repo)
351
+
352
+ if repo.head:
353
+ tag = next((t for t, c in repo.tags.items() if c == repo.head), None)
354
+ print(f"On version {col(tag or repo.head[:10], 'b')}")
355
+ else:
356
+ print("No commits yet")
357
+
358
+ if added or removed or changed:
359
+ print(f"\n Staged for commit "
360
+ f"({col('+' + format(len(added), ','), 'g')} "
361
+ f"{col('-' + format(len(removed), ','), 'r')} "
362
+ f"{col('~' + format(len(changed), ','), 'y')}):")
363
+ _preview([*added[:4], *changed[:4]], "staged")
364
+ if modified:
365
+ print(f"\n Modified since `dset add` ({len(modified):,}):")
366
+ _preview(modified, "modified")
367
+ if deleted:
368
+ print(f"\n Deleted from disk but still tracked ({len(deleted):,}):")
369
+ _preview(deleted, "deleted")
370
+ if untracked:
371
+ print(f"\n Untracked ({len(untracked):,}) — use `dset add` to track:")
372
+ _preview(untracked, "")
373
+ if not any([added, removed, changed, modified, deleted, untracked]):
374
+ print("Nothing to commit, working tree clean")
375
+
376
+
377
+ def cmd_commit(args):
378
+ repo = Repo.find()
379
+ index = repo.index
380
+ if not index:
381
+ die("nothing staged (use `dset add <paths>` first)")
382
+
383
+ # drop staged entries whose files were deleted from disk
384
+ manifest = {rel: [v[0], v[1]] for rel, v in index.items()
385
+ if (repo.root / rel).exists()}
386
+
387
+ if repo.head:
388
+ prev = repo.load_commit(repo.head)["manifest"]
389
+ if manifest == prev:
390
+ die("no changes since last version")
391
+
392
+ print(" computing dataset stats...", end="", flush=True)
393
+ stats = commit_stats(repo, manifest)
394
+ print("\r" + " " * 32 + "\r", end="")
395
+
396
+ commit = {
397
+ "parent": repo.head or None,
398
+ "message": args.message,
399
+ "time": time.time(),
400
+ "manifest": manifest,
401
+ "stats": stats,
402
+ }
403
+ raw = json.dumps(commit, sort_keys=True).encode()
404
+ cid = hashlib.sha256(raw).hexdigest()
405
+ (repo.commits_dir / f"{cid}.json").write_text(json.dumps(commit))
406
+
407
+ tags = repo.tags
408
+ tag = f"v{len(tags) + 1}"
409
+ tags[tag] = cid
410
+ repo.tags = tags
411
+ repo.head = cid
412
+ repo.index = {rel: [manifest[rel][0], manifest[rel][1],
413
+ (repo.root / rel).stat().st_mtime_ns] for rel in manifest}
414
+
415
+ s = stats
416
+ print(f"{col(tag, 'b')} {args.message}")
417
+ print(f" {s['files']:,} files · {s['images']:,} images · "
418
+ f"{s['labels']:,} label files · {human(s['bytes'])}")
419
+ if s["classes"]:
420
+ top = sorted(s["classes"].items(), key=lambda kv: -kv[1])[:6]
421
+ total = sum(s["classes"].values())
422
+ dist = " / ".join(f"{k} {v / total:.0%}" for k, v in top)
423
+ print(f" classes: {dist}")
424
+
425
+
426
+ def cmd_log(args):
427
+ repo = Repo.find()
428
+ tags = repo.tags
429
+ if not tags:
430
+ print("No versions yet")
431
+ return
432
+ by_commit = {c: t for t, c in tags.items()}
433
+ cid = repo.head
434
+ while cid:
435
+ c = repo.load_commit(cid)
436
+ tag = by_commit.get(cid, cid[:10])
437
+ when = time.strftime("%Y-%m-%d %H:%M", time.localtime(c["time"]))
438
+ s = c["stats"]
439
+ mark = col(" (HEAD)", "c") if cid == repo.head else ""
440
+ print(f"{col(tag, 'b')}{mark} {when} {c['message']}")
441
+ print(col(f" {s['files']:,} files · {s['images']:,} images · "
442
+ f"{human(s['bytes'])}", "d"))
443
+ cid = c["parent"]
444
+
445
+
446
+ def _dist(classes):
447
+ total = sum(classes.values())
448
+ return {k: v / total for k, v in classes.items()} if total else {}
449
+
450
+
451
+ def cmd_diff(args):
452
+ repo = Repo.find()
453
+ a_id, b_id = repo.resolve(args.a), repo.resolve(args.b)
454
+ a, b = repo.load_commit(a_id), repo.load_commit(b_id)
455
+ am, bm = a["manifest"], b["manifest"]
456
+
457
+ a_files, b_files = set(am), set(bm)
458
+ added = b_files - a_files
459
+ removed = a_files - b_files
460
+ modified = {r for r in a_files & b_files if am[r][0] != bm[r][0]}
461
+
462
+ def by_cat(paths):
463
+ out = {"images": 0, "labels": 0, "other": 0}
464
+ for p in paths:
465
+ out[category(p)] += 1
466
+ return out
467
+
468
+ ac, rc, mc = by_cat(added), by_cat(removed), by_cat(modified)
469
+
470
+ print(f"comparing {col(args.a, 'b')} → {col(args.b, 'b')} "
471
+ + col(f"({len(am):,} → {len(bm):,} files)", "d") + "\n")
472
+
473
+ def line(name, add, rem, mod):
474
+ if not (add or rem or mod):
475
+ return
476
+ parts = []
477
+ if add:
478
+ parts.append(col(f"+{add:,}", "g"))
479
+ if rem:
480
+ parts.append(col(f"−{rem:,}", "r"))
481
+ if mod:
482
+ parts.append(col(f"~{mod:,} modified", "y"))
483
+ print(f" {name:<10} " + " ".join(parts))
484
+
485
+ line("files", len(added), len(removed), len(modified))
486
+ line("images", ac["images"], rc["images"], mc["images"])
487
+ line("labels", ac["labels"], rc["labels"], mc["labels"])
488
+ line("other", ac["other"], rc["other"], mc["other"])
489
+
490
+ da, db = a["stats"]["bytes"], b["stats"]["bytes"]
491
+ if da != db:
492
+ sign = "+" if db > da else "−"
493
+ print(f" {'size':<10} {human(da)} → {human(db)} "
494
+ + col(f"({sign}{human(abs(db - da))})", "d"))
495
+
496
+ # class distribution shift
497
+ pa, pb = _dist(a["stats"]["classes"]), _dist(b["stats"]["classes"])
498
+ names = sorted(set(pa) | set(pb),
499
+ key=lambda k: -abs(pb.get(k, 0) - pa.get(k, 0)))
500
+ shifts = [(k, pa.get(k, 0), pb.get(k, 0)) for k in names
501
+ if abs(pb.get(k, 0) - pa.get(k, 0)) >= 0.005]
502
+ if shifts:
503
+ print()
504
+ for k, x, y in shifts[:8]:
505
+ delta = y - x
506
+ arrow = col(f"{'+' if delta > 0 else '−'}{abs(delta):.1%}",
507
+ "g" if delta > 0 else "r")
508
+ print(f" {'class':<10} {k} {x:.1%} → {y:.1%} ({arrow})")
509
+ if len(shifts) > 8:
510
+ print(col(f" ... and {len(shifts) - 8} more classes shifted", "d"))
511
+
512
+ if not (added or removed or modified):
513
+ print(col(" identical", "d"))
514
+
515
+
516
+ def cmd_checkout(args):
517
+ repo = Repo.find()
518
+ cid = repo.resolve(args.ref)
519
+ target = repo.load_commit(cid)["manifest"]
520
+
521
+ if not args.force:
522
+ modified, deleted, untracked_ = _tree_changes(repo)
523
+ added, removed, changed = _staged_changes(repo)
524
+ if modified or added or removed or changed:
525
+ die("you have uncommitted changes — commit them or use --force")
526
+
527
+ current = {}
528
+ if repo.head:
529
+ current = repo.load_commit(repo.head)["manifest"]
530
+
531
+ index = repo.index
532
+ restored = removed_n = kept = 0
533
+ for rel, (digest, size) in target.items():
534
+ full = repo.root / rel
535
+ entry = index.get(rel)
536
+ if full.exists() and entry and entry[0] == digest:
537
+ kept += 1
538
+ continue
539
+ full.parent.mkdir(parents=True, exist_ok=True)
540
+ if full.exists():
541
+ full.unlink()
542
+ clone_or_copy(repo.object_path(digest), full)
543
+ os.chmod(full, 0o644) # working copies stay editable
544
+ restored += 1
545
+
546
+ for rel in set(current) - set(target):
547
+ full = repo.root / rel
548
+ if full.exists():
549
+ full.unlink()
550
+ removed_n += 1
551
+ parent = full.parent
552
+ while parent != repo.root and not any(parent.iterdir()):
553
+ parent.rmdir()
554
+ parent = parent.parent
555
+
556
+ repo.head = cid
557
+ repo.index = {rel: [d, s, (repo.root / rel).stat().st_mtime_ns]
558
+ for rel, (d, s) in target.items()}
559
+
560
+ tag = next((t for t, c in repo.tags.items() if c == cid), cid[:10])
561
+ print(f"Checked out {col(tag, 'b')} "
562
+ + col(f"({restored:,} restored, {removed_n:,} removed, "
563
+ f"{kept:,} unchanged)", "d"))
564
+
565
+
566
+ # ---------------------------------------------------------------- entrypoint
567
+
568
+ def main(argv=None):
569
+ p = argparse.ArgumentParser(
570
+ prog="dset", description="Version control for datasets.")
571
+ sub = p.add_subparsers(dest="cmd", required=True)
572
+
573
+ sub.add_parser("init", help="start tracking the current directory")
574
+
575
+ sp = sub.add_parser("add", help="stage files or folders")
576
+ sp.add_argument("paths", nargs="+")
577
+
578
+ sub.add_parser("status", help="see what changed since the last version")
579
+
580
+ sp = sub.add_parser("commit", help="snapshot staged files as a new version")
581
+ sp.add_argument("message")
582
+
583
+ sub.add_parser("log", help="list versions")
584
+
585
+ sp = sub.add_parser("diff", help="semantic diff between two versions")
586
+ sp.add_argument("a")
587
+ sp.add_argument("b")
588
+
589
+ sp = sub.add_parser("checkout", help="restore the working tree to a version")
590
+ sp.add_argument("ref")
591
+ sp.add_argument("--force", action="store_true",
592
+ help="discard uncommitted changes")
593
+
594
+ args = p.parse_args(argv)
595
+ try:
596
+ {"init": cmd_init, "add": cmd_add, "status": cmd_status,
597
+ "commit": cmd_commit, "log": cmd_log, "diff": cmd_diff,
598
+ "checkout": cmd_checkout}[args.cmd](args)
599
+ except BrokenPipeError:
600
+ os.dup2(os.open(os.devnull, os.O_WRONLY), sys.stdout.fileno())
601
+ sys.exit(0)
602
+ except KeyboardInterrupt:
603
+ print("\ninterrupted (repository state is safe — objects are "
604
+ "written atomically)", file=sys.stderr)
605
+ sys.exit(130)
606
+
607
+
608
+ if __name__ == "__main__":
609
+ main()
@@ -0,0 +1,96 @@
1
+ Metadata-Version: 2.4
2
+ Name: dset-cli
3
+ Version: 0.1.0
4
+ Summary: Version control for datasets — commit, diff, and roll back millions of files with semantic diffs.
5
+ Requires-Python: >=3.9
6
+ Description-Content-Type: text/markdown
7
+
8
+ # dset — version control for datasets
9
+
10
+ Commit, diff, and roll back millions of files the way you already do with
11
+ code — and see what actually changed: samples, classes, label distributions.
12
+ Not bytes.
13
+
14
+ Pure Python, zero dependencies, single file.
15
+
16
+ ## Install
17
+
18
+ ```
19
+ pip install .
20
+ ```
21
+
22
+ (from this folder — or just run `python dset/cli.py ...` directly)
23
+
24
+ ## Quick start
25
+
26
+ ```
27
+ cd my-dataset
28
+ dset init
29
+ dset add images/ labels.csv
30
+ dset commit "June survey, initial labels"
31
+
32
+ # ... collect more data, fix labels ...
33
+
34
+ dset add .
35
+ dset commit "July imagery + relabeling pass"
36
+
37
+ dset diff v1 v2
38
+ ```
39
+
40
+ Output:
41
+
42
+ ```
43
+ comparing v1 → v2 (301 → 406 files)
44
+
45
+ files +120 −15 ~1 modified
46
+ images +120 −15
47
+ labels ~1 modified
48
+ size 1.5 MB → 2.1 MB (+568.2 KB)
49
+
50
+ class acacia 19.7% → 38.8% (+19.1%)
51
+ class shrub 43.7% → 33.3% (−10.3%)
52
+ class bare 36.7% → 27.9% (−8.8%)
53
+ ```
54
+
55
+ Go back to any version, exactly:
56
+
57
+ ```
58
+ dset checkout v1
59
+ ```
60
+
61
+ ## Commands
62
+
63
+ | command | what it does |
64
+ |---|---|
65
+ | `dset init` | start tracking the current directory |
66
+ | `dset add <paths>` | stage files or folders (incremental — unchanged files are skipped) |
67
+ | `dset status` | staged / modified / deleted / untracked files |
68
+ | `dset commit "msg"` | snapshot as a new version (v1, v2, ...) with dataset stats |
69
+ | `dset log` | list versions with file counts and sizes |
70
+ | `dset diff <a> <b>` | semantic diff: file counts by type, size, class distribution shift |
71
+ | `dset checkout <ref>` | restore the working tree to a version (`--force` to discard changes) |
72
+
73
+ Refs can be a tag (`v3`), `HEAD`, or a commit-id prefix.
74
+
75
+ ## How it works
76
+
77
+ - **Content-addressed storage.** Every file is hashed (SHA-256) and stored
78
+ once under `.dset/objects/`, like git. A new version costs only what
79
+ changed; re-adding identical data costs nothing.
80
+ - **Commits are manifests** — JSON maps of path → hash. Checkout rebuilds
81
+ the working tree from objects, restoring and deleting as needed.
82
+ - **Semantic stats are computed at commit time** (file counts by type, total
83
+ size, class distribution parsed from CSV/TSV label files with a
84
+ `label`/`class`/`category`/`target` column), so `dset diff` is instant even
85
+ on huge datasets.
86
+ - **Safe by construction.** Objects are copied (never hard-linked), written
87
+ atomically, and stored read-only, so editing a working file can never
88
+ corrupt history. On btrfs/XFS the copy is a free copy-on-write clone.
89
+ - Uncommitted changes block `dset checkout` unless you pass `--force`.
90
+
91
+ ## Current limits (MVP)
92
+
93
+ - Class stats read CSV/TSV label files only (COCO/YOLO parsers are next).
94
+ - No remote yet — `dset push` / `dset pull` to a self-hosted server is the
95
+ next milestone.
96
+ - No `.dsetignore` yet; hidden files and dot-directories are always skipped.
@@ -0,0 +1,9 @@
1
+ README.md
2
+ pyproject.toml
3
+ dset/__init__.py
4
+ dset/cli.py
5
+ dset_cli.egg-info/PKG-INFO
6
+ dset_cli.egg-info/SOURCES.txt
7
+ dset_cli.egg-info/dependency_links.txt
8
+ dset_cli.egg-info/entry_points.txt
9
+ dset_cli.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ dset = dset.cli:main
@@ -0,0 +1 @@
1
+ dset
@@ -0,0 +1,16 @@
1
+ [project]
2
+ name = "dset-cli"
3
+ version = "0.1.0"
4
+ description = "Version control for datasets — commit, diff, and roll back millions of files with semantic diffs."
5
+ requires-python = ">=3.9"
6
+ readme = "README.md"
7
+
8
+ [project.scripts]
9
+ dset = "dset.cli:main"
10
+
11
+ [build-system]
12
+ requires = ["setuptools>=61"]
13
+ build-backend = "setuptools.build_meta"
14
+
15
+ [tool.setuptools.packages.find]
16
+ include = ["dset*"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+