kvgit 0.3.3__tar.gz → 0.3.5__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.3 → kvgit-0.3.5}/PKG-INFO +6 -1
- {kvgit-0.3.3 → kvgit-0.3.5}/README.md +5 -0
- {kvgit-0.3.3 → kvgit-0.3.5}/kvgit/__init__.py +4 -2
- {kvgit-0.3.3 → kvgit-0.3.5}/kvgit/staged.py +52 -7
- {kvgit-0.3.3 → kvgit-0.3.5}/kvgit/store.py +93 -3
- {kvgit-0.3.3 → kvgit-0.3.5}/kvgit/versioned/__init__.py +2 -1
- {kvgit-0.3.3 → kvgit-0.3.5}/kvgit/versioned/kv.py +588 -60
- {kvgit-0.3.3 → kvgit-0.3.5}/kvgit/versioned/protocol.py +41 -2
- {kvgit-0.3.3 → kvgit-0.3.5}/kvgit.egg-info/PKG-INFO +6 -1
- {kvgit-0.3.3 → kvgit-0.3.5}/pyproject.toml +1 -1
- {kvgit-0.3.3 → kvgit-0.3.5}/LICENSE +0 -0
- {kvgit-0.3.3 → kvgit-0.3.5}/kvgit/codecs/__init__.py +0 -0
- {kvgit-0.3.3 → kvgit-0.3.5}/kvgit/codecs/_hash.py +0 -0
- {kvgit-0.3.3 → kvgit-0.3.5}/kvgit/codecs/base.py +0 -0
- {kvgit-0.3.3 → kvgit-0.3.5}/kvgit/codecs/numpy.py +0 -0
- {kvgit-0.3.3 → kvgit-0.3.5}/kvgit/codecs/pandas.py +0 -0
- {kvgit-0.3.3 → kvgit-0.3.5}/kvgit/codecs/pickler.py +0 -0
- {kvgit-0.3.3 → kvgit-0.3.5}/kvgit/content_types.py +0 -0
- {kvgit-0.3.3 → kvgit-0.3.5}/kvgit/encoding.py +0 -0
- {kvgit-0.3.3 → kvgit-0.3.5}/kvgit/errors.py +0 -0
- {kvgit-0.3.3 → kvgit-0.3.5}/kvgit/hamt.py +0 -0
- {kvgit-0.3.3 → kvgit-0.3.5}/kvgit/kv/__init__.py +0 -0
- {kvgit-0.3.3 → kvgit-0.3.5}/kvgit/kv/base.py +0 -0
- {kvgit-0.3.3 → kvgit-0.3.5}/kvgit/kv/composite.py +0 -0
- {kvgit-0.3.3 → kvgit-0.3.5}/kvgit/kv/disk.py +0 -0
- {kvgit-0.3.3 → kvgit-0.3.5}/kvgit/kv/indexeddb.py +0 -0
- {kvgit-0.3.3 → kvgit-0.3.5}/kvgit/kv/memory.py +0 -0
- {kvgit-0.3.3 → kvgit-0.3.5}/kvgit/namespaced.py +0 -0
- {kvgit-0.3.3 → kvgit-0.3.5}/kvgit/py.typed +0 -0
- {kvgit-0.3.3 → kvgit-0.3.5}/kvgit/versioned/base.py +0 -0
- {kvgit-0.3.3 → kvgit-0.3.5}/kvgit/versioned/helpers.py +0 -0
- {kvgit-0.3.3 → kvgit-0.3.5}/kvgit/versioned/keyset.py +0 -0
- {kvgit-0.3.3 → kvgit-0.3.5}/kvgit/versioned/merge.py +0 -0
- {kvgit-0.3.3 → kvgit-0.3.5}/kvgit.egg-info/SOURCES.txt +0 -0
- {kvgit-0.3.3 → kvgit-0.3.5}/kvgit.egg-info/dependency_links.txt +0 -0
- {kvgit-0.3.3 → kvgit-0.3.5}/kvgit.egg-info/requires.txt +0 -0
- {kvgit-0.3.3 → kvgit-0.3.5}/kvgit.egg-info/top_level.txt +0 -0
- {kvgit-0.3.3 → kvgit-0.3.5}/setup.cfg +0 -0
- {kvgit-0.3.3 → kvgit-0.3.5}/tests/test_content_types.py +0 -0
- {kvgit-0.3.3 → kvgit-0.3.5}/tests/test_delete_branches.py +0 -0
- {kvgit-0.3.3 → kvgit-0.3.5}/tests/test_hamt.py +0 -0
- {kvgit-0.3.3 → kvgit-0.3.5}/tests/test_namespaced.py +0 -0
- {kvgit-0.3.3 → kvgit-0.3.5}/tests/test_staged.py +0 -0
- {kvgit-0.3.3 → kvgit-0.3.5}/tests/test_store_factory.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: kvgit
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.5
|
|
4
4
|
Summary: Versioned key-value store with git-like commit, branch, and merge semantics.
|
|
5
5
|
Author: ashenfad
|
|
6
6
|
License: MIT
|
|
@@ -56,6 +56,7 @@ Git-style versioning for your data. Commits, branches, and merges -- backed by a
|
|
|
56
56
|
| **Dict interface** | `MutableMapping[str, Any]` -- reads and writes work like a dict |
|
|
57
57
|
| **Commits** | Immutable, content-addressable snapshots with rollback |
|
|
58
58
|
| **Branches** | Cheap forks with CAS-based optimistic concurrency |
|
|
59
|
+
| **Tags** | Immutable names for commits; a tagged commit outlives every branch that reached it, in every kvgit version |
|
|
59
60
|
| **Three-way merge** | Auto-merges non-overlapping changes; pluggable merge fns for conflicts |
|
|
60
61
|
| **Pluggable backends** | In-memory, disk (diskcache), IndexedDB (Pyodide/browser), or bring your own `KVStore` |
|
|
61
62
|
| **Chunked codecs** | Optional content-addressed dedup for large numpy arrays and pandas DataFrames -- equal buffers stored once across keys, commits, and branches |
|
|
@@ -87,6 +88,10 @@ dev.commit()
|
|
|
87
88
|
|
|
88
89
|
print(main["score"]) # 0 (main unchanged)
|
|
89
90
|
print(dev["score"]) # 999 (dev branch)
|
|
91
|
+
|
|
92
|
+
# Tag a commit by name -- immutable, and safe from garbage collection
|
|
93
|
+
main.tag("v1")
|
|
94
|
+
print(main.peek("score", tag="v1")) # 0
|
|
90
95
|
```
|
|
91
96
|
|
|
92
97
|
## Chunked codecs (numpy / pandas)
|
|
@@ -7,6 +7,7 @@ Git-style versioning for your data. Commits, branches, and merges -- backed by a
|
|
|
7
7
|
| **Dict interface** | `MutableMapping[str, Any]` -- reads and writes work like a dict |
|
|
8
8
|
| **Commits** | Immutable, content-addressable snapshots with rollback |
|
|
9
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 |
|
|
10
11
|
| **Three-way merge** | Auto-merges non-overlapping changes; pluggable merge fns for conflicts |
|
|
11
12
|
| **Pluggable backends** | In-memory, disk (diskcache), IndexedDB (Pyodide/browser), or bring your own `KVStore` |
|
|
12
13
|
| **Chunked codecs** | Optional content-addressed dedup for large numpy arrays and pandas DataFrames -- equal buffers stored once across keys, commits, and branches |
|
|
@@ -38,6 +39,10 @@ dev.commit()
|
|
|
38
39
|
|
|
39
40
|
print(main["score"]) # 0 (main unchanged)
|
|
40
41
|
print(dev["score"]) # 999 (dev branch)
|
|
42
|
+
|
|
43
|
+
# Tag a commit by name -- immutable, and safe from garbage collection
|
|
44
|
+
main.tag("v1")
|
|
45
|
+
print(main.peek("score", tag="v1")) # 0
|
|
41
46
|
```
|
|
42
47
|
|
|
43
48
|
## Chunked codecs (numpy / pandas)
|
|
@@ -4,9 +4,9 @@ from .content_types import MergeFn, counter, last_writer_wins
|
|
|
4
4
|
from .errors import ConcurrencyError, MergeConflict
|
|
5
5
|
from .namespaced import Namespaced
|
|
6
6
|
from .staged import Staged
|
|
7
|
-
from .store import delete_branches, store
|
|
7
|
+
from .store import delete_branches, delete_tags, store
|
|
8
8
|
from .versioned.kv import VersionedKV
|
|
9
|
-
from .versioned.protocol import MergeResult, Versioned
|
|
9
|
+
from .versioned.protocol import MergeResult, TagInfo, Versioned
|
|
10
10
|
|
|
11
11
|
__all__ = [
|
|
12
12
|
"ConcurrencyError",
|
|
@@ -15,10 +15,12 @@ __all__ = [
|
|
|
15
15
|
"MergeResult",
|
|
16
16
|
"Namespaced",
|
|
17
17
|
"Staged",
|
|
18
|
+
"TagInfo",
|
|
18
19
|
"Versioned",
|
|
19
20
|
"VersionedKV",
|
|
20
21
|
"counter",
|
|
21
22
|
"delete_branches",
|
|
23
|
+
"delete_tags",
|
|
22
24
|
"last_writer_wins",
|
|
23
25
|
"store",
|
|
24
26
|
]
|
|
@@ -8,7 +8,7 @@ from typing import Any
|
|
|
8
8
|
from .codecs._hash import hash_bytes
|
|
9
9
|
from .content_types import MergeFn
|
|
10
10
|
from .versioned.kv import CHUNK_PREFIX, VersionedKV
|
|
11
|
-
from .versioned.protocol import BytesMergeFn, MergeResult, Versioned
|
|
11
|
+
from .versioned.protocol import BytesMergeFn, MergeResult, TagInfo, Versioned
|
|
12
12
|
|
|
13
13
|
|
|
14
14
|
class _ChunkSink:
|
|
@@ -407,10 +407,22 @@ class Staged(MutableMapping[str, Any]):
|
|
|
407
407
|
)
|
|
408
408
|
|
|
409
409
|
def checkout(
|
|
410
|
-
self,
|
|
410
|
+
self,
|
|
411
|
+
commit_hash: str | None = None,
|
|
412
|
+
*,
|
|
413
|
+
branch: str | None = None,
|
|
414
|
+
tag: str | None = None,
|
|
411
415
|
) -> "Staged | None":
|
|
412
|
-
"""Create a new Staged at a specific commit
|
|
413
|
-
|
|
416
|
+
"""Create a new Staged at a specific commit or tag.
|
|
417
|
+
|
|
418
|
+
Pass exactly one of ``commit_hash`` or ``tag``. Returns None if
|
|
419
|
+
the commit or tag is not in the store.
|
|
420
|
+
|
|
421
|
+
The handle is a normal writable one on the caller's branch: a
|
|
422
|
+
commit from it advances that branch when it has not moved, and
|
|
423
|
+
conflicts when it has.
|
|
424
|
+
"""
|
|
425
|
+
v = self._versioned.checkout(commit_hash, branch=branch, tag=tag)
|
|
414
426
|
if v is None:
|
|
415
427
|
return None
|
|
416
428
|
return Staged(v, encoder=self._encoder, decoder=self._decoder)
|
|
@@ -430,13 +442,46 @@ class Staged(MutableMapping[str, Any]):
|
|
|
430
442
|
self._removals.clear()
|
|
431
443
|
self._cache.clear()
|
|
432
444
|
|
|
433
|
-
def peek(
|
|
434
|
-
|
|
435
|
-
|
|
445
|
+
def peek(
|
|
446
|
+
self, key: str, *, branch: str | None = None, tag: str | None = None
|
|
447
|
+
) -> Any:
|
|
448
|
+
"""Read a key from another branch, or from a tag, without switching.
|
|
449
|
+
|
|
450
|
+
Pass exactly one of ``branch`` or ``tag``. Returns None if not
|
|
451
|
+
found.
|
|
452
|
+
"""
|
|
453
|
+
raw = self._versioned.peek(key, branch=branch, tag=tag)
|
|
436
454
|
if raw is None:
|
|
437
455
|
return None
|
|
438
456
|
return self._decode(raw)
|
|
439
457
|
|
|
458
|
+
# -- Tags --
|
|
459
|
+
|
|
460
|
+
def tag(self, name: str, *, at: str | None = None, info: dict | None = None) -> str:
|
|
461
|
+
"""Name a commit permanently. Returns the commit hash tagged.
|
|
462
|
+
|
|
463
|
+
Tags the **current commit** — the last committed state — not the
|
|
464
|
+
staging buffer. Staged changes are not part of any commit yet,
|
|
465
|
+
so there is nothing there to name; commit first if you meant to
|
|
466
|
+
tag them.
|
|
467
|
+
|
|
468
|
+
Immutable: an existing name raises rather than moving. Tags keep
|
|
469
|
+
their commit's ancestry alive through garbage collection.
|
|
470
|
+
"""
|
|
471
|
+
return self._versioned.tag(name, at=at, info=info)
|
|
472
|
+
|
|
473
|
+
def tags(self) -> dict[str, str]:
|
|
474
|
+
"""Map every tag in the store to the commit it names."""
|
|
475
|
+
return self._versioned.tags()
|
|
476
|
+
|
|
477
|
+
def tag_info(self, name: str) -> TagInfo | None:
|
|
478
|
+
"""Describe one tag, or None if the store has no such tag."""
|
|
479
|
+
return self._versioned.tag_info(name)
|
|
480
|
+
|
|
481
|
+
def delete_tag(self, name: str) -> None:
|
|
482
|
+
"""Delete a tag, then sweep commits nothing else reaches."""
|
|
483
|
+
self._versioned.delete_tag(name)
|
|
484
|
+
|
|
440
485
|
def reset_to(self, commit_hash: str) -> bool:
|
|
441
486
|
"""Reset HEAD to a specific commit and clear staged changes.
|
|
442
487
|
|
|
@@ -7,7 +7,16 @@ from typing import Any, Literal
|
|
|
7
7
|
from .kv.base import KVStore
|
|
8
8
|
from .kv.memory import Memory
|
|
9
9
|
from .staged import Staged
|
|
10
|
-
from .versioned.kv import
|
|
10
|
+
from .versioned.kv import (
|
|
11
|
+
BRANCH_HEAD,
|
|
12
|
+
BRANCH_HEAD_PREV,
|
|
13
|
+
TAG_BRANCH_PREFIX,
|
|
14
|
+
TAG_INFO_KEY,
|
|
15
|
+
CorruptHeadRecoverer,
|
|
16
|
+
VersionedKV,
|
|
17
|
+
_assert_supported_version,
|
|
18
|
+
clean_orphans,
|
|
19
|
+
)
|
|
11
20
|
|
|
12
21
|
|
|
13
22
|
def _make_backend(
|
|
@@ -46,6 +55,7 @@ def store(
|
|
|
46
55
|
encoder: Callable[..., bytes] = pickle.dumps,
|
|
47
56
|
decoder: Callable[..., Any] = pickle.loads,
|
|
48
57
|
codecs: str | None = None,
|
|
58
|
+
recover_from_corrupt_head: CorruptHeadRecoverer | None = None,
|
|
49
59
|
) -> Staged:
|
|
50
60
|
"""Create a Staged store with sensible defaults.
|
|
51
61
|
|
|
@@ -62,6 +72,13 @@ def store(
|
|
|
62
72
|
``"scientific"`` — numpy/pandas chunked codecs (requires
|
|
63
73
|
numpy; install with ``pip install kvgit[scientific]``).
|
|
64
74
|
Mutually exclusive with explicit ``encoder`` / ``decoder``.
|
|
75
|
+
recover_from_corrupt_head: Optional last-resort recovery for a
|
|
76
|
+
branch whose HEAD is unresolvable *and* whose prev-HEAD
|
|
77
|
+
backup is gone. Off by default: with both gone the store
|
|
78
|
+
does not know what the branch pointed at, so the honest
|
|
79
|
+
answer is to fail rather than guess. Pass
|
|
80
|
+
``kvgit.versioned.kv.recover_by_commit_scan`` to opt into
|
|
81
|
+
the scan, having read what it can return.
|
|
65
82
|
|
|
66
83
|
Returns:
|
|
67
84
|
A ``Staged`` store instance.
|
|
@@ -85,7 +102,15 @@ def store(
|
|
|
85
102
|
|
|
86
103
|
backend = _make_backend(kind, path=path, db_name=db_name)
|
|
87
104
|
|
|
88
|
-
return Staged(
|
|
105
|
+
return Staged(
|
|
106
|
+
VersionedKV(
|
|
107
|
+
backend,
|
|
108
|
+
branch=branch,
|
|
109
|
+
recover_from_corrupt_head=recover_from_corrupt_head,
|
|
110
|
+
),
|
|
111
|
+
encoder=encoder,
|
|
112
|
+
decoder=decoder,
|
|
113
|
+
)
|
|
89
114
|
|
|
90
115
|
|
|
91
116
|
def delete_branches(
|
|
@@ -115,7 +140,10 @@ def delete_branches(
|
|
|
115
140
|
just mints a fresh empty ``main`` on next open.
|
|
116
141
|
|
|
117
142
|
One :func:`clean_orphans` sweep runs after all removals, so commits
|
|
118
|
-
that only the deleted branches referenced are reclaimed.
|
|
143
|
+
that only the deleted branches referenced are reclaimed. A tag is a
|
|
144
|
+
head under the reserved ``refs/tags/`` name, so a tagged commit
|
|
145
|
+
survives the deletion of every ordinary branch that reached it —
|
|
146
|
+
the sweep marks from it like any other head. All heads
|
|
119
147
|
(and prev-heads) are gone before the sweep, so it can't mistake a
|
|
120
148
|
doomed branch for a live one. ``min_age`` defaults to
|
|
121
149
|
``clean_orphans``'s one-hour guard (matching ``delete_branch``), so
|
|
@@ -147,6 +175,12 @@ def delete_branches(
|
|
|
147
175
|
return # nothing asked: skip the backend open and the sweep
|
|
148
176
|
backend = _make_backend(kind, path=path, db_name=db_name)
|
|
149
177
|
try:
|
|
178
|
+
# No ``VersionedKV`` is constructed here, so nothing else on this
|
|
179
|
+
# path checks the store's layout version. Checked before the
|
|
180
|
+
# first removal rather than leaving it to the sweep: a store this
|
|
181
|
+
# code cannot read must come out of the call untouched, not with
|
|
182
|
+
# its branch keys already gone.
|
|
183
|
+
_assert_supported_version(backend)
|
|
150
184
|
for name in doomed:
|
|
151
185
|
backend.remove(BRANCH_HEAD % name)
|
|
152
186
|
backend.remove(BRANCH_HEAD_PREV % name)
|
|
@@ -155,3 +189,59 @@ def delete_branches(
|
|
|
155
189
|
close = getattr(backend, "close", None)
|
|
156
190
|
if callable(close):
|
|
157
191
|
close()
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def delete_tags(
|
|
195
|
+
names: str | Iterable[str],
|
|
196
|
+
*,
|
|
197
|
+
kind: Literal["memory", "disk", "indexeddb"] = "disk",
|
|
198
|
+
path: str | None = None,
|
|
199
|
+
db_name: str = "kvgit",
|
|
200
|
+
min_age: float = 3600,
|
|
201
|
+
) -> None:
|
|
202
|
+
"""Delete tags with no branch anchor, then sweep orphans.
|
|
203
|
+
|
|
204
|
+
The tag counterpart of :func:`delete_branches`, and anchor-free for
|
|
205
|
+
the same reason: teardown should not need a handle, and a handle
|
|
206
|
+
always has a current branch to open on. A tag is stored as a branch
|
|
207
|
+
head under the reserved name ``refs/tags/<name>``, so for each name
|
|
208
|
+
that head, its ``__branch_head_prev__`` backup and its
|
|
209
|
+
``__tag_info__<name>`` record are removed, then a single
|
|
210
|
+
:func:`clean_orphans` sweep reclaims commits that only the deleted
|
|
211
|
+
tags kept alive. The backup is defensive: this code never writes one
|
|
212
|
+
for a tag, but something that treated the tag as an ordinary branch
|
|
213
|
+
could have, and a backup outliving its head would let a later tag of
|
|
214
|
+
the same name resolve to the deleted commit. A name with no tag is
|
|
215
|
+
skipped — idempotency beats a ``ValueError`` in teardown.
|
|
216
|
+
|
|
217
|
+
That sweep does not reclaim chunks; follow up with ``deep_clean`` on
|
|
218
|
+
a quiescent store if the store uses chunked codecs.
|
|
219
|
+
|
|
220
|
+
Args:
|
|
221
|
+
names: Tag names to delete — one name or an iterable of them.
|
|
222
|
+
Missing names are no-ops. (A bare string is treated as ONE
|
|
223
|
+
name, not iterated per character.)
|
|
224
|
+
kind: ``"disk"`` (default), ``"memory"``, or ``"indexeddb"``.
|
|
225
|
+
path: Required when ``kind="disk"``. Store directory.
|
|
226
|
+
db_name: IndexedDB database name (``kind="indexeddb"`` only).
|
|
227
|
+
min_age: Passed to :func:`clean_orphans` — commits younger than
|
|
228
|
+
this many seconds survive the sweep.
|
|
229
|
+
"""
|
|
230
|
+
doomed = {names} if isinstance(names, str) else set(names)
|
|
231
|
+
if not doomed:
|
|
232
|
+
return # nothing asked: skip the backend open and the sweep
|
|
233
|
+
backend = _make_backend(kind, path=path, db_name=db_name)
|
|
234
|
+
try:
|
|
235
|
+
# Same reasoning as delete_branches: no handle is constructed on
|
|
236
|
+
# this path, so the layout check has to happen here, before
|
|
237
|
+
# anything is removed.
|
|
238
|
+
_assert_supported_version(backend)
|
|
239
|
+
for name in doomed:
|
|
240
|
+
backend.remove(BRANCH_HEAD % (TAG_BRANCH_PREFIX + name))
|
|
241
|
+
backend.remove(BRANCH_HEAD_PREV % (TAG_BRANCH_PREFIX + name))
|
|
242
|
+
backend.remove(TAG_INFO_KEY % name)
|
|
243
|
+
clean_orphans(backend, min_age=min_age)
|
|
244
|
+
finally:
|
|
245
|
+
close = getattr(backend, "close", None)
|
|
246
|
+
if callable(close):
|
|
247
|
+
close()
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
"""Versioned store implementations."""
|
|
2
2
|
|
|
3
3
|
from .kv import VersionedKV
|
|
4
|
-
from .protocol import BytesMergeFn, DiffResult, MergeResult, Versioned
|
|
4
|
+
from .protocol import BytesMergeFn, DiffResult, MergeResult, TagInfo, Versioned
|
|
5
5
|
|
|
6
6
|
__all__ = [
|
|
7
7
|
"BytesMergeFn",
|
|
8
8
|
"DiffResult",
|
|
9
9
|
"MergeResult",
|
|
10
|
+
"TagInfo",
|
|
10
11
|
"Versioned",
|
|
11
12
|
"VersionedKV",
|
|
12
13
|
]
|