kvgit 0.3.8__tar.gz → 0.4.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.
- {kvgit-0.3.8 → kvgit-0.4.0}/PKG-INFO +104 -25
- kvgit-0.4.0/README.md +242 -0
- kvgit-0.4.0/kvgit/__init__.py +81 -0
- kvgit-0.4.0/kvgit/_codec.py +162 -0
- kvgit-0.4.0/kvgit/_open.py +62 -0
- {kvgit-0.3.8 → kvgit-0.4.0}/kvgit/codecs/__init__.py +7 -8
- {kvgit-0.3.8 → kvgit-0.4.0}/kvgit/codecs/base.py +3 -2
- {kvgit-0.3.8 → kvgit-0.4.0}/kvgit/content_types.py +17 -8
- kvgit-0.4.0/kvgit/errors.py +87 -0
- {kvgit-0.3.8 → kvgit-0.4.0}/kvgit/hamt.py +201 -71
- {kvgit-0.3.8 → kvgit-0.4.0}/kvgit/kv/__init__.py +7 -0
- {kvgit-0.3.8 → kvgit-0.4.0}/kvgit/kv/base.py +49 -4
- {kvgit-0.3.8 → kvgit-0.4.0}/kvgit/kv/composite.py +40 -29
- {kvgit-0.3.8 → kvgit-0.4.0}/kvgit/kv/disk.py +23 -12
- {kvgit-0.3.8 → kvgit-0.4.0}/kvgit/kv/indexeddb.py +79 -33
- {kvgit-0.3.8 → kvgit-0.4.0}/kvgit/kv/memory.py +15 -10
- kvgit-0.4.0/kvgit/kv/postgres.py +314 -0
- {kvgit-0.3.8 → kvgit-0.4.0}/kvgit/merges.py +76 -24
- {kvgit-0.3.8 → kvgit-0.4.0}/kvgit/namespaced.py +1 -1
- kvgit-0.4.0/kvgit/refs.py +159 -0
- kvgit-0.4.0/kvgit/repo.py +401 -0
- {kvgit-0.3.8 → kvgit-0.4.0}/kvgit/versioned/__init__.py +1 -3
- {kvgit-0.3.8 → kvgit-0.4.0}/kvgit/versioned/base.py +188 -63
- {kvgit-0.3.8 → kvgit-0.4.0}/kvgit/versioned/helpers.py +12 -24
- {kvgit-0.3.8 → kvgit-0.4.0}/kvgit/versioned/keyset.py +13 -4
- {kvgit-0.3.8 → kvgit-0.4.0}/kvgit/versioned/kv.py +1136 -715
- kvgit-0.4.0/kvgit/versioned/merge.py +197 -0
- kvgit-0.4.0/kvgit/versioned/protocol.py +134 -0
- kvgit-0.4.0/kvgit/worktree.py +432 -0
- {kvgit-0.3.8 → kvgit-0.4.0}/kvgit.egg-info/PKG-INFO +104 -25
- {kvgit-0.3.8 → kvgit-0.4.0}/kvgit.egg-info/SOURCES.txt +10 -4
- {kvgit-0.3.8 → kvgit-0.4.0}/kvgit.egg-info/requires.txt +6 -0
- {kvgit-0.3.8 → kvgit-0.4.0}/pyproject.toml +9 -2
- {kvgit-0.3.8 → kvgit-0.4.0}/tests/test_content_types.py +42 -27
- kvgit-0.4.0/tests/test_delete_branches.py +139 -0
- {kvgit-0.3.8 → kvgit-0.4.0}/tests/test_hamt.py +63 -0
- {kvgit-0.3.8 → kvgit-0.4.0}/tests/test_merge_policy.py +47 -54
- {kvgit-0.3.8 → kvgit-0.4.0}/tests/test_merges.py +123 -2
- {kvgit-0.3.8 → kvgit-0.4.0}/tests/test_namespaced.py +6 -11
- kvgit-0.4.0/tests/test_open.py +118 -0
- kvgit-0.4.0/tests/test_repo.py +447 -0
- kvgit-0.4.0/tests/test_storage_v4.py +398 -0
- kvgit-0.4.0/tests/test_worktree.py +655 -0
- kvgit-0.3.8/README.md +0 -168
- kvgit-0.3.8/kvgit/__init__.py +0 -43
- kvgit-0.3.8/kvgit/errors.py +0 -38
- kvgit-0.3.8/kvgit/staged.py +0 -633
- kvgit-0.3.8/kvgit/store.py +0 -247
- kvgit-0.3.8/kvgit/versioned/merge.py +0 -222
- kvgit-0.3.8/kvgit/versioned/protocol.py +0 -226
- kvgit-0.3.8/tests/test_delete_branches.py +0 -209
- kvgit-0.3.8/tests/test_staged.py +0 -536
- kvgit-0.3.8/tests/test_store_factory.py +0 -99
- {kvgit-0.3.8 → kvgit-0.4.0}/LICENSE +0 -0
- {kvgit-0.3.8 → kvgit-0.4.0}/kvgit/codecs/_hash.py +0 -0
- {kvgit-0.3.8 → kvgit-0.4.0}/kvgit/codecs/numpy.py +0 -0
- {kvgit-0.3.8 → kvgit-0.4.0}/kvgit/codecs/pandas.py +0 -0
- {kvgit-0.3.8 → kvgit-0.4.0}/kvgit/codecs/pickler.py +0 -0
- {kvgit-0.3.8 → kvgit-0.4.0}/kvgit/encoding.py +0 -0
- {kvgit-0.3.8 → kvgit-0.4.0}/kvgit/py.typed +0 -0
- {kvgit-0.3.8 → kvgit-0.4.0}/kvgit.egg-info/dependency_links.txt +0 -0
- {kvgit-0.3.8 → kvgit-0.4.0}/kvgit.egg-info/top_level.txt +0 -0
- {kvgit-0.3.8 → kvgit-0.4.0}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: kvgit
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: Versioned key-value store with git-like commit, branch, and merge semantics.
|
|
5
5
|
Author: ashenfad
|
|
6
6
|
License: MIT
|
|
@@ -35,10 +35,15 @@ Requires-Dist: pandas>=2.0; extra == "pandas"
|
|
|
35
35
|
Provides-Extra: scientific
|
|
36
36
|
Requires-Dist: numpy>=1.24; extra == "scientific"
|
|
37
37
|
Requires-Dist: pandas>=2.0; extra == "scientific"
|
|
38
|
+
Provides-Extra: postgres
|
|
39
|
+
Requires-Dist: psycopg[binary]>=3.2; extra == "postgres"
|
|
40
|
+
Requires-Dist: psycopg-pool>=3.2; extra == "postgres"
|
|
38
41
|
Provides-Extra: all
|
|
39
42
|
Requires-Dist: diskcache; extra == "all"
|
|
40
43
|
Requires-Dist: numpy>=1.24; extra == "all"
|
|
41
44
|
Requires-Dist: pandas>=2.0; extra == "all"
|
|
45
|
+
Requires-Dist: psycopg[binary]>=3.2; extra == "all"
|
|
46
|
+
Requires-Dist: psycopg-pool>=3.2; extra == "all"
|
|
42
47
|
Provides-Extra: dev
|
|
43
48
|
Requires-Dist: pytest; extra == "dev"
|
|
44
49
|
Requires-Dist: diskcache; extra == "dev"
|
|
@@ -58,14 +63,17 @@ Git-style versioning for your data. Commits, branches, and merges -- backed by a
|
|
|
58
63
|
| **Branches** | Cheap forks with CAS-based optimistic concurrency |
|
|
59
64
|
| **Tags** | Immutable names for commits; a tagged commit outlives every branch that reached it, in every kvgit version |
|
|
60
65
|
| **Three-way merge** | Auto-merges non-overlapping changes; pluggable merge fns for conflicts |
|
|
61
|
-
| **Pluggable backends** | In-memory, disk (diskcache), IndexedDB (Pyodide/browser), or bring your own `KVStore` |
|
|
62
|
-
| **
|
|
66
|
+
| **Pluggable backends** | In-memory, disk (diskcache), PostgreSQL, IndexedDB (Pyodide/browser), or bring your own `KVStore` -- any store with an atomic conditional batch |
|
|
67
|
+
| **Concurrent GC** | Garbage collection runs beside live writers, from any process sharing the store |
|
|
68
|
+
| **Content-addressed storage** | Equal values are stored once across keys, commits, and branches |
|
|
69
|
+
| **Chunked codecs** | Optional dedup *inside* large numpy arrays and pandas DataFrames -- equal buffers (slices included) stored once |
|
|
63
70
|
|
|
64
71
|
## Install
|
|
65
72
|
|
|
66
73
|
```bash
|
|
67
74
|
pip install kvgit # in-memory only
|
|
68
75
|
pip install kvgit[disk] # adds disk backend via diskcache
|
|
76
|
+
pip install kvgit[postgres] # adds PostgreSQL backend via psycopg
|
|
69
77
|
pip install kvgit[scientific] # adds chunked codecs for numpy / pandas
|
|
70
78
|
# IndexedDB backend is available automatically in Pyodide (browser) environments
|
|
71
79
|
```
|
|
@@ -75,14 +83,16 @@ pip install kvgit[scientific] # adds chunked codecs for numpy / pandas
|
|
|
75
83
|
```python
|
|
76
84
|
import kvgit
|
|
77
85
|
|
|
78
|
-
main = kvgit.
|
|
86
|
+
main = kvgit.open() # a Worktree on branch "main" of an in-memory Repo
|
|
87
|
+
repo = main.repo
|
|
79
88
|
|
|
80
89
|
main["user"] = "alice"
|
|
81
90
|
main["score"] = 0
|
|
82
91
|
main.commit()
|
|
83
92
|
|
|
84
93
|
# Branch and diverge
|
|
85
|
-
dev =
|
|
94
|
+
repo.branches.create("dev", at=main.head)
|
|
95
|
+
dev = repo.worktree("dev")
|
|
86
96
|
dev["score"] = 999
|
|
87
97
|
dev.commit()
|
|
88
98
|
|
|
@@ -90,22 +100,70 @@ print(main["score"]) # 0 (main unchanged)
|
|
|
90
100
|
print(dev["score"]) # 999 (dev branch)
|
|
91
101
|
|
|
92
102
|
# Tag a commit by name -- immutable, and safe from garbage collection
|
|
93
|
-
|
|
94
|
-
print(
|
|
103
|
+
repo.tags.create("v1", main.head)
|
|
104
|
+
print(repo.snapshot(tag="v1")["score"]) # 0
|
|
95
105
|
```
|
|
96
106
|
|
|
107
|
+
A `Repo` owns the backend and everything store-wide: branches, tags,
|
|
108
|
+
history, snapshots, garbage collection. `repo.branches` and `repo.tags`
|
|
109
|
+
are live mappings of name to commit, with `create` and `delete`, shaped
|
|
110
|
+
like pygit2's `repo.branches`. A `Worktree` is one branch
|
|
111
|
+
checked out for work -- a dict whose writes stay pending until
|
|
112
|
+
`commit()`. A `Snapshot` is a read-only dict pinned to one commit.
|
|
113
|
+
|
|
114
|
+
```python
|
|
115
|
+
from kvgit import Repo
|
|
116
|
+
from kvgit.kv.disk import Disk
|
|
117
|
+
|
|
118
|
+
with Repo(Disk("/tmp/kvgit-demo")) as on_disk:
|
|
119
|
+
wt = on_disk.worktree("main", create=True) # open, or create at the empty root
|
|
120
|
+
wt["k"] = "v"
|
|
121
|
+
print(wt.status()) # Status(updated=frozenset({'k'}), removed=frozenset())
|
|
122
|
+
wt.commit(info={"msg": "first"})
|
|
123
|
+
for c in on_disk.log(branch="main"):
|
|
124
|
+
print(c.hash[:8], c.info) # the commit, then the empty root commit
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
## For git users
|
|
128
|
+
|
|
129
|
+
| git | kvgit |
|
|
130
|
+
|---|---|
|
|
131
|
+
| `git worktree add` / `git checkout s1` | `repo.worktree("s1")` |
|
|
132
|
+
| edit, `git add` | `wt[k] = v` -- no index; everything is pending until commit |
|
|
133
|
+
| `git status` | `wt.status()` |
|
|
134
|
+
| `git commit [paths]` | `wt.commit([keys=...])` |
|
|
135
|
+
| `git restore .` | `wt.discard()` |
|
|
136
|
+
| `git reset --hard c` | `wt.reset(c)` |
|
|
137
|
+
| `git merge x` | `wt.merge(branch="x")` |
|
|
138
|
+
| `git cherry-pick c` / `git revert c` | `wt.cherry_pick(c)` / `wt.revert(c)` |
|
|
139
|
+
| `git branch`, `git tag` (list) | `list(repo.branches)`, `dict(repo.tags)` |
|
|
140
|
+
| `git branch [-D] x` | `repo.branches.create("x")` / `repo.branches.delete("x")` |
|
|
141
|
+
| `git tag [-d] v1` | `repo.tags.create("v1", c)` / `repo.tags.delete("v1")` |
|
|
142
|
+
| `git log`, `git show c`, `git diff a b`, `git merge-base a b` | `repo.log(...)`, `repo.get_commit(c)`, `repo.diff(a, b)`, `repo.merge_base(a, b)` |
|
|
143
|
+
| `git show v1:path` | `repo.snapshot(tag="v1")[key]` |
|
|
144
|
+
| `git gc [--prune=now]` | `repo.gc([min_age=0])` |
|
|
145
|
+
|
|
146
|
+
Where it differs: there is no index; several worktrees can hold one
|
|
147
|
+
branch, and their commits race and merge rather than being refused; a
|
|
148
|
+
conflicted merge raises `MergeConflict` and changes nothing (unless a
|
|
149
|
+
merge function such as `text_merge` writes markers into the value);
|
|
150
|
+
commit hashes are not git's, and there are no remotes.
|
|
151
|
+
|
|
97
152
|
## Merging
|
|
98
153
|
|
|
99
|
-
`
|
|
100
|
-
|
|
101
|
-
|
|
154
|
+
`Worktree.merge()` merges a branch, tag or commit into the worktree's
|
|
155
|
+
branch: lowest common ancestor, three-way resolve, and a two-parent
|
|
156
|
+
merge commit guarded on your own head. As in git, a branch that has not
|
|
157
|
+
moved since the fork fast-forwards instead, and `fast_forward=False`
|
|
158
|
+
writes the merge commit anyway:
|
|
102
159
|
|
|
103
160
|
```python
|
|
104
161
|
dev["score"] = 500
|
|
105
162
|
dev.commit()
|
|
106
163
|
|
|
107
|
-
result = main.merge(dev
|
|
108
|
-
print(
|
|
164
|
+
result = main.merge(branch="dev") # truthy when merged
|
|
165
|
+
print(result.strategy) # "fast_forward": main hadn't moved since dev forked
|
|
166
|
+
print(main["score"]) # 500
|
|
109
167
|
```
|
|
110
168
|
|
|
111
169
|
Overlapping changes need a merge function per key, or a `default_merge`
|
|
@@ -120,21 +178,21 @@ from kvgit import text_merge
|
|
|
120
178
|
main["notes"] = "alpha\nbeta\n"
|
|
121
179
|
main.commit()
|
|
122
180
|
|
|
123
|
-
edits =
|
|
181
|
+
repo.branches.create("edits", at=main.head)
|
|
182
|
+
edits = repo.worktree("edits")
|
|
124
183
|
edits["notes"] = "alpha\nBETA\n"
|
|
125
184
|
edits.commit()
|
|
126
185
|
|
|
127
186
|
main["notes"] = "ALPHA\nbeta\n"
|
|
128
187
|
main.commit()
|
|
129
188
|
|
|
130
|
-
main.merge(edits
|
|
189
|
+
main.merge(branch="edits", default_merge=text_merge())
|
|
131
190
|
print(main["notes"]) # "ALPHA\nBETA\n" -- both edits kept
|
|
132
191
|
```
|
|
133
192
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
already `bytes`.
|
|
193
|
+
Merge functions see decoded values, so `text_merge()` works on `str`
|
|
194
|
+
values under the default codec. `kvgit.merges.text` is the same merge
|
|
195
|
+
over raw bytes, for keys whose values already are `bytes`.
|
|
138
196
|
|
|
139
197
|
Registrations resolve most-specific-first: a key takes its exact-key
|
|
140
198
|
registration, else the longest registered prefix it starts with, else
|
|
@@ -148,6 +206,11 @@ main.set_merge_prefix("runs/", MergeChoice.OURS) # this branch owns runs/
|
|
|
148
206
|
main.set_merge_fn("runs/index", text_merge()) # except this one key
|
|
149
207
|
```
|
|
150
208
|
|
|
209
|
+
Rules can live on the `Repo` too -- `Repo(backend, merge_fns=...,
|
|
210
|
+
merge_prefixes=..., default_merge=...)` -- for every worktree it opens.
|
|
211
|
+
A worktree's own registrations layer over the repo's, and a call's
|
|
212
|
+
`merge_fns=` / `merge_prefixes=` / `default_merge=` over both.
|
|
213
|
+
|
|
151
214
|
A registration holds either a merge function or a `MergeChoice`, and the
|
|
152
215
|
two reach differently:
|
|
153
216
|
|
|
@@ -165,17 +228,33 @@ keep that side's stored value as it stands rather than rewriting it, so
|
|
|
165
228
|
the merge writes no new blob -- and if the chosen side removed the key,
|
|
166
229
|
the merge removes it.
|
|
167
230
|
|
|
168
|
-
`commit()` and `merge()` take `merge_fns=` / `merge_prefixes=` /
|
|
169
|
-
`default_merge=` for one call, layered over what is registered.
|
|
170
|
-
|
|
171
231
|
Keys both sides changed to the same bytes merge cleanly with no merge
|
|
172
232
|
function, even though each side wrote its own copy.
|
|
173
233
|
|
|
174
234
|
A `post_check(key, merged_bytes)` predicate runs over every
|
|
175
235
|
merge-produced value; returning `False` files that key as conflicted.
|
|
176
236
|
`on_conflict="abandon"` leaves the branch untouched instead of raising.
|
|
177
|
-
Merging refuses with `ValueError`
|
|
178
|
-
|
|
237
|
+
Merging refuses with `ValueError` while the worktree has pending
|
|
238
|
+
changes -- commit or `discard()` first.
|
|
239
|
+
|
|
240
|
+
`cherry_pick(c)`, `revert(c)` and the general `apply(base, target)`
|
|
241
|
+
replay one change onto the worktree's branch as an ordinary
|
|
242
|
+
single-parent commit, with the same merge options.
|
|
243
|
+
|
|
244
|
+
## Codecs and trust
|
|
245
|
+
|
|
246
|
+
A repo's codec turns values into stored bytes. `codec="pickle"` is the
|
|
247
|
+
default: it is what makes a worktree a dict of anything. But unpickling
|
|
248
|
+
can execute code, so anyone who can write a pickle-codec store -- a
|
|
249
|
+
shared Postgres table, a disk directory -- can run code in every process
|
|
250
|
+
that reads it. For a store more than one party can write, use
|
|
251
|
+
`codec="bytes"`: kvgit then never decodes anything, values must be
|
|
252
|
+
`bytes`, and you choose where (if anywhere) untrusted pickles are
|
|
253
|
+
loaded. `snapshot.raw` reads the stored bytes under any codec.
|
|
254
|
+
|
|
255
|
+
The codec is fixed per store in practice: values written under one read
|
|
256
|
+
back as that codec's bytes under another, so switching an existing
|
|
257
|
+
store means rewriting its values.
|
|
179
258
|
|
|
180
259
|
## Chunked codecs (numpy / pandas)
|
|
181
260
|
|
|
@@ -185,7 +264,7 @@ Large numpy arrays and pandas DataFrames -- and any sliced views of them -- can
|
|
|
185
264
|
import kvgit
|
|
186
265
|
import numpy as np
|
|
187
266
|
|
|
188
|
-
s = kvgit.
|
|
267
|
+
s = kvgit.open(codec="scientific")
|
|
189
268
|
|
|
190
269
|
big = np.arange(1_000_000, dtype="float64") # ~8 MB
|
|
191
270
|
s["full"] = big
|
|
@@ -199,7 +278,7 @@ Pandas DataFrames piggyback on the numpy codec via their underlying block ndarra
|
|
|
199
278
|
|
|
200
279
|
## Part of the agex stack
|
|
201
280
|
|
|
202
|
-
kvgit provides versioned agent memory in [agex](https://github.com/ashenfad/agex) with branching and rollback. It also works as a versioned backing store for [monkeyfs](https://github.com/ashenfad/monkeyfs) virtual filesystems -- pass a `
|
|
281
|
+
kvgit provides versioned agent memory in [agex](https://github.com/ashenfad/agex) with branching and rollback. It also works as a versioned backing store for [monkeyfs](https://github.com/ashenfad/monkeyfs) virtual filesystems -- pass a `Worktree` anywhere a dict is expected.
|
|
203
282
|
|
|
204
283
|
## Development
|
|
205
284
|
|
kvgit-0.4.0/README.md
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
# kvgit 🔀
|
|
2
|
+
|
|
3
|
+
Git-style versioning for your data. Commits, branches, and merges -- backed by a dict-like `MutableMapping`.
|
|
4
|
+
|
|
5
|
+
| Features | Description |
|
|
6
|
+
|---|---|
|
|
7
|
+
| **Dict interface** | `MutableMapping[str, Any]` -- reads and writes work like a dict |
|
|
8
|
+
| **Commits** | Immutable, content-addressable snapshots with rollback |
|
|
9
|
+
| **Branches** | Cheap forks with CAS-based optimistic concurrency |
|
|
10
|
+
| **Tags** | Immutable names for commits; a tagged commit outlives every branch that reached it, in every kvgit version |
|
|
11
|
+
| **Three-way merge** | Auto-merges non-overlapping changes; pluggable merge fns for conflicts |
|
|
12
|
+
| **Pluggable backends** | In-memory, disk (diskcache), PostgreSQL, IndexedDB (Pyodide/browser), or bring your own `KVStore` -- any store with an atomic conditional batch |
|
|
13
|
+
| **Concurrent GC** | Garbage collection runs beside live writers, from any process sharing the store |
|
|
14
|
+
| **Content-addressed storage** | Equal values are stored once across keys, commits, and branches |
|
|
15
|
+
| **Chunked codecs** | Optional dedup *inside* large numpy arrays and pandas DataFrames -- equal buffers (slices included) stored once |
|
|
16
|
+
|
|
17
|
+
## Install
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
pip install kvgit # in-memory only
|
|
21
|
+
pip install kvgit[disk] # adds disk backend via diskcache
|
|
22
|
+
pip install kvgit[postgres] # adds PostgreSQL backend via psycopg
|
|
23
|
+
pip install kvgit[scientific] # adds chunked codecs for numpy / pandas
|
|
24
|
+
# IndexedDB backend is available automatically in Pyodide (browser) environments
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Quick example
|
|
28
|
+
|
|
29
|
+
```python
|
|
30
|
+
import kvgit
|
|
31
|
+
|
|
32
|
+
main = kvgit.open() # a Worktree on branch "main" of an in-memory Repo
|
|
33
|
+
repo = main.repo
|
|
34
|
+
|
|
35
|
+
main["user"] = "alice"
|
|
36
|
+
main["score"] = 0
|
|
37
|
+
main.commit()
|
|
38
|
+
|
|
39
|
+
# Branch and diverge
|
|
40
|
+
repo.branches.create("dev", at=main.head)
|
|
41
|
+
dev = repo.worktree("dev")
|
|
42
|
+
dev["score"] = 999
|
|
43
|
+
dev.commit()
|
|
44
|
+
|
|
45
|
+
print(main["score"]) # 0 (main unchanged)
|
|
46
|
+
print(dev["score"]) # 999 (dev branch)
|
|
47
|
+
|
|
48
|
+
# Tag a commit by name -- immutable, and safe from garbage collection
|
|
49
|
+
repo.tags.create("v1", main.head)
|
|
50
|
+
print(repo.snapshot(tag="v1")["score"]) # 0
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
A `Repo` owns the backend and everything store-wide: branches, tags,
|
|
54
|
+
history, snapshots, garbage collection. `repo.branches` and `repo.tags`
|
|
55
|
+
are live mappings of name to commit, with `create` and `delete`, shaped
|
|
56
|
+
like pygit2's `repo.branches`. A `Worktree` is one branch
|
|
57
|
+
checked out for work -- a dict whose writes stay pending until
|
|
58
|
+
`commit()`. A `Snapshot` is a read-only dict pinned to one commit.
|
|
59
|
+
|
|
60
|
+
```python
|
|
61
|
+
from kvgit import Repo
|
|
62
|
+
from kvgit.kv.disk import Disk
|
|
63
|
+
|
|
64
|
+
with Repo(Disk("/tmp/kvgit-demo")) as on_disk:
|
|
65
|
+
wt = on_disk.worktree("main", create=True) # open, or create at the empty root
|
|
66
|
+
wt["k"] = "v"
|
|
67
|
+
print(wt.status()) # Status(updated=frozenset({'k'}), removed=frozenset())
|
|
68
|
+
wt.commit(info={"msg": "first"})
|
|
69
|
+
for c in on_disk.log(branch="main"):
|
|
70
|
+
print(c.hash[:8], c.info) # the commit, then the empty root commit
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## For git users
|
|
74
|
+
|
|
75
|
+
| git | kvgit |
|
|
76
|
+
|---|---|
|
|
77
|
+
| `git worktree add` / `git checkout s1` | `repo.worktree("s1")` |
|
|
78
|
+
| edit, `git add` | `wt[k] = v` -- no index; everything is pending until commit |
|
|
79
|
+
| `git status` | `wt.status()` |
|
|
80
|
+
| `git commit [paths]` | `wt.commit([keys=...])` |
|
|
81
|
+
| `git restore .` | `wt.discard()` |
|
|
82
|
+
| `git reset --hard c` | `wt.reset(c)` |
|
|
83
|
+
| `git merge x` | `wt.merge(branch="x")` |
|
|
84
|
+
| `git cherry-pick c` / `git revert c` | `wt.cherry_pick(c)` / `wt.revert(c)` |
|
|
85
|
+
| `git branch`, `git tag` (list) | `list(repo.branches)`, `dict(repo.tags)` |
|
|
86
|
+
| `git branch [-D] x` | `repo.branches.create("x")` / `repo.branches.delete("x")` |
|
|
87
|
+
| `git tag [-d] v1` | `repo.tags.create("v1", c)` / `repo.tags.delete("v1")` |
|
|
88
|
+
| `git log`, `git show c`, `git diff a b`, `git merge-base a b` | `repo.log(...)`, `repo.get_commit(c)`, `repo.diff(a, b)`, `repo.merge_base(a, b)` |
|
|
89
|
+
| `git show v1:path` | `repo.snapshot(tag="v1")[key]` |
|
|
90
|
+
| `git gc [--prune=now]` | `repo.gc([min_age=0])` |
|
|
91
|
+
|
|
92
|
+
Where it differs: there is no index; several worktrees can hold one
|
|
93
|
+
branch, and their commits race and merge rather than being refused; a
|
|
94
|
+
conflicted merge raises `MergeConflict` and changes nothing (unless a
|
|
95
|
+
merge function such as `text_merge` writes markers into the value);
|
|
96
|
+
commit hashes are not git's, and there are no remotes.
|
|
97
|
+
|
|
98
|
+
## Merging
|
|
99
|
+
|
|
100
|
+
`Worktree.merge()` merges a branch, tag or commit into the worktree's
|
|
101
|
+
branch: lowest common ancestor, three-way resolve, and a two-parent
|
|
102
|
+
merge commit guarded on your own head. As in git, a branch that has not
|
|
103
|
+
moved since the fork fast-forwards instead, and `fast_forward=False`
|
|
104
|
+
writes the merge commit anyway:
|
|
105
|
+
|
|
106
|
+
```python
|
|
107
|
+
dev["score"] = 500
|
|
108
|
+
dev.commit()
|
|
109
|
+
|
|
110
|
+
result = main.merge(branch="dev") # truthy when merged
|
|
111
|
+
print(result.strategy) # "fast_forward": main hadn't moved since dev forked
|
|
112
|
+
print(main["score"]) # 500
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Overlapping changes need a merge function per key, or a `default_merge`
|
|
116
|
+
fallback. `text_merge()` resolves line-oriented text with git-style
|
|
117
|
+
`<<<<<<<` markers (its `ours_label` / `theirs_label` arguments name
|
|
118
|
+
them); anything it cannot mark -- binary, non-UTF-8, oversized -- raises
|
|
119
|
+
`CantMark`, filed as an ordinary conflict:
|
|
120
|
+
|
|
121
|
+
```python
|
|
122
|
+
from kvgit import text_merge
|
|
123
|
+
|
|
124
|
+
main["notes"] = "alpha\nbeta\n"
|
|
125
|
+
main.commit()
|
|
126
|
+
|
|
127
|
+
repo.branches.create("edits", at=main.head)
|
|
128
|
+
edits = repo.worktree("edits")
|
|
129
|
+
edits["notes"] = "alpha\nBETA\n"
|
|
130
|
+
edits.commit()
|
|
131
|
+
|
|
132
|
+
main["notes"] = "ALPHA\nbeta\n"
|
|
133
|
+
main.commit()
|
|
134
|
+
|
|
135
|
+
main.merge(branch="edits", default_merge=text_merge())
|
|
136
|
+
print(main["notes"]) # "ALPHA\nBETA\n" -- both edits kept
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
Merge functions see decoded values, so `text_merge()` works on `str`
|
|
140
|
+
values under the default codec. `kvgit.merges.text` is the same merge
|
|
141
|
+
over raw bytes, for keys whose values already are `bytes`.
|
|
142
|
+
|
|
143
|
+
Registrations resolve most-specific-first: a key takes its exact-key
|
|
144
|
+
registration, else the longest registered prefix it starts with, else
|
|
145
|
+
`default_merge`. Prefixes cover keys whose names are not known when the
|
|
146
|
+
policy is set:
|
|
147
|
+
|
|
148
|
+
```python
|
|
149
|
+
from kvgit import MergeChoice, text_merge
|
|
150
|
+
|
|
151
|
+
main.set_merge_prefix("runs/", MergeChoice.OURS) # this branch owns runs/
|
|
152
|
+
main.set_merge_fn("runs/index", text_merge()) # except this one key
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
Rules can live on the `Repo` too -- `Repo(backend, merge_fns=...,
|
|
156
|
+
merge_prefixes=..., default_merge=...)` -- for every worktree it opens.
|
|
157
|
+
A worktree's own registrations layer over the repo's, and a call's
|
|
158
|
+
`merge_fns=` / `merge_prefixes=` / `default_merge=` over both.
|
|
159
|
+
|
|
160
|
+
A registration holds either a merge function or a `MergeChoice`, and the
|
|
161
|
+
two reach differently:
|
|
162
|
+
|
|
163
|
+
* A **merge function** is consulted only where both sides changed a key.
|
|
164
|
+
A change only one side made is applied as it always was.
|
|
165
|
+
* A **`MergeChoice`** is a standing policy: it hands that side every key
|
|
166
|
+
either side changed under it. So `OURS` also drops a key the other
|
|
167
|
+
side added and keeps one the other side removed -- what a branch that
|
|
168
|
+
owns a whole namespace needs. Nothing is read or decoded for those
|
|
169
|
+
keys.
|
|
170
|
+
|
|
171
|
+
`kvgit.merges.ours` and `kvgit.merges.theirs` are the merge-function
|
|
172
|
+
form, for keys where one side wins only when both sides collide. They
|
|
173
|
+
keep that side's stored value as it stands rather than rewriting it, so
|
|
174
|
+
the merge writes no new blob -- and if the chosen side removed the key,
|
|
175
|
+
the merge removes it.
|
|
176
|
+
|
|
177
|
+
Keys both sides changed to the same bytes merge cleanly with no merge
|
|
178
|
+
function, even though each side wrote its own copy.
|
|
179
|
+
|
|
180
|
+
A `post_check(key, merged_bytes)` predicate runs over every
|
|
181
|
+
merge-produced value; returning `False` files that key as conflicted.
|
|
182
|
+
`on_conflict="abandon"` leaves the branch untouched instead of raising.
|
|
183
|
+
Merging refuses with `ValueError` while the worktree has pending
|
|
184
|
+
changes -- commit or `discard()` first.
|
|
185
|
+
|
|
186
|
+
`cherry_pick(c)`, `revert(c)` and the general `apply(base, target)`
|
|
187
|
+
replay one change onto the worktree's branch as an ordinary
|
|
188
|
+
single-parent commit, with the same merge options.
|
|
189
|
+
|
|
190
|
+
## Codecs and trust
|
|
191
|
+
|
|
192
|
+
A repo's codec turns values into stored bytes. `codec="pickle"` is the
|
|
193
|
+
default: it is what makes a worktree a dict of anything. But unpickling
|
|
194
|
+
can execute code, so anyone who can write a pickle-codec store -- a
|
|
195
|
+
shared Postgres table, a disk directory -- can run code in every process
|
|
196
|
+
that reads it. For a store more than one party can write, use
|
|
197
|
+
`codec="bytes"`: kvgit then never decodes anything, values must be
|
|
198
|
+
`bytes`, and you choose where (if anywhere) untrusted pickles are
|
|
199
|
+
loaded. `snapshot.raw` reads the stored bytes under any codec.
|
|
200
|
+
|
|
201
|
+
The codec is fixed per store in practice: values written under one read
|
|
202
|
+
back as that codec's bytes under another, so switching an existing
|
|
203
|
+
store means rewriting its values.
|
|
204
|
+
|
|
205
|
+
## Chunked codecs (numpy / pandas)
|
|
206
|
+
|
|
207
|
+
Large numpy arrays and pandas DataFrames -- and any sliced views of them -- can be stored once and shared across keys, commits, and branches:
|
|
208
|
+
|
|
209
|
+
```python
|
|
210
|
+
import kvgit
|
|
211
|
+
import numpy as np
|
|
212
|
+
|
|
213
|
+
s = kvgit.open(codec="scientific")
|
|
214
|
+
|
|
215
|
+
big = np.arange(1_000_000, dtype="float64") # ~8 MB
|
|
216
|
+
s["full"] = big
|
|
217
|
+
s["head"] = big[:100_000]
|
|
218
|
+
s["tail"] = big[-100_000:]
|
|
219
|
+
s.commit()
|
|
220
|
+
# All three keys reference the same chunk on disk -- ~8 MB total, not ~24 MB.
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
Pandas DataFrames piggyback on the numpy codec via their underlying block ndarrays. See [`docs/quick-start.md`](docs/quick-start.md#storing-scientific-data-efficiently-chunked-codecs) and the [API reference](docs/api.md#chunked-codecs).
|
|
224
|
+
|
|
225
|
+
## Part of the agex stack
|
|
226
|
+
|
|
227
|
+
kvgit provides versioned agent memory in [agex](https://github.com/ashenfad/agex) with branching and rollback. It also works as a versioned backing store for [monkeyfs](https://github.com/ashenfad/monkeyfs) virtual filesystems -- pass a `Worktree` anywhere a dict is expected.
|
|
228
|
+
|
|
229
|
+
## Development
|
|
230
|
+
|
|
231
|
+
```bash
|
|
232
|
+
uv sync --extra dev
|
|
233
|
+
uv run pytest
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
## Documentation
|
|
237
|
+
|
|
238
|
+
See [`docs/`](docs/) for detailed documentation:
|
|
239
|
+
|
|
240
|
+
- [Quick Start](docs/quick-start.md) -- common patterns with runnable examples
|
|
241
|
+
- [API Reference](docs/api.md) -- full reference for all classes, methods, and types
|
|
242
|
+
- [Browser persistence (Pyodide)](docs/pyodide.md) -- choosing between the IndexedDB and OPFS-mounted-disk backends, plus the syncfs flush requirement and recommended host-side patterns
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""kvgit: versioned key-value store."""
|
|
2
|
+
|
|
3
|
+
from ._open import open
|
|
4
|
+
from .content_types import MergeFn, counter, last_writer_wins, text_merge
|
|
5
|
+
from .errors import (
|
|
6
|
+
BranchExistsError,
|
|
7
|
+
ConcurrencyError,
|
|
8
|
+
CorruptHeadError,
|
|
9
|
+
GcBusy,
|
|
10
|
+
KvgitError,
|
|
11
|
+
MergeConflict,
|
|
12
|
+
StorageVersionError,
|
|
13
|
+
TagExistsError,
|
|
14
|
+
UnknownBranchError,
|
|
15
|
+
UnknownCommitError,
|
|
16
|
+
UnknownTagError,
|
|
17
|
+
)
|
|
18
|
+
from .merges import (
|
|
19
|
+
CantMark,
|
|
20
|
+
TextMergeFn,
|
|
21
|
+
make_text_merge,
|
|
22
|
+
ours,
|
|
23
|
+
text,
|
|
24
|
+
text_merge_result,
|
|
25
|
+
theirs,
|
|
26
|
+
)
|
|
27
|
+
from .namespaced import Namespaced
|
|
28
|
+
from .refs import Branches, Tags
|
|
29
|
+
from .repo import Commit, RawSnapshot, Repo, Snapshot
|
|
30
|
+
from .versioned.kv import ROOT_COMMIT, CorruptHeadRecoverer, recover_by_commit_scan
|
|
31
|
+
from .versioned.protocol import (
|
|
32
|
+
DiffResult,
|
|
33
|
+
MergeChoice,
|
|
34
|
+
MergePolicy,
|
|
35
|
+
MergeResult,
|
|
36
|
+
TagInfo,
|
|
37
|
+
)
|
|
38
|
+
from .worktree import Status, Worktree
|
|
39
|
+
|
|
40
|
+
__all__ = [
|
|
41
|
+
"ROOT_COMMIT",
|
|
42
|
+
"BranchExistsError",
|
|
43
|
+
"Branches",
|
|
44
|
+
"CantMark",
|
|
45
|
+
"Commit",
|
|
46
|
+
"ConcurrencyError",
|
|
47
|
+
"CorruptHeadError",
|
|
48
|
+
"CorruptHeadRecoverer",
|
|
49
|
+
"DiffResult",
|
|
50
|
+
"GcBusy",
|
|
51
|
+
"KvgitError",
|
|
52
|
+
"MergeChoice",
|
|
53
|
+
"MergeConflict",
|
|
54
|
+
"MergeFn",
|
|
55
|
+
"MergePolicy",
|
|
56
|
+
"MergeResult",
|
|
57
|
+
"Namespaced",
|
|
58
|
+
"RawSnapshot",
|
|
59
|
+
"Repo",
|
|
60
|
+
"Snapshot",
|
|
61
|
+
"Status",
|
|
62
|
+
"StorageVersionError",
|
|
63
|
+
"TagExistsError",
|
|
64
|
+
"TagInfo",
|
|
65
|
+
"Tags",
|
|
66
|
+
"TextMergeFn",
|
|
67
|
+
"UnknownBranchError",
|
|
68
|
+
"UnknownCommitError",
|
|
69
|
+
"UnknownTagError",
|
|
70
|
+
"Worktree",
|
|
71
|
+
"counter",
|
|
72
|
+
"last_writer_wins",
|
|
73
|
+
"make_text_merge",
|
|
74
|
+
"open",
|
|
75
|
+
"ours",
|
|
76
|
+
"recover_by_commit_scan",
|
|
77
|
+
"text",
|
|
78
|
+
"text_merge",
|
|
79
|
+
"text_merge_result",
|
|
80
|
+
"theirs",
|
|
81
|
+
]
|