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.
- dset_cli-0.1.0/PKG-INFO +96 -0
- dset_cli-0.1.0/README.md +89 -0
- dset_cli-0.1.0/dset/__init__.py +0 -0
- dset_cli-0.1.0/dset/cli.py +609 -0
- dset_cli-0.1.0/dset_cli.egg-info/PKG-INFO +96 -0
- dset_cli-0.1.0/dset_cli.egg-info/SOURCES.txt +9 -0
- dset_cli-0.1.0/dset_cli.egg-info/dependency_links.txt +1 -0
- dset_cli-0.1.0/dset_cli.egg-info/entry_points.txt +2 -0
- dset_cli-0.1.0/dset_cli.egg-info/top_level.txt +1 -0
- dset_cli-0.1.0/pyproject.toml +16 -0
- dset_cli-0.1.0/setup.cfg +4 -0
dset_cli-0.1.0/PKG-INFO
ADDED
|
@@ -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.
|
dset_cli-0.1.0/README.md
ADDED
|
@@ -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 @@
|
|
|
1
|
+
|
|
@@ -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*"]
|
dset_cli-0.1.0/setup.cfg
ADDED