kvgit 0.3.3__tar.gz → 0.3.5__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. {kvgit-0.3.3 → kvgit-0.3.5}/PKG-INFO +6 -1
  2. {kvgit-0.3.3 → kvgit-0.3.5}/README.md +5 -0
  3. {kvgit-0.3.3 → kvgit-0.3.5}/kvgit/__init__.py +4 -2
  4. {kvgit-0.3.3 → kvgit-0.3.5}/kvgit/staged.py +52 -7
  5. {kvgit-0.3.3 → kvgit-0.3.5}/kvgit/store.py +93 -3
  6. {kvgit-0.3.3 → kvgit-0.3.5}/kvgit/versioned/__init__.py +2 -1
  7. {kvgit-0.3.3 → kvgit-0.3.5}/kvgit/versioned/kv.py +588 -60
  8. {kvgit-0.3.3 → kvgit-0.3.5}/kvgit/versioned/protocol.py +41 -2
  9. {kvgit-0.3.3 → kvgit-0.3.5}/kvgit.egg-info/PKG-INFO +6 -1
  10. {kvgit-0.3.3 → kvgit-0.3.5}/pyproject.toml +1 -1
  11. {kvgit-0.3.3 → kvgit-0.3.5}/LICENSE +0 -0
  12. {kvgit-0.3.3 → kvgit-0.3.5}/kvgit/codecs/__init__.py +0 -0
  13. {kvgit-0.3.3 → kvgit-0.3.5}/kvgit/codecs/_hash.py +0 -0
  14. {kvgit-0.3.3 → kvgit-0.3.5}/kvgit/codecs/base.py +0 -0
  15. {kvgit-0.3.3 → kvgit-0.3.5}/kvgit/codecs/numpy.py +0 -0
  16. {kvgit-0.3.3 → kvgit-0.3.5}/kvgit/codecs/pandas.py +0 -0
  17. {kvgit-0.3.3 → kvgit-0.3.5}/kvgit/codecs/pickler.py +0 -0
  18. {kvgit-0.3.3 → kvgit-0.3.5}/kvgit/content_types.py +0 -0
  19. {kvgit-0.3.3 → kvgit-0.3.5}/kvgit/encoding.py +0 -0
  20. {kvgit-0.3.3 → kvgit-0.3.5}/kvgit/errors.py +0 -0
  21. {kvgit-0.3.3 → kvgit-0.3.5}/kvgit/hamt.py +0 -0
  22. {kvgit-0.3.3 → kvgit-0.3.5}/kvgit/kv/__init__.py +0 -0
  23. {kvgit-0.3.3 → kvgit-0.3.5}/kvgit/kv/base.py +0 -0
  24. {kvgit-0.3.3 → kvgit-0.3.5}/kvgit/kv/composite.py +0 -0
  25. {kvgit-0.3.3 → kvgit-0.3.5}/kvgit/kv/disk.py +0 -0
  26. {kvgit-0.3.3 → kvgit-0.3.5}/kvgit/kv/indexeddb.py +0 -0
  27. {kvgit-0.3.3 → kvgit-0.3.5}/kvgit/kv/memory.py +0 -0
  28. {kvgit-0.3.3 → kvgit-0.3.5}/kvgit/namespaced.py +0 -0
  29. {kvgit-0.3.3 → kvgit-0.3.5}/kvgit/py.typed +0 -0
  30. {kvgit-0.3.3 → kvgit-0.3.5}/kvgit/versioned/base.py +0 -0
  31. {kvgit-0.3.3 → kvgit-0.3.5}/kvgit/versioned/helpers.py +0 -0
  32. {kvgit-0.3.3 → kvgit-0.3.5}/kvgit/versioned/keyset.py +0 -0
  33. {kvgit-0.3.3 → kvgit-0.3.5}/kvgit/versioned/merge.py +0 -0
  34. {kvgit-0.3.3 → kvgit-0.3.5}/kvgit.egg-info/SOURCES.txt +0 -0
  35. {kvgit-0.3.3 → kvgit-0.3.5}/kvgit.egg-info/dependency_links.txt +0 -0
  36. {kvgit-0.3.3 → kvgit-0.3.5}/kvgit.egg-info/requires.txt +0 -0
  37. {kvgit-0.3.3 → kvgit-0.3.5}/kvgit.egg-info/top_level.txt +0 -0
  38. {kvgit-0.3.3 → kvgit-0.3.5}/setup.cfg +0 -0
  39. {kvgit-0.3.3 → kvgit-0.3.5}/tests/test_content_types.py +0 -0
  40. {kvgit-0.3.3 → kvgit-0.3.5}/tests/test_delete_branches.py +0 -0
  41. {kvgit-0.3.3 → kvgit-0.3.5}/tests/test_hamt.py +0 -0
  42. {kvgit-0.3.3 → kvgit-0.3.5}/tests/test_namespaced.py +0 -0
  43. {kvgit-0.3.3 → kvgit-0.3.5}/tests/test_staged.py +0 -0
  44. {kvgit-0.3.3 → kvgit-0.3.5}/tests/test_store_factory.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kvgit
3
- Version: 0.3.3
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
 
@@ -7,7 +7,16 @@ from typing import Any, Literal
7
7
  from .kv.base import KVStore
8
8
  from .kv.memory import Memory
9
9
  from .staged import Staged
10
- from .versioned.kv import BRANCH_HEAD, BRANCH_HEAD_PREV, VersionedKV, clean_orphans
10
+ from .versioned.kv import (
11
+ BRANCH_HEAD,
12
+ BRANCH_HEAD_PREV,
13
+ TAG_BRANCH_PREFIX,
14
+ TAG_INFO_KEY,
15
+ CorruptHeadRecoverer,
16
+ VersionedKV,
17
+ _assert_supported_version,
18
+ clean_orphans,
19
+ )
11
20
 
12
21
 
13
22
  def _make_backend(
@@ -46,6 +55,7 @@ def store(
46
55
  encoder: Callable[..., bytes] = pickle.dumps,
47
56
  decoder: Callable[..., Any] = pickle.loads,
48
57
  codecs: str | None = None,
58
+ recover_from_corrupt_head: CorruptHeadRecoverer | None = None,
49
59
  ) -> Staged:
50
60
  """Create a Staged store with sensible defaults.
51
61
 
@@ -62,6 +72,13 @@ def store(
62
72
  ``"scientific"`` — numpy/pandas chunked codecs (requires
63
73
  numpy; install with ``pip install kvgit[scientific]``).
64
74
  Mutually exclusive with explicit ``encoder`` / ``decoder``.
75
+ recover_from_corrupt_head: Optional last-resort recovery for a
76
+ branch whose HEAD is unresolvable *and* whose prev-HEAD
77
+ backup is gone. Off by default: with both gone the store
78
+ does not know what the branch pointed at, so the honest
79
+ answer is to fail rather than guess. Pass
80
+ ``kvgit.versioned.kv.recover_by_commit_scan`` to opt into
81
+ the scan, having read what it can return.
65
82
 
66
83
  Returns:
67
84
  A ``Staged`` store instance.
@@ -85,7 +102,15 @@ def store(
85
102
 
86
103
  backend = _make_backend(kind, path=path, db_name=db_name)
87
104
 
88
- return Staged(VersionedKV(backend, branch=branch), encoder=encoder, decoder=decoder)
105
+ return Staged(
106
+ VersionedKV(
107
+ backend,
108
+ branch=branch,
109
+ recover_from_corrupt_head=recover_from_corrupt_head,
110
+ ),
111
+ encoder=encoder,
112
+ decoder=decoder,
113
+ )
89
114
 
90
115
 
91
116
  def delete_branches(
@@ -115,7 +140,10 @@ def delete_branches(
115
140
  just mints a fresh empty ``main`` on next open.
116
141
 
117
142
  One :func:`clean_orphans` sweep runs after all removals, so commits
118
- that only the deleted branches referenced are reclaimed. 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
119
147
  (and prev-heads) are gone before the sweep, so it can't mistake a
120
148
  doomed branch for a live one. ``min_age`` defaults to
121
149
  ``clean_orphans``'s one-hour guard (matching ``delete_branch``), so
@@ -147,6 +175,12 @@ def delete_branches(
147
175
  return # nothing asked: skip the backend open and the sweep
148
176
  backend = _make_backend(kind, path=path, db_name=db_name)
149
177
  try:
178
+ # No ``VersionedKV`` is constructed here, so nothing else on this
179
+ # path checks the store's layout version. Checked before the
180
+ # first removal rather than leaving it to the sweep: a store this
181
+ # code cannot read must come out of the call untouched, not with
182
+ # its branch keys already gone.
183
+ _assert_supported_version(backend)
150
184
  for name in doomed:
151
185
  backend.remove(BRANCH_HEAD % name)
152
186
  backend.remove(BRANCH_HEAD_PREV % name)
@@ -155,3 +189,59 @@ def delete_branches(
155
189
  close = getattr(backend, "close", None)
156
190
  if callable(close):
157
191
  close()
192
+
193
+
194
+ def delete_tags(
195
+ names: str | Iterable[str],
196
+ *,
197
+ kind: Literal["memory", "disk", "indexeddb"] = "disk",
198
+ path: str | None = None,
199
+ db_name: str = "kvgit",
200
+ min_age: float = 3600,
201
+ ) -> None:
202
+ """Delete tags with no branch anchor, then sweep orphans.
203
+
204
+ The tag counterpart of :func:`delete_branches`, and anchor-free for
205
+ the same reason: teardown should not need a handle, and a handle
206
+ always has a current branch to open on. A tag is stored as a branch
207
+ head under the reserved name ``refs/tags/<name>``, so for each name
208
+ that head, its ``__branch_head_prev__`` backup and its
209
+ ``__tag_info__<name>`` record are removed, then a single
210
+ :func:`clean_orphans` sweep reclaims commits that only the deleted
211
+ tags kept alive. The backup is defensive: this code never writes one
212
+ for a tag, but something that treated the tag as an ordinary branch
213
+ could have, and a backup outliving its head would let a later tag of
214
+ the same name resolve to the deleted commit. A name with no tag is
215
+ skipped — idempotency beats a ``ValueError`` in teardown.
216
+
217
+ That sweep does not reclaim chunks; follow up with ``deep_clean`` on
218
+ a quiescent store if the store uses chunked codecs.
219
+
220
+ Args:
221
+ names: Tag names to delete — one name or an iterable of them.
222
+ Missing names are no-ops. (A bare string is treated as ONE
223
+ name, not iterated per character.)
224
+ kind: ``"disk"`` (default), ``"memory"``, or ``"indexeddb"``.
225
+ path: Required when ``kind="disk"``. Store directory.
226
+ db_name: IndexedDB database name (``kind="indexeddb"`` only).
227
+ min_age: Passed to :func:`clean_orphans` — commits younger than
228
+ this many seconds survive the sweep.
229
+ """
230
+ doomed = {names} if isinstance(names, str) else set(names)
231
+ if not doomed:
232
+ return # nothing asked: skip the backend open and the sweep
233
+ backend = _make_backend(kind, path=path, db_name=db_name)
234
+ try:
235
+ # Same reasoning as delete_branches: no handle is constructed on
236
+ # this path, so the layout check has to happen here, before
237
+ # anything is removed.
238
+ _assert_supported_version(backend)
239
+ for name in doomed:
240
+ backend.remove(BRANCH_HEAD % (TAG_BRANCH_PREFIX + name))
241
+ backend.remove(BRANCH_HEAD_PREV % (TAG_BRANCH_PREFIX + name))
242
+ backend.remove(TAG_INFO_KEY % name)
243
+ clean_orphans(backend, min_age=min_age)
244
+ finally:
245
+ close = getattr(backend, "close", None)
246
+ if callable(close):
247
+ close()
@@ -1,12 +1,13 @@
1
1
  """Versioned store implementations."""
2
2
 
3
3
  from .kv import VersionedKV
4
- from .protocol import BytesMergeFn, DiffResult, MergeResult, Versioned
4
+ from .protocol import BytesMergeFn, DiffResult, MergeResult, TagInfo, Versioned
5
5
 
6
6
  __all__ = [
7
7
  "BytesMergeFn",
8
8
  "DiffResult",
9
9
  "MergeResult",
10
+ "TagInfo",
10
11
  "Versioned",
11
12
  "VersionedKV",
12
13
  ]