kvgit 0.2.1__tar.gz → 0.2.2__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 (38) hide show
  1. {kvgit-0.2.1 → kvgit-0.2.2}/PKG-INFO +1 -2
  2. {kvgit-0.2.1 → kvgit-0.2.2}/README.md +0 -1
  3. {kvgit-0.2.1 → kvgit-0.2.2}/kvgit/hamt.py +20 -5
  4. {kvgit-0.2.1 → kvgit-0.2.2}/kvgit/kv/indexeddb.py +18 -1
  5. {kvgit-0.2.1 → kvgit-0.2.2}/kvgit/versioned/keyset.py +6 -3
  6. {kvgit-0.2.1 → kvgit-0.2.2}/kvgit/versioned/kv.py +9 -3
  7. {kvgit-0.2.1 → kvgit-0.2.2}/kvgit.egg-info/PKG-INFO +1 -2
  8. {kvgit-0.2.1 → kvgit-0.2.2}/pyproject.toml +1 -1
  9. {kvgit-0.2.1 → kvgit-0.2.2}/tests/test_hamt.py +113 -0
  10. {kvgit-0.2.1 → kvgit-0.2.2}/LICENSE +0 -0
  11. {kvgit-0.2.1 → kvgit-0.2.2}/kvgit/__init__.py +0 -0
  12. {kvgit-0.2.1 → kvgit-0.2.2}/kvgit/content_types.py +0 -0
  13. {kvgit-0.2.1 → kvgit-0.2.2}/kvgit/encoding.py +0 -0
  14. {kvgit-0.2.1 → kvgit-0.2.2}/kvgit/errors.py +0 -0
  15. {kvgit-0.2.1 → kvgit-0.2.2}/kvgit/kv/__init__.py +0 -0
  16. {kvgit-0.2.1 → kvgit-0.2.2}/kvgit/kv/base.py +0 -0
  17. {kvgit-0.2.1 → kvgit-0.2.2}/kvgit/kv/composite.py +0 -0
  18. {kvgit-0.2.1 → kvgit-0.2.2}/kvgit/kv/disk.py +0 -0
  19. {kvgit-0.2.1 → kvgit-0.2.2}/kvgit/kv/memory.py +0 -0
  20. {kvgit-0.2.1 → kvgit-0.2.2}/kvgit/namespaced.py +0 -0
  21. {kvgit-0.2.1 → kvgit-0.2.2}/kvgit/py.typed +0 -0
  22. {kvgit-0.2.1 → kvgit-0.2.2}/kvgit/staged.py +0 -0
  23. {kvgit-0.2.1 → kvgit-0.2.2}/kvgit/store.py +0 -0
  24. {kvgit-0.2.1 → kvgit-0.2.2}/kvgit/versioned/__init__.py +0 -0
  25. {kvgit-0.2.1 → kvgit-0.2.2}/kvgit/versioned/base.py +0 -0
  26. {kvgit-0.2.1 → kvgit-0.2.2}/kvgit/versioned/gp.py +0 -0
  27. {kvgit-0.2.1 → kvgit-0.2.2}/kvgit/versioned/helpers.py +0 -0
  28. {kvgit-0.2.1 → kvgit-0.2.2}/kvgit/versioned/merge.py +0 -0
  29. {kvgit-0.2.1 → kvgit-0.2.2}/kvgit/versioned/protocol.py +0 -0
  30. {kvgit-0.2.1 → kvgit-0.2.2}/kvgit.egg-info/SOURCES.txt +0 -0
  31. {kvgit-0.2.1 → kvgit-0.2.2}/kvgit.egg-info/dependency_links.txt +0 -0
  32. {kvgit-0.2.1 → kvgit-0.2.2}/kvgit.egg-info/requires.txt +0 -0
  33. {kvgit-0.2.1 → kvgit-0.2.2}/kvgit.egg-info/top_level.txt +0 -0
  34. {kvgit-0.2.1 → kvgit-0.2.2}/setup.cfg +0 -0
  35. {kvgit-0.2.1 → kvgit-0.2.2}/tests/test_content_types.py +0 -0
  36. {kvgit-0.2.1 → kvgit-0.2.2}/tests/test_namespaced.py +0 -0
  37. {kvgit-0.2.1 → kvgit-0.2.2}/tests/test_staged.py +0 -0
  38. {kvgit-0.2.1 → kvgit-0.2.2}/tests/test_store_factory.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kvgit
3
- Version: 0.2.1
3
+ Version: 0.2.2
4
4
  Summary: Versioned key-value store with git-like commit, branch, and merge semantics.
5
5
  Author: ashenfad
6
6
  License: MIT
@@ -49,7 +49,6 @@ Git-style versioning for your data. Commits, branches, and merges -- backed by a
49
49
  | **Commits** | Immutable, content-addressable snapshots with rollback |
50
50
  | **Branches** | Cheap forks with CAS-based optimistic concurrency |
51
51
  | **Three-way merge** | Auto-merges non-overlapping changes; pluggable merge fns for conflicts |
52
- | **Eviction** | High/low water rebase drops least-recently-used keys automatically |
53
52
  | **Pluggable backends** | In-memory, disk (diskcache), git (GitPython), IndexedDB (Pyodide/browser), or bring your own `KVStore` |
54
53
 
55
54
  ## Install
@@ -8,7 +8,6 @@ Git-style versioning for your data. Commits, branches, and merges -- backed by a
8
8
  | **Commits** | Immutable, content-addressable snapshots with rollback |
9
9
  | **Branches** | Cheap forks with CAS-based optimistic concurrency |
10
10
  | **Three-way merge** | Auto-merges non-overlapping changes; pluggable merge fns for conflicts |
11
- | **Eviction** | High/low water rebase drops least-recently-used keys automatically |
12
11
  | **Pluggable backends** | In-memory, disk (diskcache), git (GitPython), IndexedDB (Pyodide/browser), or bring your own `KVStore` |
13
12
 
14
13
  ## Install
@@ -217,7 +217,9 @@ class Hamt:
217
217
  """
218
218
  return self.walk()[0]
219
219
 
220
- def walk(self) -> tuple[dict[str, bytes], set[str]]:
220
+ def walk(
221
+ self, skip_nodes: set[str] | None = None
222
+ ) -> tuple[dict[str, bytes], set[str]]:
221
223
  """Walk the entire HAMT, returning (items, node_hashes).
222
224
 
223
225
  Single batched BFS that collects both the key→value entries
@@ -229,8 +231,19 @@ class Hamt:
229
231
  Same batching properties as ``materialize()``: one
230
232
  ``get_many`` call per tree level, O(log_branching N)
231
233
  round-trips total.
234
+
235
+ ``skip_nodes`` is an optional set of node hashes to treat as
236
+ already-visited. Skipped subtrees are not fetched, not
237
+ recursed into, and not included in the returned ``nodes``
238
+ set. Items beneath skipped subtrees are also omitted from
239
+ the returned ``items`` dict. Pass a cumulative seen-set
240
+ across multiple ``walk()`` calls (e.g. across the commits
241
+ of a branch's history) to share work where the underlying
242
+ HAMTs share structure — turns N-walks-over-shared-tree from
243
+ O(N · subtree) into O(unique nodes).
232
244
  """
233
- if self.root == EMPTY_HASH:
245
+ skip = skip_nodes if skip_nodes is not None else set()
246
+ if self.root == EMPTY_HASH or self.root in skip:
234
247
  return {}, set()
235
248
 
236
249
  items: dict[str, bytes] = {}
@@ -239,11 +252,13 @@ class Hamt:
239
252
 
240
253
  while current_level:
241
254
  # Partition this level: nodes already in pending vs
242
- # nodes that need to be fetched from the store.
255
+ # nodes that need to be fetched from the store. Drop
256
+ # anything in skip_nodes — those subtrees have already
257
+ # been visited by a prior walk.
243
258
  cached_nodes: dict[str, dict] = {}
244
259
  to_fetch: list[str] = []
245
260
  for node_hash in current_level:
246
- if node_hash == EMPTY_HASH:
261
+ if node_hash == EMPTY_HASH or node_hash in skip:
247
262
  continue
248
263
  prefixed = self.prefix + node_hash
249
264
  if prefixed in self.pending:
@@ -261,7 +276,7 @@ class Hamt:
261
276
  # node hash we successfully load.
262
277
  next_level: list[str] = []
263
278
  for node_hash in current_level:
264
- if node_hash == EMPTY_HASH:
279
+ if node_hash == EMPTY_HASH or node_hash in skip:
265
280
  continue
266
281
  if node_hash in cached_nodes:
267
282
  node = cached_nodes[node_hash]
@@ -63,7 +63,14 @@ def _promise(executor):
63
63
 
64
64
 
65
65
  async def _idb_open(db_name: str, store_name: str):
66
- """Open (or create) an IndexedDB database, returning the IDBDatabase."""
66
+ """Open (or create) an IndexedDB database, returning the IDBDatabase.
67
+
68
+ Rejects with a clear error when ``onblocked`` fires — a zombie
69
+ connection from another tab / window (or transient browser-side
70
+ state) can refuse the open indefinitely. Without an explicit
71
+ handler the promise would never resolve and ``run_sync`` would
72
+ hang forever.
73
+ """
67
74
 
68
75
  def _executor(resolve, reject):
69
76
  request = indexedDB.open(db_name, 1)
@@ -79,9 +86,19 @@ async def _idb_open(db_name: str, store_name: str):
79
86
  def on_error(event):
80
87
  reject(event.target.error)
81
88
 
89
+ def on_blocked(event):
90
+ reject(
91
+ Exception(
92
+ f"IndexedDB open of {db_name!r} is blocked. Close other "
93
+ f"tabs / windows holding the database open and reload, "
94
+ f"or restart the browser."
95
+ )
96
+ )
97
+
82
98
  request.onupgradeneeded = on_upgrade
83
99
  request.onsuccess = on_success
84
100
  request.onerror = on_error
101
+ request.onblocked = on_blocked
85
102
 
86
103
  return await _promise(_executor)
87
104
 
@@ -146,14 +146,17 @@ class Keyset:
146
146
  """
147
147
  return {k: decode_entry(v) for k, v in self._hamt.materialize().items()}
148
148
 
149
- def walk(self) -> tuple[dict[str, KeysetEntry], set[str]]:
149
+ def walk(
150
+ self, skip_nodes: set[str] | None = None
151
+ ) -> tuple[dict[str, KeysetEntry], set[str]]:
150
152
  """Single batched walk returning (entries, hamt_node_hashes).
151
153
 
152
154
  Equivalent to ``materialize()`` plus collecting every HAMT
153
155
  node hash, in one tree traversal. Used by GC mark phases
154
- that need both — see ``Hamt.walk``.
156
+ that need both — see ``Hamt.walk`` (including the
157
+ ``skip_nodes`` cumulative seen-set semantics).
155
158
  """
156
- raw_items, nodes = self._hamt.walk()
159
+ raw_items, nodes = self._hamt.walk(skip_nodes=skip_nodes)
157
160
  return {k: decode_entry(v) for k, v in raw_items.items()}, nodes
158
161
 
159
162
  def keys(self) -> Iterator[str]:
@@ -735,11 +735,17 @@ class VersionedKV(VersionedBase):
735
735
  if root is None:
736
736
  continue
737
737
  # Single batched walk per commit collects both the
738
- # blob references and the HAMT node hashes.
739
- entries, nodes = Keyset(self.store, root=root).walk()
738
+ # blob references and the HAMT node hashes. Pass the
739
+ # cumulative ``reachable_nodes`` as ``skip_nodes`` so
740
+ # subtrees already seen on a prior commit / branch are
741
+ # not re-fetched — structural sharing means the blobs
742
+ # under those subtrees are also already accounted for.
743
+ entries, new_nodes = Keyset(self.store, root=root).walk(
744
+ skip_nodes=reachable_nodes
745
+ )
740
746
  for entry in entries.values():
741
747
  reachable_blobs.add(entry.blob)
742
- reachable_nodes.update(nodes)
748
+ reachable_nodes.update(new_nodes)
743
749
 
744
750
  # Sweep phase: find orphaned commits via __commit_root__ scan.
745
751
  cutoff_time = time.time() - min_age
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kvgit
3
- Version: 0.2.1
3
+ Version: 0.2.2
4
4
  Summary: Versioned key-value store with git-like commit, branch, and merge semantics.
5
5
  Author: ashenfad
6
6
  License: MIT
@@ -49,7 +49,6 @@ Git-style versioning for your data. Commits, branches, and merges -- backed by a
49
49
  | **Commits** | Immutable, content-addressable snapshots with rollback |
50
50
  | **Branches** | Cheap forks with CAS-based optimistic concurrency |
51
51
  | **Three-way merge** | Auto-merges non-overlapping changes; pluggable merge fns for conflicts |
52
- | **Eviction** | High/low water rebase drops least-recently-used keys automatically |
53
52
  | **Pluggable backends** | In-memory, disk (diskcache), git (GitPython), IndexedDB (Pyodide/browser), or bring your own `KVStore` |
54
53
 
55
54
  ## Install
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "kvgit"
7
- version = "0.2.1"
7
+ version = "0.2.2"
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"
@@ -779,3 +779,116 @@ def test_materialize_is_walk_dot_zero():
779
779
  items = {f"k{i:03d}": f"v{i}".encode() for i in range(50)}
780
780
  h = Hamt(_store(), bucket_max=4).persist(items)
781
781
  assert h.materialize() == h.walk()[0]
782
+
783
+
784
+ # ---- walk(skip_nodes=...) cumulative seen-set ----
785
+
786
+
787
+ def test_walk_skip_nodes_root_short_circuits():
788
+ """Skipping the root yields nothing — entire tree pruned."""
789
+ items = {f"k{i:03d}": f"v{i}".encode() for i in range(20)}
790
+ h = Hamt(_store(), bucket_max=4).persist(items)
791
+ walked_items, walked_nodes = h.walk(skip_nodes={h.root})
792
+ assert walked_items == {}
793
+ assert walked_nodes == set()
794
+
795
+
796
+ def test_walk_skip_nodes_excludes_skipped_from_returned_set():
797
+ """Returned ``nodes`` excludes anything in ``skip_nodes``."""
798
+ items = {f"k{i:03d}": f"v{i}".encode() for i in range(60)}
799
+ h = Hamt(_store(), bucket_max=4).persist(items)
800
+ full_nodes = h.walk()[1]
801
+ # Pick an arbitrary non-root node to skip.
802
+ interior = next(iter(full_nodes - {h.root}))
803
+ _, walked_nodes = h.walk(skip_nodes={interior})
804
+ assert interior not in walked_nodes
805
+ # Other nodes are still visited (root at minimum).
806
+ assert h.root in walked_nodes
807
+
808
+
809
+ def test_walk_skip_nodes_skips_subtree_items():
810
+ """Items beneath a skipped subtree are not returned."""
811
+ items = {f"k{i:03d}": f"v{i}".encode() for i in range(80)}
812
+ h = Hamt(_store(), bucket_max=4).persist(items)
813
+
814
+ # Find a non-root branch node and the items reachable beneath it,
815
+ # then verify those items are absent when we skip that node.
816
+ full_items, full_nodes = h.walk()
817
+ interior_candidates = full_nodes - {h.root}
818
+ # Walk only the subtree rooted at the candidate to see what's under it.
819
+ for candidate in interior_candidates:
820
+ sub_items = Hamt(h.store, root=candidate, bucket_max=4).walk()[0]
821
+ if sub_items and len(sub_items) < len(full_items):
822
+ walked_items, _ = h.walk(skip_nodes={candidate})
823
+ for k in sub_items:
824
+ assert k not in walked_items
825
+ # And the items not in that subtree are still present.
826
+ for k in full_items:
827
+ if k not in sub_items:
828
+ assert walked_items[k] == full_items[k]
829
+ return
830
+ pytest.fail("expected to find at least one non-root subtree to skip")
831
+
832
+
833
+ def test_walk_skip_nodes_does_not_fetch_skipped():
834
+ """Skipped nodes should not be fetched from the store at all."""
835
+ store = _CountingMemory()
836
+ items = {f"k{i:04d}": f"v{i}".encode() for i in range(200)}
837
+ h = Hamt(store, bucket_max=4).persist(items)
838
+ full_nodes = h.walk()[1]
839
+
840
+ # Skip every node — the only fetches should be ones forced by
841
+ # the level-batched fetch *before* we filter (we filter first
842
+ # in the implementation, so even those should be zero).
843
+ store.reset_counts()
844
+ walked_items, walked_nodes = h.walk(skip_nodes=full_nodes)
845
+ assert walked_items == {}
846
+ assert walked_nodes == set()
847
+ assert store.get_calls == 0
848
+ assert store.get_many_calls == 0, (
849
+ f"expected zero fetches when skipping the whole tree, "
850
+ f"got {store.get_many_calls}"
851
+ )
852
+
853
+
854
+ def test_walk_skip_nodes_cumulative_across_shared_subtree():
855
+ """Two HAMTs sharing structure: walking the second with the
856
+ first's nodes as skip_nodes should fetch only what's new."""
857
+ base_items = {f"k{i:04d}": f"v{i}".encode() for i in range(120)}
858
+ store = _CountingMemory()
859
+ h1 = Hamt(store, bucket_max=4).persist(base_items)
860
+
861
+ # h2 shares the bulk of h1's structure — single-key delta.
862
+ h2 = h1.persist({"new-key": b"new-value"})
863
+ assert h2.root != h1.root
864
+
865
+ # First walk seeds the seen-set.
866
+ seen: set[str] = set()
867
+ items1, nodes1 = h1.walk(skip_nodes=seen)
868
+ seen |= nodes1
869
+
870
+ # Second walk: only the path from h2.root down to the changed
871
+ # leaf should be visited; the rest of the tree is in ``seen``.
872
+ store.reset_counts()
873
+ items2, nodes2 = h2.walk(skip_nodes=seen)
874
+ # New items: just the added key (and only items along visited
875
+ # paths — which by definition contain the new key).
876
+ assert "new-key" in items2
877
+ # Visited node count should be tiny relative to the full tree.
878
+ assert len(nodes2) < len(nodes1) // 4, (
879
+ f"expected shared-structure walk to revisit << {len(nodes1) // 4} "
880
+ f"nodes; got {len(nodes2)}"
881
+ )
882
+ # And no overlap between the two walks' returned node sets.
883
+ assert nodes1.isdisjoint(nodes2)
884
+
885
+
886
+ def test_walk_skip_nodes_none_matches_no_arg():
887
+ """Passing skip_nodes=None is identical to omitting it."""
888
+ items = {f"k{i:03d}": f"v{i}".encode() for i in range(40)}
889
+ h = Hamt(_store(), bucket_max=4).persist(items)
890
+ a_items, a_nodes = h.walk()
891
+ b_items, b_nodes = h.walk(skip_nodes=None)
892
+ c_items, c_nodes = h.walk(skip_nodes=set())
893
+ assert a_items == b_items == c_items
894
+ assert a_nodes == b_nodes == c_nodes
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