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.
Files changed (44) hide show
  1. {kvgit-0.3.4 → kvgit-0.3.5}/PKG-INFO +6 -1
  2. {kvgit-0.3.4 → kvgit-0.3.5}/README.md +5 -0
  3. {kvgit-0.3.4 → kvgit-0.3.5}/kvgit/__init__.py +4 -2
  4. {kvgit-0.3.4 → kvgit-0.3.5}/kvgit/staged.py +52 -7
  5. {kvgit-0.3.4 → kvgit-0.3.5}/kvgit/store.py +69 -1
  6. {kvgit-0.3.4 → kvgit-0.3.5}/kvgit/versioned/__init__.py +2 -1
  7. {kvgit-0.3.4 → kvgit-0.3.5}/kvgit/versioned/kv.py +385 -36
  8. {kvgit-0.3.4 → kvgit-0.3.5}/kvgit/versioned/protocol.py +41 -2
  9. {kvgit-0.3.4 → kvgit-0.3.5}/kvgit.egg-info/PKG-INFO +6 -1
  10. {kvgit-0.3.4 → kvgit-0.3.5}/pyproject.toml +1 -1
  11. {kvgit-0.3.4 → kvgit-0.3.5}/LICENSE +0 -0
  12. {kvgit-0.3.4 → kvgit-0.3.5}/kvgit/codecs/__init__.py +0 -0
  13. {kvgit-0.3.4 → kvgit-0.3.5}/kvgit/codecs/_hash.py +0 -0
  14. {kvgit-0.3.4 → kvgit-0.3.5}/kvgit/codecs/base.py +0 -0
  15. {kvgit-0.3.4 → kvgit-0.3.5}/kvgit/codecs/numpy.py +0 -0
  16. {kvgit-0.3.4 → kvgit-0.3.5}/kvgit/codecs/pandas.py +0 -0
  17. {kvgit-0.3.4 → kvgit-0.3.5}/kvgit/codecs/pickler.py +0 -0
  18. {kvgit-0.3.4 → kvgit-0.3.5}/kvgit/content_types.py +0 -0
  19. {kvgit-0.3.4 → kvgit-0.3.5}/kvgit/encoding.py +0 -0
  20. {kvgit-0.3.4 → kvgit-0.3.5}/kvgit/errors.py +0 -0
  21. {kvgit-0.3.4 → kvgit-0.3.5}/kvgit/hamt.py +0 -0
  22. {kvgit-0.3.4 → kvgit-0.3.5}/kvgit/kv/__init__.py +0 -0
  23. {kvgit-0.3.4 → kvgit-0.3.5}/kvgit/kv/base.py +0 -0
  24. {kvgit-0.3.4 → kvgit-0.3.5}/kvgit/kv/composite.py +0 -0
  25. {kvgit-0.3.4 → kvgit-0.3.5}/kvgit/kv/disk.py +0 -0
  26. {kvgit-0.3.4 → kvgit-0.3.5}/kvgit/kv/indexeddb.py +0 -0
  27. {kvgit-0.3.4 → kvgit-0.3.5}/kvgit/kv/memory.py +0 -0
  28. {kvgit-0.3.4 → kvgit-0.3.5}/kvgit/namespaced.py +0 -0
  29. {kvgit-0.3.4 → kvgit-0.3.5}/kvgit/py.typed +0 -0
  30. {kvgit-0.3.4 → kvgit-0.3.5}/kvgit/versioned/base.py +0 -0
  31. {kvgit-0.3.4 → kvgit-0.3.5}/kvgit/versioned/helpers.py +0 -0
  32. {kvgit-0.3.4 → kvgit-0.3.5}/kvgit/versioned/keyset.py +0 -0
  33. {kvgit-0.3.4 → kvgit-0.3.5}/kvgit/versioned/merge.py +0 -0
  34. {kvgit-0.3.4 → kvgit-0.3.5}/kvgit.egg-info/SOURCES.txt +0 -0
  35. {kvgit-0.3.4 → kvgit-0.3.5}/kvgit.egg-info/dependency_links.txt +0 -0
  36. {kvgit-0.3.4 → kvgit-0.3.5}/kvgit.egg-info/requires.txt +0 -0
  37. {kvgit-0.3.4 → kvgit-0.3.5}/kvgit.egg-info/top_level.txt +0 -0
  38. {kvgit-0.3.4 → kvgit-0.3.5}/setup.cfg +0 -0
  39. {kvgit-0.3.4 → kvgit-0.3.5}/tests/test_content_types.py +0 -0
  40. {kvgit-0.3.4 → kvgit-0.3.5}/tests/test_delete_branches.py +0 -0
  41. {kvgit-0.3.4 → kvgit-0.3.5}/tests/test_hamt.py +0 -0
  42. {kvgit-0.3.4 → kvgit-0.3.5}/tests/test_namespaced.py +0 -0
  43. {kvgit-0.3.4 → kvgit-0.3.5}/tests/test_staged.py +0 -0
  44. {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.4
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, commit_hash: str, *, branch: str | None = None
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. Returns None if not found."""
413
- v = self._versioned.checkout(commit_hash, branch=branch)
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(self, key: str, *, branch: str) -> Any:
434
- """Read a key from another branch without switching. Returns None if not found."""
435
- raw = self._versioned.peek(key, branch=branch)
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. All heads
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 inside ``_create_commit`` the first
119
- time a chunked write occurs.
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
- version = safe_loads(raw)
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._stamp_v3_if_needed()
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, commit_hash: str, *, branch: str | None = None
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
- if self.store.get(COMMIT_ROOT % commit_hash) is None:
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(self, key: str, *, branch: str) -> bytes | None:
1243
- """Read a key from another branch's HEAD without switching."""
1244
- commit_hash = _resolve_head(
1245
- self.store,
1246
- branch,
1247
- recover_from_corrupt_head=self._recover_from_corrupt_head,
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, commit_hash: str, *, branch: str | None = None
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(self, key: str, *, branch: str) -> bytes | None: ...
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.4
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.4"
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