kvgit 🔀
Git-style versioning for your data. Commits, branches, and merges -- backed by a dict-like MutableMapping.
| Features | Description |
|---|---|
| Dict interface | MutableMapping[str, Any] -- reads and writes work like a dict |
| Commits | Immutable, content-addressable snapshots with rollback |
| Branches | Cheap forks with CAS-based optimistic concurrency |
| Tags | Immutable names for commits; a tagged commit outlives every branch that reached it, in every kvgit version |
| Three-way merge | Auto-merges non-overlapping changes; pluggable merge fns for conflicts |
| Pluggable backends | In-memory, disk (diskcache), IndexedDB (Pyodide/browser), or bring your own KVStore |
| Chunked codecs | Optional content-addressed dedup for large numpy arrays and pandas DataFrames -- equal buffers stored once across keys, commits, and branches |
Install
pip install kvgit # in-memory only
pip install kvgit[disk] # adds disk backend via diskcache
pip install kvgit[scientific] # adds chunked codecs for numpy / pandas
# IndexedDB backend is available automatically in Pyodide (browser) environments
Quick example
import kvgit
main = kvgit.store()
main["user"] = "alice"
main["score"] = 0
main.commit()
# Branch and diverge
dev = main.create_branch("dev")
dev["score"] = 999
dev.commit()
print(main["score"]) # 0 (main unchanged)
print(dev["score"]) # 999 (dev branch)
# Tag a commit by name -- immutable, and safe from garbage collection
main.tag("v1")
print(main.peek("score", tag="v1")) # 0
Merging
Staged.merge() merges another head -- usually another branch's HEAD --
into the current branch: lowest common ancestor, three-way resolve, and
a two-parent merge commit guarded on your own head:
dev["score"] = 500
dev.commit()
result = main.merge(dev.current_commit) # True when merged
print(main["score"]) # 500 (fast-forward: main hadn't diverged)
Overlapping changes need a merge function per key, or a default_merge
fallback. text_merge() resolves line-oriented text with git-style
<<<<<<< markers (its ours_label / theirs_label arguments name
them); anything it cannot mark -- binary, non-UTF-8, oversized -- raises
CantMark, filed as an ordinary conflict:
from kvgit import text_merge
main["notes"] = "alpha\nbeta\n"
main.commit()
edits = main.create_branch("edits")
edits["notes"] = "alpha\nBETA\n"
edits.commit()
main["notes"] = "ALPHA\nbeta\n"
main.commit()
main.merge(edits.current_commit, default_merge=text_merge())
print(main["notes"]) # "ALPHA\nBETA\n" -- both edits kept
kvgit.merges.text is the same merge one level down, over raw bytes,
for VersionedKV. A Staged decodes each side before calling a merge
function, so register text_merge() there unless the key's values are
already bytes.
Registrations resolve most-specific-first: a key takes its exact-key
registration, else the longest registered prefix it starts with, else
default_merge. Prefixes cover keys whose names are not known when the
policy is set:
from kvgit import MergeChoice, text_merge
main.set_merge_prefix("runs/", MergeChoice.OURS) # this branch owns runs/
main.set_merge_fn("runs/index", text_merge()) # except this one key
A registration holds either a merge function or a MergeChoice, and the
two reach differently:
- A merge function is consulted only where both sides changed a key. A change only one side made is applied as it always was.
- A
MergeChoiceis a standing policy: it hands that side every key either side changed under it. SoOURSalso drops a key the other side added and keeps one the other side removed -- what a branch that owns a whole namespace needs. Nothing is read or decoded for those keys.
kvgit.merges.ours and kvgit.merges.theirs are the merge-function
form, for keys where one side wins only when both sides collide. They
keep that side's stored value as it stands rather than rewriting it, so
the merge writes no new blob -- and if the chosen side removed the key,
the merge removes it.
commit() and merge() take merge_fns= / merge_prefixes= /
default_merge= for one call, layered over what is registered.
Keys both sides changed to the same bytes merge cleanly with no merge function, even though each side wrote its own copy.
A post_check(key, merged_bytes) predicate runs over every
merge-produced value; returning False files that key as conflicted.
on_conflict="abandon" leaves the branch untouched instead of raising.
Merging refuses with ValueError when the staging buffer holds
uncommitted changes -- commit or reset first.
Chunked codecs (numpy / pandas)
Large numpy arrays and pandas DataFrames -- and any sliced views of them -- can be stored once and shared across keys, commits, and branches:
import kvgit
import numpy as np
s = kvgit.store(codecs="scientific")
big = np.arange(1_000_000, dtype="float64") # ~8 MB
s["full"] = big
s["head"] = big[:100_000]
s["tail"] = big[-100_000:]
s.commit()
# All three keys reference the same chunk on disk -- ~8 MB total, not ~24 MB.
Pandas DataFrames piggyback on the numpy codec via their underlying block ndarrays. See docs/quick-start.md and the API reference.
Part of the agex stack
kvgit provides versioned agent memory in agex with branching and rollback. It also works as a versioned backing store for monkeyfs virtual filesystems -- pass a Staged instance anywhere a dict is expected.
Development
uv sync --extra dev
uv run pytest
Documentation
See docs/ for detailed documentation:
- Quick Start -- common patterns with runnable examples
- API Reference -- full reference for all classes, methods, and types
- Browser persistence (Pyodide) -- choosing between the IndexedDB and OPFS-mounted-disk backends, plus the syncfs flush requirement and recommended host-side patterns
Metadata
Release files for kvgit 0.3.9
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| kvgit-0.3.9.tar.gz | 108.6 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| kvgit-0.3.9-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 201.9 kB
Release files / kvgit-0.3.9.tar.gz
| Download URL | kvgit-0.3.9.tar.gz |
|---|---|
| Size | 108.6 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
8d02e5697294fa4d688e2d784ab78df27d8c3795ec768342d7953ea341da66f1
|
|
BLAKE2b-256 checksum How to use checksums |
9f0e8a3ac3abad835006fa754ac0d6dd481daef569fe57cde7b739b1433e9ce4
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.12.14
|
Release files / kvgit-0.3.9-py3-none-any.whl
| Download URL | kvgit-0.3.9-py3-none-any.whl |
|---|---|
| Size | 93.3 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
fb37f23fcc8eccecd7f307735e3da86bbb3360cf1c658e49339c15d692cbedb6
|
|
BLAKE2b-256 checksum How to use checksums |
a526356549505dd4f5bdbef94beec2ccc670d2901c5d85be093c62a4a4609821
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.12.14
|