kvgit 0.3.4__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.4 → kvgit-0.3.5}/PKG-INFO +6 -1
- {kvgit-0.3.4 → kvgit-0.3.5}/README.md +5 -0
- {kvgit-0.3.4 → kvgit-0.3.5}/kvgit/__init__.py +4 -2
- {kvgit-0.3.4 → kvgit-0.3.5}/kvgit/staged.py +52 -7
- {kvgit-0.3.4 → kvgit-0.3.5}/kvgit/store.py +69 -1
- {kvgit-0.3.4 → kvgit-0.3.5}/kvgit/versioned/__init__.py +2 -1
- {kvgit-0.3.4 → kvgit-0.3.5}/kvgit/versioned/kv.py +385 -36
- {kvgit-0.3.4 → kvgit-0.3.5}/kvgit/versioned/protocol.py +41 -2
- {kvgit-0.3.4 → kvgit-0.3.5}/kvgit.egg-info/PKG-INFO +6 -1
- {kvgit-0.3.4 → kvgit-0.3.5}/pyproject.toml +1 -1
- {kvgit-0.3.4 → kvgit-0.3.5}/LICENSE +0 -0
- {kvgit-0.3.4 → kvgit-0.3.5}/kvgit/codecs/__init__.py +0 -0
- {kvgit-0.3.4 → kvgit-0.3.5}/kvgit/codecs/_hash.py +0 -0
- {kvgit-0.3.4 → kvgit-0.3.5}/kvgit/codecs/base.py +0 -0
- {kvgit-0.3.4 → kvgit-0.3.5}/kvgit/codecs/numpy.py +0 -0
- {kvgit-0.3.4 → kvgit-0.3.5}/kvgit/codecs/pandas.py +0 -0
- {kvgit-0.3.4 → kvgit-0.3.5}/kvgit/codecs/pickler.py +0 -0
- {kvgit-0.3.4 → kvgit-0.3.5}/kvgit/content_types.py +0 -0
- {kvgit-0.3.4 → kvgit-0.3.5}/kvgit/encoding.py +0 -0
- {kvgit-0.3.4 → kvgit-0.3.5}/kvgit/errors.py +0 -0
- {kvgit-0.3.4 → kvgit-0.3.5}/kvgit/hamt.py +0 -0
- {kvgit-0.3.4 → kvgit-0.3.5}/kvgit/kv/__init__.py +0 -0
- {kvgit-0.3.4 → kvgit-0.3.5}/kvgit/kv/base.py +0 -0
- {kvgit-0.3.4 → kvgit-0.3.5}/kvgit/kv/composite.py +0 -0
- {kvgit-0.3.4 → kvgit-0.3.5}/kvgit/kv/disk.py +0 -0
- {kvgit-0.3.4 → kvgit-0.3.5}/kvgit/kv/indexeddb.py +0 -0
- {kvgit-0.3.4 → kvgit-0.3.5}/kvgit/kv/memory.py +0 -0
- {kvgit-0.3.4 → kvgit-0.3.5}/kvgit/namespaced.py +0 -0
- {kvgit-0.3.4 → kvgit-0.3.5}/kvgit/py.typed +0 -0
- {kvgit-0.3.4 → kvgit-0.3.5}/kvgit/versioned/base.py +0 -0
- {kvgit-0.3.4 → kvgit-0.3.5}/kvgit/versioned/helpers.py +0 -0
- {kvgit-0.3.4 → kvgit-0.3.5}/kvgit/versioned/keyset.py +0 -0
- {kvgit-0.3.4 → kvgit-0.3.5}/kvgit/versioned/merge.py +0 -0
- {kvgit-0.3.4 → kvgit-0.3.5}/kvgit.egg-info/SOURCES.txt +0 -0
- {kvgit-0.3.4 → kvgit-0.3.5}/kvgit.egg-info/dependency_links.txt +0 -0
- {kvgit-0.3.4 → kvgit-0.3.5}/kvgit.egg-info/requires.txt +0 -0
- {kvgit-0.3.4 → kvgit-0.3.5}/kvgit.egg-info/top_level.txt +0 -0
- {kvgit-0.3.4 → kvgit-0.3.5}/setup.cfg +0 -0
- {kvgit-0.3.4 → kvgit-0.3.5}/tests/test_content_types.py +0 -0
- {kvgit-0.3.4 → kvgit-0.3.5}/tests/test_delete_branches.py +0 -0
- {kvgit-0.3.4 → kvgit-0.3.5}/tests/test_hamt.py +0 -0
- {kvgit-0.3.4 → kvgit-0.3.5}/tests/test_namespaced.py +0 -0
- {kvgit-0.3.4 → kvgit-0.3.5}/tests/test_staged.py +0 -0
- {kvgit-0.3.4 → 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
|
|
|
@@ -10,8 +10,11 @@ from .staged import Staged
|
|
|
10
10
|
from .versioned.kv import (
|
|
11
11
|
BRANCH_HEAD,
|
|
12
12
|
BRANCH_HEAD_PREV,
|
|
13
|
+
TAG_BRANCH_PREFIX,
|
|
14
|
+
TAG_INFO_KEY,
|
|
13
15
|
CorruptHeadRecoverer,
|
|
14
16
|
VersionedKV,
|
|
17
|
+
_assert_supported_version,
|
|
15
18
|
clean_orphans,
|
|
16
19
|
)
|
|
17
20
|
|
|
@@ -137,7 +140,10 @@ def delete_branches(
|
|
|
137
140
|
just mints a fresh empty ``main`` on next open.
|
|
138
141
|
|
|
139
142
|
One :func:`clean_orphans` sweep runs after all removals, so commits
|
|
140
|
-
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
|
|
141
147
|
(and prev-heads) are gone before the sweep, so it can't mistake a
|
|
142
148
|
doomed branch for a live one. ``min_age`` defaults to
|
|
143
149
|
``clean_orphans``'s one-hour guard (matching ``delete_branch``), so
|
|
@@ -169,6 +175,12 @@ def delete_branches(
|
|
|
169
175
|
return # nothing asked: skip the backend open and the sweep
|
|
170
176
|
backend = _make_backend(kind, path=path, db_name=db_name)
|
|
171
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)
|
|
172
184
|
for name in doomed:
|
|
173
185
|
backend.remove(BRANCH_HEAD % name)
|
|
174
186
|
backend.remove(BRANCH_HEAD_PREV % name)
|
|
@@ -177,3 +189,59 @@ def delete_branches(
|
|
|
177
189
|
close = getattr(backend, "close", None)
|
|
178
190
|
if callable(close):
|
|
179
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
|
]
|
|
@@ -4,15 +4,23 @@ Storage layout (v3):
|
|
|
4
4
|
|
|
5
5
|
- ``__kvgit_version__`` — storage version sentinel
|
|
6
6
|
- ``__branch_head__<branch>`` — current HEAD commit hash
|
|
7
|
+
- ``__branch_head__refs/tags/<tag>`` — commit a tag names
|
|
7
8
|
- ``__branch_head_prev__<branch>`` — previous HEAD (recovery backup)
|
|
8
9
|
- ``__commit_root__<commit>`` — keyset HAMT root hash
|
|
9
10
|
- ``__parent_commit__<commit>`` — list of parent commit hashes
|
|
10
11
|
- ``__commit_time__<commit>`` — wall time the commit was created
|
|
11
12
|
- ``__info__<commit>`` — optional caller-supplied info dict
|
|
13
|
+
- ``__tag_info__<tag>`` — tag creation time + info
|
|
12
14
|
- ``kvgit:keyset:<node_hash>`` — HAMT node bytes
|
|
13
15
|
- ``kvgit:chunk:<chunk_hash>`` — content-addressed chunk bytes (v3)
|
|
14
16
|
- ``<commit_hash>:<user_key>`` — blob value bytes
|
|
15
17
|
|
|
18
|
+
A tag is deliberately not a key kind of its own. It is a branch head
|
|
19
|
+
under a reserved name, hidden from the branch API, so that reachability
|
|
20
|
+
— which every kvgit version decides by walking branch heads — keeps a
|
|
21
|
+
tagged commit alive with no knowledge of tags at all. The record beside
|
|
22
|
+
it holds only creation time and caller info, which nothing collects.
|
|
23
|
+
|
|
16
24
|
``__branch_head_prev__`` is written only after a HEAD swap succeeds, so
|
|
17
25
|
it always names a commit ``__branch_head__`` really held. Recovery reads
|
|
18
26
|
it, so a value that was never HEAD would graft onto the branch a lineage
|
|
@@ -63,6 +71,7 @@ from .base import VersionedBase
|
|
|
63
71
|
from .helpers import walk_history
|
|
64
72
|
from .keyset import Keyset, KeysetEntry, MetaEntry
|
|
65
73
|
from .merge import MergeResolution
|
|
74
|
+
from .protocol import TagInfo
|
|
66
75
|
|
|
67
76
|
PARENT_COMMIT = "__parent_commit__%s"
|
|
68
77
|
COMMIT_ROOT = "__commit_root__%s"
|
|
@@ -70,11 +79,41 @@ COMMIT_TIME = "__commit_time__%s"
|
|
|
70
79
|
BRANCH_HEAD = "__branch_head__%s"
|
|
71
80
|
BRANCH_HEAD_PREV = "__branch_head_prev__%s"
|
|
72
81
|
INFO_KEY = "__info__%s"
|
|
82
|
+
TAG_INFO_KEY = "__tag_info__%s"
|
|
83
|
+
|
|
84
|
+
TAG_BRANCH_PREFIX = "refs/tags/"
|
|
85
|
+
"""Reserved branch-name prefix a tag's commit pointer lives under.
|
|
86
|
+
|
|
87
|
+
A tag is stored as ``__branch_head__refs/tags/<name>`` — a branch head
|
|
88
|
+
in every respect except that this code hides it from the branch API.
|
|
89
|
+
That is what keeps a tagged commit alive across kvgit versions: reachability
|
|
90
|
+
is decided by walking branch heads, so *any* version, including ones
|
|
91
|
+
written before tags existed, marks a tag's commit as a root without
|
|
92
|
+
being taught anything. A separate key kind would have been invisible to
|
|
93
|
+
them, and no version stamp can retrofit the rule into code that already
|
|
94
|
+
shipped.
|
|
95
|
+
"""
|
|
73
96
|
|
|
74
97
|
CHUNK_PREFIX = "kvgit:chunk:"
|
|
75
98
|
|
|
76
99
|
STORAGE_VERSION_KEY = "__kvgit_version__"
|
|
77
100
|
STORAGE_VERSION = 3
|
|
101
|
+
"""Highest layout this code knows how to write.
|
|
102
|
+
|
|
103
|
+
Tags did not raise it. They are branch heads under a reserved name, so
|
|
104
|
+
every kvgit that walks branch heads already treats them correctly; a
|
|
105
|
+
version bump would have locked those readers out of a store they can
|
|
106
|
+
serve perfectly well, and would have protected nothing that the naming
|
|
107
|
+
does not.
|
|
108
|
+
"""
|
|
109
|
+
|
|
110
|
+
CHUNK_STORAGE_VERSION = 3
|
|
111
|
+
"""Lowest layout that can read a store containing chunks.
|
|
112
|
+
|
|
113
|
+
Named separately from :data:`STORAGE_VERSION` because it is a rule about
|
|
114
|
+
chunks, not about whatever the newest layout happens to be.
|
|
115
|
+
"""
|
|
116
|
+
|
|
78
117
|
# Lower versions accepted as input. v3 code reads v2 stores transparently
|
|
79
118
|
# and only stamps the store as v3 once a chunked write actually happens.
|
|
80
119
|
SUPPORTED_READ_VERSIONS = frozenset({2, 3})
|
|
@@ -108,6 +147,56 @@ def content_hash(
|
|
|
108
147
|
logger = logging.getLogger("kvgit")
|
|
109
148
|
|
|
110
149
|
|
|
150
|
+
def _assert_supported_version(store: KVStore) -> None:
|
|
151
|
+
"""Raise if the store's version stamp is one this code cannot read.
|
|
152
|
+
|
|
153
|
+
Read-only, and silent on an unstamped store — it answers "may this
|
|
154
|
+
code touch what is here", not "is this store initialized". Every
|
|
155
|
+
entry point that mutates a store it did not open through
|
|
156
|
+
``VersionedKV`` calls it first, the sweep above all: a store stamped
|
|
157
|
+
higher than this code understands may hold roots this code cannot
|
|
158
|
+
see, and sweeping it would delete live data.
|
|
159
|
+
"""
|
|
160
|
+
raw = store.get(STORAGE_VERSION_KEY)
|
|
161
|
+
if raw is None:
|
|
162
|
+
return
|
|
163
|
+
version = safe_loads(raw)
|
|
164
|
+
if version not in SUPPORTED_READ_VERSIONS:
|
|
165
|
+
raise ValueError(
|
|
166
|
+
f"Store has kvgit storage version {version!r}, "
|
|
167
|
+
f"this code supports {sorted(SUPPORTED_READ_VERSIONS)}. "
|
|
168
|
+
"Use a fresh store."
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _stamp_version_at_least(store: KVStore, version: int) -> None:
|
|
173
|
+
"""Raise the store's version stamp to ``version`` if it is lower.
|
|
174
|
+
|
|
175
|
+
Called from any path that writes an artifact older layouts cannot
|
|
176
|
+
handle — currently a chunk, which needs v3.
|
|
177
|
+
|
|
178
|
+
The write is a CAS against the exact bytes the decision was made
|
|
179
|
+
from, not a plain ``set``: read-then-set lets two writers stamping
|
|
180
|
+
different versions interleave so that the *lower* one lands last,
|
|
181
|
+
leaving a store whose stamp under-describes what is in it, which is
|
|
182
|
+
precisely the state older readers are willing to open. A lost CAS
|
|
183
|
+
means someone else moved the stamp, so the loop re-reads and decides
|
|
184
|
+
again. It terminates because a stamp is only ever raised: every
|
|
185
|
+
iteration either returns or observes a strictly higher value.
|
|
186
|
+
"""
|
|
187
|
+
while True:
|
|
188
|
+
raw = store.get(STORAGE_VERSION_KEY)
|
|
189
|
+
current = safe_loads(raw) if raw is not None else None
|
|
190
|
+
if (
|
|
191
|
+
isinstance(current, int)
|
|
192
|
+
and not isinstance(current, bool)
|
|
193
|
+
and current >= version
|
|
194
|
+
):
|
|
195
|
+
return
|
|
196
|
+
if store.cas(STORAGE_VERSION_KEY, dumps(version), expected=raw):
|
|
197
|
+
return
|
|
198
|
+
|
|
199
|
+
|
|
111
200
|
def _check_storage_version(store: KVStore) -> None:
|
|
112
201
|
"""Verify the store's kvgit version is compatible.
|
|
113
202
|
|
|
@@ -115,18 +204,12 @@ def _check_storage_version(store: KVStore) -> None:
|
|
|
115
204
|
:data:`SUPPORTED_READ_VERSIONS`; the on-disk stamp is left
|
|
116
205
|
untouched on open so that opening a v2 store with v3 code does not
|
|
117
206
|
silently upgrade it to v3 (which would lock out older readers).
|
|
118
|
-
The upgrade happens lazily
|
|
119
|
-
|
|
207
|
+
The upgrade happens lazily, the first time a chunked write or a tag
|
|
208
|
+
write actually occurs.
|
|
120
209
|
"""
|
|
121
210
|
raw = store.get(STORAGE_VERSION_KEY)
|
|
122
211
|
if raw is not None:
|
|
123
|
-
|
|
124
|
-
if version not in SUPPORTED_READ_VERSIONS:
|
|
125
|
-
raise ValueError(
|
|
126
|
-
f"Store has kvgit storage version {version!r}, "
|
|
127
|
-
f"this code supports {sorted(SUPPORTED_READ_VERSIONS)}. "
|
|
128
|
-
"Use a fresh store."
|
|
129
|
-
)
|
|
212
|
+
_assert_supported_version(store)
|
|
130
213
|
return
|
|
131
214
|
|
|
132
215
|
# No version sentinel. Either fresh, or pre-v2.
|
|
@@ -465,12 +548,120 @@ def repair_head(
|
|
|
465
548
|
)
|
|
466
549
|
|
|
467
550
|
|
|
551
|
+
def _validate_tag_name(name: str) -> None:
|
|
552
|
+
"""Reject tag names that cannot be stored, or cannot be read back.
|
|
553
|
+
|
|
554
|
+
Branch names are unvalidated, so this is deliberately close to
|
|
555
|
+
unvalidated too: any non-empty string, ``/`` included, so embedders
|
|
556
|
+
can namespace their own tags (``pub/v1``). ``%`` is the one
|
|
557
|
+
exclusion — tag keys are built with ``%``-formatting, and a name
|
|
558
|
+
carrying its own format specifier turns the key template into
|
|
559
|
+
something other than a template.
|
|
560
|
+
"""
|
|
561
|
+
if not isinstance(name, str) or not name:
|
|
562
|
+
raise ValueError("Tag name must be a non-empty string")
|
|
563
|
+
if "%" in name:
|
|
564
|
+
raise ValueError(f"Tag name must not contain '%': {name!r}")
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
def _tag_branch(name: str) -> str:
|
|
568
|
+
"""The reserved branch name a tag's commit pointer lives under."""
|
|
569
|
+
return TAG_BRANCH_PREFIX + name
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
def _reject_reserved_branch(name: str) -> None:
|
|
573
|
+
"""Refuse a branch name inside the reserved tag namespace.
|
|
574
|
+
|
|
575
|
+
The branch API does not hand out names under ``refs/tags/``, because
|
|
576
|
+
a branch there would be indistinguishable from a tag: it would show
|
|
577
|
+
up in ``tags()``, and moving it would silently move the tag. Tags
|
|
578
|
+
are created, listed and deleted through the tag API instead.
|
|
579
|
+
"""
|
|
580
|
+
if isinstance(name, str) and name.startswith(TAG_BRANCH_PREFIX):
|
|
581
|
+
raise ValueError(
|
|
582
|
+
f"Branch name {name!r} is reserved for tags "
|
|
583
|
+
f"(the '{TAG_BRANCH_PREFIX}' namespace). "
|
|
584
|
+
"Use tag() / tags() / delete_tag() instead."
|
|
585
|
+
)
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
def _resolve_tag(store: KVStore, name: str) -> str | None:
|
|
589
|
+
"""Read the commit a tag names, or None if it does not resolve.
|
|
590
|
+
|
|
591
|
+
Reads the tag's head key and nothing else. The prev-HEAD recovery
|
|
592
|
+
tiers a branch gets do not apply: a tag is written once and never
|
|
593
|
+
moved, so this code never creates a backup for one, and a backup
|
|
594
|
+
left by something that treated the tag as an ordinary branch
|
|
595
|
+
describes a move that is not ours to honour.
|
|
596
|
+
"""
|
|
597
|
+
raw = store.get(BRANCH_HEAD % _tag_branch(name))
|
|
598
|
+
if raw is None:
|
|
599
|
+
return None
|
|
600
|
+
commit_hash = safe_loads(raw)
|
|
601
|
+
return commit_hash if isinstance(commit_hash, str) else None
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
def tags(store: KVStore) -> dict[str, str]:
|
|
605
|
+
"""Map every tag in the store to the commit it names.
|
|
606
|
+
|
|
607
|
+
Dangling tags — ones whose commit is no longer in the store — are
|
|
608
|
+
included, because leaving them out would make a damaged tag look
|
|
609
|
+
deleted. :func:`tag_info` says which is which.
|
|
610
|
+
"""
|
|
611
|
+
prefix = BRANCH_HEAD % TAG_BRANCH_PREFIX
|
|
612
|
+
found: dict[str, str] = {}
|
|
613
|
+
for key in store.keys():
|
|
614
|
+
if not (isinstance(key, str) and key.startswith(prefix)):
|
|
615
|
+
continue
|
|
616
|
+
name = key[len(prefix) :]
|
|
617
|
+
if not name:
|
|
618
|
+
continue
|
|
619
|
+
raw = store.get(key)
|
|
620
|
+
if raw is None:
|
|
621
|
+
continue
|
|
622
|
+
commit_hash = safe_loads(raw)
|
|
623
|
+
if isinstance(commit_hash, str):
|
|
624
|
+
found[name] = commit_hash
|
|
625
|
+
return dict(sorted(found.items()))
|
|
626
|
+
|
|
627
|
+
|
|
628
|
+
def tag_info(store: KVStore, name: str) -> TagInfo | None:
|
|
629
|
+
"""Describe one tag, or None if the store has no such tag."""
|
|
630
|
+
commit_hash = _resolve_tag(store, name)
|
|
631
|
+
if commit_hash is None:
|
|
632
|
+
return None
|
|
633
|
+
|
|
634
|
+
created: float | None = None
|
|
635
|
+
info: dict | None = None
|
|
636
|
+
record_bytes = store.get(TAG_INFO_KEY % name)
|
|
637
|
+
if record_bytes is not None:
|
|
638
|
+
record = safe_loads(record_bytes)
|
|
639
|
+
if isinstance(record, dict):
|
|
640
|
+
ts = record.get("time")
|
|
641
|
+
if isinstance(ts, (int, float)) and not isinstance(ts, bool):
|
|
642
|
+
created = float(ts)
|
|
643
|
+
stored_info = record.get("info")
|
|
644
|
+
if isinstance(stored_info, dict):
|
|
645
|
+
info = stored_info
|
|
646
|
+
|
|
647
|
+
return TagInfo(
|
|
648
|
+
name=name,
|
|
649
|
+
commit=commit_hash,
|
|
650
|
+
time=created,
|
|
651
|
+
info=info,
|
|
652
|
+
dangling=store.get(COMMIT_ROOT % commit_hash) is None,
|
|
653
|
+
)
|
|
654
|
+
|
|
655
|
+
|
|
468
656
|
def clean_orphans(store: KVStore, min_age: float = 3600) -> int:
|
|
469
657
|
"""Remove orphaned commits unreachable from any branch HEAD.
|
|
470
658
|
|
|
471
659
|
Traces all reachable commits from live branch HEADs, then deletes
|
|
472
660
|
the commit metadata, blobs and HAMT nodes owned by the orphaned
|
|
473
|
-
commits and not shared with anything still reachable.
|
|
661
|
+
commits and not shared with anything still reachable. Tags need no
|
|
662
|
+
special handling: a tag is a branch head under a reserved name, so
|
|
663
|
+
it keeps its commit's whole ancestry alive by being walked with
|
|
664
|
+
everything else.
|
|
474
665
|
|
|
475
666
|
Handle-independent by design: it marks from ALL live branch HEADs
|
|
476
667
|
and touches nothing but ``store``, so it works with or without a
|
|
@@ -536,6 +727,13 @@ def deep_clean(store: KVStore, min_age: float = 3600) -> int:
|
|
|
536
727
|
def _sweep(store: KVStore, min_age: float, *, deep: bool) -> int:
|
|
537
728
|
"""Shared mark-and-sweep behind ``clean_orphans`` / ``deep_clean``."""
|
|
538
729
|
gc_logger = logging.getLogger("kvgit.orphans")
|
|
730
|
+
# Reachability is decided from the roots this code knows about, so
|
|
731
|
+
# a store stamped above what this code reads must not be swept: its
|
|
732
|
+
# roots may be a kind that did not exist here, and everything hanging
|
|
733
|
+
# off them would look like garbage. The check belongs on this side of
|
|
734
|
+
# the call rather than only in the handle constructors, because the
|
|
735
|
+
# admin entry points sweep a raw store with no handle at all.
|
|
736
|
+
_assert_supported_version(store)
|
|
539
737
|
cutoff_time = time.time() - min_age
|
|
540
738
|
|
|
541
739
|
def _parent_loader(commit_hash: str) -> tuple[str, ...]:
|
|
@@ -578,6 +776,11 @@ def _sweep(store: KVStore, min_age: float, *, deep: bool) -> int:
|
|
|
578
776
|
reachable_chunks.update(entry.meta.chunks)
|
|
579
777
|
reachable_nodes.update(new_nodes)
|
|
580
778
|
|
|
779
|
+
# Every root is a branch head, tags included: a tag is a head under
|
|
780
|
+
# the reserved ``refs/tags/`` name, so it is marked here without the
|
|
781
|
+
# sweep knowing tags exist. That is the whole compatibility
|
|
782
|
+
# property — a kvgit that predates tags runs this same loop and
|
|
783
|
+
# keeps tagged commits alive for the same reason.
|
|
581
784
|
branch_prefix = BRANCH_HEAD.replace("%s", "")
|
|
582
785
|
for key in store.keys():
|
|
583
786
|
if not (isinstance(key, str) and key.startswith(branch_prefix)):
|
|
@@ -753,6 +956,7 @@ class VersionedKV(VersionedBase):
|
|
|
753
956
|
if store is None:
|
|
754
957
|
store = Memory()
|
|
755
958
|
self.store = store
|
|
959
|
+
_reject_reserved_branch(branch)
|
|
756
960
|
# Applies to every resolve this handle makes — opening, reading
|
|
757
961
|
# HEAD, refreshing, switching, peeking, repairing — and is
|
|
758
962
|
# inherited by the handles ``checkout`` and ``create_branch``
|
|
@@ -921,7 +1125,7 @@ class VersionedKV(VersionedBase):
|
|
|
921
1125
|
# save a roundtrip on idempotent rewrites; the dedup property
|
|
922
1126
|
# holds either way because the key is the hash.
|
|
923
1127
|
if chunks:
|
|
924
|
-
self.
|
|
1128
|
+
_stamp_version_at_least(self.store, CHUNK_STORAGE_VERSION)
|
|
925
1129
|
for chunk_hash, chunk_bytes in chunks.items():
|
|
926
1130
|
diffs[CHUNK_PREFIX + chunk_hash] = chunk_bytes
|
|
927
1131
|
|
|
@@ -954,19 +1158,6 @@ class VersionedKV(VersionedBase):
|
|
|
954
1158
|
|
|
955
1159
|
return new_hash
|
|
956
1160
|
|
|
957
|
-
def _stamp_v3_if_needed(self) -> None:
|
|
958
|
-
"""Lazily upgrade an opened v2 store to v3.
|
|
959
|
-
|
|
960
|
-
Called from any code path that writes a v3-only artifact (a
|
|
961
|
-
chunk under ``kvgit:chunk:*``). v3 stamps are sticky: once
|
|
962
|
-
upgraded, the store can no longer be opened by code that
|
|
963
|
-
only knows v2.
|
|
964
|
-
"""
|
|
965
|
-
raw = self.store.get(STORAGE_VERSION_KEY)
|
|
966
|
-
current = safe_loads(raw) if raw is not None else None
|
|
967
|
-
if current != STORAGE_VERSION:
|
|
968
|
-
self.store.set(STORAGE_VERSION_KEY, dumps(STORAGE_VERSION))
|
|
969
|
-
|
|
970
1161
|
def _create_merge_commit(
|
|
971
1162
|
self,
|
|
972
1163
|
resolution: MergeResolution,
|
|
@@ -1170,10 +1361,32 @@ class VersionedKV(VersionedBase):
|
|
|
1170
1361
|
self._load_commit(commit_hash, update_base=True)
|
|
1171
1362
|
|
|
1172
1363
|
def checkout(
|
|
1173
|
-
self,
|
|
1364
|
+
self,
|
|
1365
|
+
commit_hash: str | None = None,
|
|
1366
|
+
*,
|
|
1367
|
+
branch: str | None = None,
|
|
1368
|
+
tag: str | None = None,
|
|
1174
1369
|
) -> "VersionedKV | None":
|
|
1175
|
-
"""Return a new VersionedKV at a specific commit.
|
|
1176
|
-
|
|
1370
|
+
"""Return a new VersionedKV at a specific commit or tag.
|
|
1371
|
+
|
|
1372
|
+
Name the commit positionally or name a ``tag``, not both.
|
|
1373
|
+
|
|
1374
|
+
The handle comes back on this handle's branch (or ``branch``),
|
|
1375
|
+
not on a branch of its own — there is no read-only mode. A
|
|
1376
|
+
commit made from it goes through the ordinary HEAD CAS, so it
|
|
1377
|
+
fast-forwards when the branch has not moved since, and conflicts
|
|
1378
|
+
exactly as any other stale handle does when it has.
|
|
1379
|
+
|
|
1380
|
+
Returns None when the commit, or the tag, is not in the store.
|
|
1381
|
+
"""
|
|
1382
|
+
if (commit_hash is None) == (tag is None):
|
|
1383
|
+
raise ValueError("Pass exactly one of commit_hash or tag")
|
|
1384
|
+
if tag is not None:
|
|
1385
|
+
tagged = _resolve_tag(self.store, tag)
|
|
1386
|
+
if tagged is None or self.store.get(COMMIT_ROOT % tagged) is None:
|
|
1387
|
+
return None
|
|
1388
|
+
commit_hash = tagged
|
|
1389
|
+
elif self.store.get(COMMIT_ROOT % commit_hash) is None:
|
|
1177
1390
|
return None
|
|
1178
1391
|
return VersionedKV(
|
|
1179
1392
|
self.store,
|
|
@@ -1187,6 +1400,7 @@ class VersionedKV(VersionedBase):
|
|
|
1187
1400
|
|
|
1188
1401
|
Returns a new VersionedKV instance on the new branch.
|
|
1189
1402
|
"""
|
|
1403
|
+
_reject_reserved_branch(name)
|
|
1190
1404
|
branch_key = BRANCH_HEAD % name
|
|
1191
1405
|
target = at or self._current_commit
|
|
1192
1406
|
if at is not None and self.store.get(COMMIT_ROOT % at) is None:
|
|
@@ -1212,6 +1426,7 @@ class VersionedKV(VersionedBase):
|
|
|
1212
1426
|
|
|
1213
1427
|
def delete_branch(self, name: str) -> None:
|
|
1214
1428
|
"""Delete a branch and clean up orphaned commits."""
|
|
1429
|
+
_reject_reserved_branch(name)
|
|
1215
1430
|
if name == self._branch:
|
|
1216
1431
|
raise ValueError("Cannot delete the current branch")
|
|
1217
1432
|
branch_key = BRANCH_HEAD % name
|
|
@@ -1227,6 +1442,7 @@ class VersionedKV(VersionedBase):
|
|
|
1227
1442
|
|
|
1228
1443
|
def switch_branch(self, name: str) -> None:
|
|
1229
1444
|
"""Switch this instance to a different branch in-place."""
|
|
1445
|
+
_reject_reserved_branch(name)
|
|
1230
1446
|
commit_hash = _resolve_head(
|
|
1231
1447
|
self.store,
|
|
1232
1448
|
name,
|
|
@@ -1239,13 +1455,32 @@ class VersionedKV(VersionedBase):
|
|
|
1239
1455
|
self._branch = name
|
|
1240
1456
|
self._load_commit(commit_hash, update_base=True)
|
|
1241
1457
|
|
|
1242
|
-
def peek(
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1458
|
+
def peek(
|
|
1459
|
+
self, key: str, *, branch: str | None = None, tag: str | None = None
|
|
1460
|
+
) -> bytes | None:
|
|
1461
|
+
"""Read a key from another branch's HEAD, or from a tag.
|
|
1462
|
+
|
|
1463
|
+
Pass exactly one of ``branch`` or ``tag``. Returns None when the
|
|
1464
|
+
key, the branch, or the tag is not there.
|
|
1465
|
+
"""
|
|
1466
|
+
if (branch is None) == (tag is None):
|
|
1467
|
+
raise ValueError("Pass exactly one of branch or tag")
|
|
1468
|
+
commit_hash: str | None
|
|
1469
|
+
if branch is not None:
|
|
1470
|
+
_reject_reserved_branch(branch)
|
|
1471
|
+
commit_hash = _resolve_head(
|
|
1472
|
+
self.store,
|
|
1473
|
+
branch,
|
|
1474
|
+
recover_from_corrupt_head=self._recover_from_corrupt_head,
|
|
1475
|
+
)
|
|
1476
|
+
else:
|
|
1477
|
+
tagged = _resolve_tag(self.store, tag or "")
|
|
1478
|
+
commit_hash = (
|
|
1479
|
+
tagged
|
|
1480
|
+
if tagged is not None
|
|
1481
|
+
and self.store.get(COMMIT_ROOT % tagged) is not None
|
|
1482
|
+
else None
|
|
1483
|
+
)
|
|
1249
1484
|
if commit_hash is None:
|
|
1250
1485
|
return None
|
|
1251
1486
|
root = _load_root(self.store, commit_hash)
|
|
@@ -1273,13 +1508,20 @@ class VersionedKV(VersionedBase):
|
|
|
1273
1508
|
|
|
1274
1509
|
@staticmethod
|
|
1275
1510
|
def branches(store: KVStore) -> list[str]:
|
|
1276
|
-
"""List all branch names in the store.
|
|
1511
|
+
"""List all branch names in the store.
|
|
1512
|
+
|
|
1513
|
+
Tags live under the reserved ``refs/tags/`` branch name and are
|
|
1514
|
+
excluded here: callers of this list treat what it returns as
|
|
1515
|
+
branches — switching to them, deleting them, showing them to a
|
|
1516
|
+
user — and a tag is none of those things. :meth:`tags` lists
|
|
1517
|
+
those.
|
|
1518
|
+
"""
|
|
1277
1519
|
prefix = BRANCH_HEAD.replace("%s", "")
|
|
1278
1520
|
result = []
|
|
1279
1521
|
for key in store.keys():
|
|
1280
1522
|
if isinstance(key, str) and key.startswith(prefix):
|
|
1281
1523
|
branch_name = key[len(prefix) :]
|
|
1282
|
-
if branch_name:
|
|
1524
|
+
if branch_name and not branch_name.startswith(TAG_BRANCH_PREFIX):
|
|
1283
1525
|
result.append(branch_name)
|
|
1284
1526
|
return sorted(result)
|
|
1285
1527
|
|
|
@@ -1287,6 +1529,113 @@ class VersionedKV(VersionedBase):
|
|
|
1287
1529
|
"""List all branch names in the store."""
|
|
1288
1530
|
return VersionedKV.branches(self.store)
|
|
1289
1531
|
|
|
1532
|
+
# -- Tags --
|
|
1533
|
+
|
|
1534
|
+
def tag(self, name: str, *, at: str | None = None, info: dict | None = None) -> str:
|
|
1535
|
+
"""Name a commit permanently, and keep it (and its ancestry) alive.
|
|
1536
|
+
|
|
1537
|
+
A tag is immutable: creating one over an existing name raises,
|
|
1538
|
+
and there is no move. Point a name somewhere else by deleting
|
|
1539
|
+
the tag and creating it again, so that the history of the name
|
|
1540
|
+
is at least visible in the calling code. Tags and branches are
|
|
1541
|
+
separate namespaces — ``v1`` can be both, and they are unrelated.
|
|
1542
|
+
|
|
1543
|
+
The tagged commit and everything it descends from survive
|
|
1544
|
+
garbage collection for as long as the tag exists, exactly as a
|
|
1545
|
+
branch head's ancestry does — because a tag *is* a branch head,
|
|
1546
|
+
stored under the reserved name ``refs/tags/<name>`` and hidden
|
|
1547
|
+
from the branch API.
|
|
1548
|
+
|
|
1549
|
+
That storage choice is the compatibility contract, and it is
|
|
1550
|
+
worth stating plainly. Any kvgit that walks branch heads reads a
|
|
1551
|
+
tag's commit and keeps it alive, including versions written
|
|
1552
|
+
before tags existed and including their anchor-free admin sweep;
|
|
1553
|
+
no version stamp could have taught them a new key kind. What
|
|
1554
|
+
such a version does see is a branch named ``refs/tags/<name>``.
|
|
1555
|
+
It can delete that branch by name, or switch to it and commit,
|
|
1556
|
+
which deletes or moves the tag — both deliberate acts naming a
|
|
1557
|
+
path that says what it is.
|
|
1558
|
+
|
|
1559
|
+
Args:
|
|
1560
|
+
at: Commit to tag. Defaults to this handle's current commit,
|
|
1561
|
+
which for a ``Staged`` wrapper means the last *committed*
|
|
1562
|
+
state — staged changes are not part of any commit yet and
|
|
1563
|
+
are not tagged.
|
|
1564
|
+
info: Optional caller metadata, stored beside the tag. Must
|
|
1565
|
+
be JSON-serializable, like commit info.
|
|
1566
|
+
|
|
1567
|
+
Returns:
|
|
1568
|
+
The commit hash the tag names.
|
|
1569
|
+
|
|
1570
|
+
Raises:
|
|
1571
|
+
ValueError: if the name is unusable, already taken, or the
|
|
1572
|
+
commit does not exist.
|
|
1573
|
+
|
|
1574
|
+
There is one race, and it is narrow: tagging a commit that is
|
|
1575
|
+
*already* an orphan older than ``min_age`` can lose to a sweep
|
|
1576
|
+
running concurrently, which was free to collect that commit
|
|
1577
|
+
before the tag existed. Callers tag a commit they are holding —
|
|
1578
|
+
a head, or something a head descends from — and a commit a
|
|
1579
|
+
branch reaches is never a sweep candidate.
|
|
1580
|
+
"""
|
|
1581
|
+
_validate_tag_name(name)
|
|
1582
|
+
target = at or self._current_commit
|
|
1583
|
+
if self.store.get(COMMIT_ROOT % target) is None:
|
|
1584
|
+
raise ValueError(f"Commit '{target}' does not exist")
|
|
1585
|
+
# Encoded before anything is written, so info that cannot be
|
|
1586
|
+
# serialized raises without leaving a tag behind.
|
|
1587
|
+
record = dumps({"time": time.time(), "info": info})
|
|
1588
|
+
|
|
1589
|
+
# CAS against absence: two writers racing the same name cannot
|
|
1590
|
+
# both win, and an existing tag is never silently overwritten.
|
|
1591
|
+
head_key = BRANCH_HEAD % _tag_branch(name)
|
|
1592
|
+
if not self.store.cas(head_key, dumps(target), expected=None):
|
|
1593
|
+
raise ValueError(f"Tag '{name}' already exists")
|
|
1594
|
+
# A name that has just been claimed has no previous HEAD, so any
|
|
1595
|
+
# backup under it is stale by definition — left by an earlier tag
|
|
1596
|
+
# of the same name, or by something that moved this name as an
|
|
1597
|
+
# ordinary branch. Dropped after the CAS, so a losing attempt
|
|
1598
|
+
# cannot take out the winner's state.
|
|
1599
|
+
self.store.remove(BRANCH_HEAD_PREV % _tag_branch(name))
|
|
1600
|
+
# The record is a second write, so a crash in between leaves a
|
|
1601
|
+
# tag with no time and no info. That is why the head key alone is
|
|
1602
|
+
# what GC and resolution read.
|
|
1603
|
+
self.store.set(TAG_INFO_KEY % name, record)
|
|
1604
|
+
return target
|
|
1605
|
+
|
|
1606
|
+
def tags(self) -> dict[str, str]:
|
|
1607
|
+
"""Map every tag in the store to the commit it names."""
|
|
1608
|
+
return tags(self.store)
|
|
1609
|
+
|
|
1610
|
+
def tag_info(self, name: str) -> TagInfo | None:
|
|
1611
|
+
"""Describe one tag, or None if the store has no such tag."""
|
|
1612
|
+
return tag_info(self.store, name)
|
|
1613
|
+
|
|
1614
|
+
def delete_tag(self, name: str) -> None:
|
|
1615
|
+
"""Remove a tag, then sweep the commits nothing else reaches.
|
|
1616
|
+
|
|
1617
|
+
Three keys go — the tag's reserved head, that head's prev-HEAD
|
|
1618
|
+
backup, and the info record — and then :meth:`clean_orphans`
|
|
1619
|
+
runs, matching :meth:`delete_branch`. A commit the tag was the
|
|
1620
|
+
last root for becomes collectable at that point, subject to the
|
|
1621
|
+
sweep's ``min_age`` guard.
|
|
1622
|
+
|
|
1623
|
+
This code never writes a backup for a tag, since it never moves
|
|
1624
|
+
one. The removal is for a backup written by something that
|
|
1625
|
+
treated the tag as an ordinary branch — switching to it and
|
|
1626
|
+
committing — where leaving it behind would let head resolution
|
|
1627
|
+
serve the deleted tag's commit under a later branch of the same
|
|
1628
|
+
reserved name.
|
|
1629
|
+
"""
|
|
1630
|
+
_validate_tag_name(name)
|
|
1631
|
+
head_key = BRANCH_HEAD % _tag_branch(name)
|
|
1632
|
+
if self.store.get(head_key) is None:
|
|
1633
|
+
raise ValueError(f"Tag '{name}' does not exist")
|
|
1634
|
+
self.store.remove(head_key)
|
|
1635
|
+
self.store.remove(BRANCH_HEAD_PREV % _tag_branch(name))
|
|
1636
|
+
self.store.remove(TAG_INFO_KEY % name)
|
|
1637
|
+
self.clean_orphans()
|
|
1638
|
+
|
|
1290
1639
|
def commit_info(self, commit_hash: str | None = None) -> dict | None:
|
|
1291
1640
|
"""Retrieve the info dict for a commit, or None if none was stored."""
|
|
1292
1641
|
target = commit_hash or self._current_commit
|
|
@@ -20,6 +20,27 @@ class DiffResult:
|
|
|
20
20
|
modified: frozenset[str]
|
|
21
21
|
|
|
22
22
|
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class TagInfo:
|
|
25
|
+
"""What a store records about one tag.
|
|
26
|
+
|
|
27
|
+
``time`` and ``info`` come from a record written just after the tag
|
|
28
|
+
itself, so both are ``None`` for a tag whose record is missing —
|
|
29
|
+
a crash between the two writes, or a store written by hand.
|
|
30
|
+
|
|
31
|
+
``dangling`` means the tagged commit is not in the store. A tag
|
|
32
|
+
cannot be created for a commit that does not exist, so this is
|
|
33
|
+
damage rather than an ordinary state, and a dangling tag keeps
|
|
34
|
+
nothing alive: garbage collection marks nothing from it.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
name: str
|
|
38
|
+
commit: str
|
|
39
|
+
time: float | None
|
|
40
|
+
info: dict | None
|
|
41
|
+
dangling: bool
|
|
42
|
+
|
|
43
|
+
|
|
23
44
|
@dataclass(frozen=True)
|
|
24
45
|
class MergeResult:
|
|
25
46
|
"""Result of a merge operation."""
|
|
@@ -92,7 +113,11 @@ class Versioned(Protocol):
|
|
|
92
113
|
def refresh(self) -> None: ...
|
|
93
114
|
|
|
94
115
|
def checkout(
|
|
95
|
-
self,
|
|
116
|
+
self,
|
|
117
|
+
commit_hash: str | None = None,
|
|
118
|
+
*,
|
|
119
|
+
branch: str | None = None,
|
|
120
|
+
tag: str | None = None,
|
|
96
121
|
) -> "Versioned | None": ...
|
|
97
122
|
|
|
98
123
|
def create_branch(self, name: str, *, at: str | None = None) -> "Versioned": ...
|
|
@@ -101,7 +126,21 @@ class Versioned(Protocol):
|
|
|
101
126
|
|
|
102
127
|
def switch_branch(self, name: str) -> None: ...
|
|
103
128
|
|
|
104
|
-
def peek(
|
|
129
|
+
def peek(
|
|
130
|
+
self, key: str, *, branch: str | None = None, tag: str | None = None
|
|
131
|
+
) -> bytes | None: ...
|
|
132
|
+
|
|
133
|
+
# -- Tags --
|
|
134
|
+
|
|
135
|
+
def tag(
|
|
136
|
+
self, name: str, *, at: str | None = None, info: dict | None = None
|
|
137
|
+
) -> str: ...
|
|
138
|
+
|
|
139
|
+
def tags(self) -> dict[str, str]: ...
|
|
140
|
+
|
|
141
|
+
def tag_info(self, name: str) -> TagInfo | None: ...
|
|
142
|
+
|
|
143
|
+
def delete_tag(self, name: str) -> None: ...
|
|
105
144
|
|
|
106
145
|
def reset_to(self, commit_hash: str) -> bool: ...
|
|
107
146
|
|
|
@@ -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)
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "kvgit"
|
|
7
|
-
version = "0.3.
|
|
7
|
+
version = "0.3.5"
|
|
8
8
|
description = "Versioned key-value store with git-like commit, branch, and merge semantics."
|
|
9
9
|
authors = [{ name = "ashenfad" }]
|
|
10
10
|
requires-python = ">=3.10"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|