kvgit 0.4.0__tar.gz → 0.4.1__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 (53) hide show
  1. {kvgit-0.4.0 → kvgit-0.4.1}/PKG-INFO +1 -1
  2. {kvgit-0.4.0 → kvgit-0.4.1}/kvgit/hamt.py +21 -3
  3. {kvgit-0.4.0 → kvgit-0.4.1}/kvgit/repo.py +43 -15
  4. {kvgit-0.4.0 → kvgit-0.4.1}/kvgit/versioned/keyset.py +5 -0
  5. {kvgit-0.4.0 → kvgit-0.4.1}/kvgit.egg-info/PKG-INFO +1 -1
  6. {kvgit-0.4.0 → kvgit-0.4.1}/pyproject.toml +1 -1
  7. {kvgit-0.4.0 → kvgit-0.4.1}/tests/test_hamt.py +18 -0
  8. {kvgit-0.4.0 → kvgit-0.4.1}/tests/test_repo.py +82 -0
  9. {kvgit-0.4.0 → kvgit-0.4.1}/LICENSE +0 -0
  10. {kvgit-0.4.0 → kvgit-0.4.1}/README.md +0 -0
  11. {kvgit-0.4.0 → kvgit-0.4.1}/kvgit/__init__.py +0 -0
  12. {kvgit-0.4.0 → kvgit-0.4.1}/kvgit/_codec.py +0 -0
  13. {kvgit-0.4.0 → kvgit-0.4.1}/kvgit/_open.py +0 -0
  14. {kvgit-0.4.0 → kvgit-0.4.1}/kvgit/codecs/__init__.py +0 -0
  15. {kvgit-0.4.0 → kvgit-0.4.1}/kvgit/codecs/_hash.py +0 -0
  16. {kvgit-0.4.0 → kvgit-0.4.1}/kvgit/codecs/base.py +0 -0
  17. {kvgit-0.4.0 → kvgit-0.4.1}/kvgit/codecs/numpy.py +0 -0
  18. {kvgit-0.4.0 → kvgit-0.4.1}/kvgit/codecs/pandas.py +0 -0
  19. {kvgit-0.4.0 → kvgit-0.4.1}/kvgit/codecs/pickler.py +0 -0
  20. {kvgit-0.4.0 → kvgit-0.4.1}/kvgit/content_types.py +0 -0
  21. {kvgit-0.4.0 → kvgit-0.4.1}/kvgit/encoding.py +0 -0
  22. {kvgit-0.4.0 → kvgit-0.4.1}/kvgit/errors.py +0 -0
  23. {kvgit-0.4.0 → kvgit-0.4.1}/kvgit/kv/__init__.py +0 -0
  24. {kvgit-0.4.0 → kvgit-0.4.1}/kvgit/kv/base.py +0 -0
  25. {kvgit-0.4.0 → kvgit-0.4.1}/kvgit/kv/composite.py +0 -0
  26. {kvgit-0.4.0 → kvgit-0.4.1}/kvgit/kv/disk.py +0 -0
  27. {kvgit-0.4.0 → kvgit-0.4.1}/kvgit/kv/indexeddb.py +0 -0
  28. {kvgit-0.4.0 → kvgit-0.4.1}/kvgit/kv/memory.py +0 -0
  29. {kvgit-0.4.0 → kvgit-0.4.1}/kvgit/kv/postgres.py +0 -0
  30. {kvgit-0.4.0 → kvgit-0.4.1}/kvgit/merges.py +0 -0
  31. {kvgit-0.4.0 → kvgit-0.4.1}/kvgit/namespaced.py +0 -0
  32. {kvgit-0.4.0 → kvgit-0.4.1}/kvgit/py.typed +0 -0
  33. {kvgit-0.4.0 → kvgit-0.4.1}/kvgit/refs.py +0 -0
  34. {kvgit-0.4.0 → kvgit-0.4.1}/kvgit/versioned/__init__.py +0 -0
  35. {kvgit-0.4.0 → kvgit-0.4.1}/kvgit/versioned/base.py +0 -0
  36. {kvgit-0.4.0 → kvgit-0.4.1}/kvgit/versioned/helpers.py +0 -0
  37. {kvgit-0.4.0 → kvgit-0.4.1}/kvgit/versioned/kv.py +0 -0
  38. {kvgit-0.4.0 → kvgit-0.4.1}/kvgit/versioned/merge.py +0 -0
  39. {kvgit-0.4.0 → kvgit-0.4.1}/kvgit/versioned/protocol.py +0 -0
  40. {kvgit-0.4.0 → kvgit-0.4.1}/kvgit/worktree.py +0 -0
  41. {kvgit-0.4.0 → kvgit-0.4.1}/kvgit.egg-info/SOURCES.txt +0 -0
  42. {kvgit-0.4.0 → kvgit-0.4.1}/kvgit.egg-info/dependency_links.txt +0 -0
  43. {kvgit-0.4.0 → kvgit-0.4.1}/kvgit.egg-info/requires.txt +0 -0
  44. {kvgit-0.4.0 → kvgit-0.4.1}/kvgit.egg-info/top_level.txt +0 -0
  45. {kvgit-0.4.0 → kvgit-0.4.1}/setup.cfg +0 -0
  46. {kvgit-0.4.0 → kvgit-0.4.1}/tests/test_content_types.py +0 -0
  47. {kvgit-0.4.0 → kvgit-0.4.1}/tests/test_delete_branches.py +0 -0
  48. {kvgit-0.4.0 → kvgit-0.4.1}/tests/test_merge_policy.py +0 -0
  49. {kvgit-0.4.0 → kvgit-0.4.1}/tests/test_merges.py +0 -0
  50. {kvgit-0.4.0 → kvgit-0.4.1}/tests/test_namespaced.py +0 -0
  51. {kvgit-0.4.0 → kvgit-0.4.1}/tests/test_open.py +0 -0
  52. {kvgit-0.4.0 → kvgit-0.4.1}/tests/test_storage_v4.py +0 -0
  53. {kvgit-0.4.0 → kvgit-0.4.1}/tests/test_worktree.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kvgit
3
- Version: 0.4.0
3
+ Version: 0.4.1
4
4
  Summary: Versioned key-value store with git-like commit, branch, and merge semantics.
5
5
  Author: ashenfad
6
6
  License: MIT
@@ -108,9 +108,10 @@ class Hamt:
108
108
  self.prefix = prefix
109
109
  self.bucket_max = bucket_max
110
110
  self.pending = pending if pending is not None else {}
111
- # Stored nodes already read for an update in progress, keyed like
112
- # ``pending``. Filled only on the private view ``updated`` works
113
- # through, so a shared ``Hamt`` never has two updates writing it.
111
+ # Stored nodes already read, keyed like ``pending``: by an update
112
+ # in progress, on the private view ``updated`` works through, and
113
+ # by ``get_many``. A node never changes once stored, so nothing
114
+ # here goes stale.
114
115
  self._fetched: dict[str, bytes] = {}
115
116
 
116
117
  # ---- internal helpers ----
@@ -179,6 +180,23 @@ class Hamt:
179
180
  node_hash = node["children"][chunk]
180
181
  depth += 1
181
182
 
183
+ def get_many(self, keys: Iterable[str]) -> dict[str, bytes]:
184
+ """The values of the keys that are present, as ``{key: value}``.
185
+
186
+ Reads the paths to all of them level by level, one batched read
187
+ per level, rather than walking the tree once per key. The nodes
188
+ read stay cached on this view, so later lookups through it reuse
189
+ them.
190
+ """
191
+ keys = list(dict.fromkeys(keys))
192
+ self._prefetch_paths(keys, [])
193
+ found: dict[str, bytes] = {}
194
+ for key in keys:
195
+ value = self.get(key)
196
+ if value is not None:
197
+ found[key] = value
198
+ return found
199
+
182
200
  def __contains__(self, key: str) -> bool:
183
201
  return self.get(key) is not None
184
202
 
@@ -305,14 +305,6 @@ class Repo:
305
305
  raise UnknownCommitError(f"Commit '{target}' does not exist")
306
306
  return target
307
307
 
308
- def _pointers(self, commit: str) -> dict[str, str]:
309
- """A commit's keyset as key -> blob pointer."""
310
- root_raw = self._store.get(COMMIT_ROOT % commit)
311
- if root_raw is None:
312
- raise UnknownCommitError(f"Commit '{commit}' does not exist")
313
- entries = Keyset(self._store, root=loads(root_raw)).materialize()
314
- return {key: entry.blob for key, entry in entries.items()}
315
-
316
308
 
317
309
  class Snapshot(Mapping[str, Any]):
318
310
  """The state of the repository at one commit, read-only.
@@ -324,23 +316,59 @@ class Snapshot(Mapping[str, Any]):
324
316
  def __init__(self, repo: Repo, commit: str) -> None:
325
317
  self._repo = repo
326
318
  self.commit = commit
319
+ self._keyset: Keyset | None = None
320
+ # The whole keyset as key -> blob pointer, once something iterates
321
+ # it; until then, reads look keys up in the tree one batch at a
322
+ # time and remember what they found (None: absent).
327
323
  self._pointers: dict[str, str] | None = None
324
+ self._looked_up: dict[str, str | None] = {}
328
325
 
329
326
  def __repr__(self) -> str:
330
327
  return f"Snapshot(commit={self.commit[:8]}...)"
331
328
 
329
+ def _tree(self) -> Keyset:
330
+ if self._keyset is None:
331
+ root_raw = self._repo.store.get(COMMIT_ROOT % self.commit)
332
+ if root_raw is None:
333
+ raise UnknownCommitError(f"Commit '{self.commit}' does not exist")
334
+ self._keyset = Keyset(self._repo.store, root=loads(root_raw))
335
+ return self._keyset
336
+
332
337
  def _index(self) -> dict[str, str]:
338
+ """The whole keyset, read once, for iteration and length."""
333
339
  if self._pointers is None:
334
- self._pointers = self._repo._pointers(self.commit)
340
+ entries = self._tree().materialize()
341
+ self._pointers = {key: entry.blob for key, entry in entries.items()}
335
342
  return self._pointers
336
343
 
344
+ def _pointers_of(self, keys: tuple[str, ...]) -> dict[str, str]:
345
+ """The blob pointers of those keys that exist.
346
+
347
+ Once the whole keyset has been read, from that; before, by looking
348
+ just these keys up in the tree — its depth in batched reads, not
349
+ the whole keyset — so reading a few keys at a commit stays cheap
350
+ however many the commit holds.
351
+ """
352
+ if self._pointers is not None:
353
+ return {k: self._pointers[k] for k in keys if k in self._pointers}
354
+ unknown = [k for k in dict.fromkeys(keys) if k not in self._looked_up]
355
+ if unknown:
356
+ found = self._tree().get_many(unknown)
357
+ for key in unknown:
358
+ entry = found.get(key)
359
+ self._looked_up[key] = None if entry is None else entry.blob
360
+ return {
361
+ key: pointer
362
+ for key in keys
363
+ if (pointer := self._looked_up.get(key)) is not None
364
+ }
365
+
337
366
  def _raw_many(self, keys: tuple[str, ...]) -> dict[str, bytes]:
338
- index = self._index()
339
367
  wanted: dict[str, list[str]] = {}
340
- for key in keys:
341
- pointer = index.get(key)
342
- if pointer is not None:
343
- wanted.setdefault(pointer, []).append(key)
368
+ for key, pointer in self._pointers_of(keys).items():
369
+ wanted.setdefault(pointer, []).append(key)
370
+ if not wanted:
371
+ return {}
344
372
  found = self._repo.store.get_many(wanted.keys())
345
373
  return {key: raw for pointer, raw in found.items() for key in wanted[pointer]}
346
374
 
@@ -362,7 +390,7 @@ class Snapshot(Mapping[str, Any]):
362
390
  return len(self._index())
363
391
 
364
392
  def __contains__(self, key: object) -> bool:
365
- return isinstance(key, str) and key in self._index()
393
+ return isinstance(key, str) and bool(self._pointers_of((key,)))
366
394
 
367
395
  @property
368
396
  def raw(self) -> RawSnapshot:
@@ -155,6 +155,11 @@ class Keyset:
155
155
  return None
156
156
  return decode_entry(raw)
157
157
 
158
+ def get_many(self, keys: Iterable[str]) -> dict[str, KeysetEntry]:
159
+ """The entries of the keys that are present, reading their paths
160
+ in one batched read per tree level; see ``Hamt.get_many``."""
161
+ return {k: decode_entry(v) for k, v in self._hamt.get_many(keys).items()}
162
+
158
163
  def get_blob(self, key: str) -> str | None:
159
164
  """Shortcut: just the blob pointer, no meta."""
160
165
  entry = self.get(key)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kvgit
3
- Version: 0.4.0
3
+ Version: 0.4.1
4
4
  Summary: Versioned key-value store with git-like commit, branch, and merge semantics.
5
5
  Author: ashenfad
6
6
  License: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "kvgit"
7
- version = "0.4.0"
7
+ version = "0.4.1"
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"
@@ -954,3 +954,21 @@ def test_an_update_leaves_the_shared_view_untouched():
954
954
  h = Hamt(_store(), bucket_max=2).persist({f"k{i}": b"v" for i in range(50)})
955
955
  h.updated({"x": b"1"}, ["k1"])
956
956
  assert h._fetched == {}
957
+
958
+
959
+ @pytest.mark.parametrize("seed", range(20))
960
+ def test_get_many_matches_get(seed):
961
+ rng = random.Random(seed)
962
+ items = {f"k{i}": b"%d" % i for i in range(rng.randrange(0, 300))}
963
+ h = Hamt(_store(), bucket_max=rng.choice([1, 2, 8])).persist(items)
964
+ keys = [f"k{rng.randrange(0, 400)}" for _ in range(rng.randrange(0, 80))]
965
+ assert h.get_many(keys) == {k: h.get(k) for k in keys if h.get(k) is not None}
966
+
967
+
968
+ def test_get_many_reads_by_level():
969
+ store = _CountingStore()
970
+ h = Hamt(store, bucket_max=8).persist({f"k{i}": b"v" for i in range(5000)})
971
+ store.gets = store.batches = 0
972
+ found = h.get_many([f"k{i}" for i in range(0, 5000, 10)])
973
+ assert len(found) == 500
974
+ assert store.gets == 0 and store.batches <= 8
@@ -1,6 +1,7 @@
1
1
  """Tests for Repo: branches, tags, the commit graph, snapshots and gc."""
2
2
 
3
3
  import os
4
+ import random
4
5
  import tempfile
5
6
  from collections.abc import Mapping
6
7
 
@@ -415,6 +416,87 @@ class TestSnapshots:
415
416
  repo.snapshot(tag="v1")
416
417
 
417
418
 
419
+ class TestSnapshotPointReads:
420
+ """Reading a few keys at a commit costs the tree's depth in batched
421
+ reads, not the whole keyset; iterating reads the keyset once."""
422
+
423
+ class Counting(Memory):
424
+ def __init__(self):
425
+ super().__init__()
426
+ self.calls = 0
427
+ self.nodes = 0
428
+
429
+ def get(self, key):
430
+ self.calls += 1
431
+ self.nodes += key.startswith("kvgit:keyset:")
432
+ return super().get(key)
433
+
434
+ def get_many(self, *args):
435
+ keys = self._normalize_keys(args)
436
+ self.calls += 1
437
+ self.nodes += sum(k.startswith("kvgit:keyset:") for k in keys)
438
+ return super().get_many(*args)
439
+
440
+ def _big(self):
441
+ store = self.Counting()
442
+ repo = Repo(store)
443
+ wt = repo.worktree("main", create=True)
444
+ for i in range(5000):
445
+ wt[f"k{i}"] = i
446
+ wt.commit()
447
+ total = sum(1 for k in store.keys("kvgit:keyset:"))
448
+ store.calls = store.nodes = 0
449
+ return store, repo, wt, total
450
+
451
+ def test_one_key_reads_a_path_not_the_keyset(self):
452
+ store, repo, _, total = self._big()
453
+ snap = repo.snapshot(branch="main")
454
+ assert snap["k4321"] == 4321
455
+ assert store.calls <= 8
456
+ assert store.nodes < 10 < total
457
+
458
+ def test_many_keys_read_by_level_in_batches(self):
459
+ store, repo, _, total = self._big()
460
+ snap = repo.snapshot(branch="main")
461
+ keys = [f"k{i}" for i in range(0, 5000, 100)] + ["absent"]
462
+ found = snap.get_many(*keys)
463
+ assert found == {k: int(k[1:]) for k in keys if k != "absent"}
464
+ assert store.calls <= 8
465
+ assert store.nodes < total
466
+
467
+ def test_a_key_read_twice_is_looked_up_once(self):
468
+ store, repo, _, _ = self._big()
469
+ snap = repo.snapshot(branch="main")
470
+ snap["k1"]
471
+ before = store.calls
472
+ assert snap["k1"] == 1 and "k1" in snap and snap.raw["k1"]
473
+ assert store.calls - before == 2 # the blob, twice; no tree reads
474
+ assert "nope" not in snap
475
+ with pytest.raises(KeyError):
476
+ snap["nope"]
477
+
478
+ def test_point_reads_agree_with_the_whole_keyset(self):
479
+ wt = worktree()
480
+ rng = random.Random(7)
481
+ for i in range(300):
482
+ wt[f"k{i}"] = i
483
+ wt.commit()
484
+ for i in rng.sample(range(300), 60):
485
+ del wt[f"k{i}"]
486
+ wt.commit()
487
+ repo = wt.repo
488
+ full = dict(repo.snapshot(commit=wt.head))
489
+ probe = [f"k{i}" for i in range(0, 320, 7)]
490
+ lazy = repo.snapshot(commit=wt.head)
491
+ assert {k: lazy.get(k) for k in probe} == {k: full.get(k) for k in probe}
492
+ assert [k in lazy for k in probe] == [k in full for k in probe]
493
+ assert lazy.get_many(*probe) == {k: full[k] for k in probe if k in full}
494
+ assert dict(lazy) == full # iterating after point reads
495
+ assert {k: lazy.get(k) for k in probe} == {k: full.get(k) for k in probe}
496
+ raw = repo.snapshot(commit=wt.head).raw
497
+ assert set(raw.get_many(*probe)) == {k for k in probe if k in full}
498
+
499
+
418
500
  class TestGc:
419
501
  def test_gc_reclaims_a_deleted_branch(self):
420
502
  repo, _, dev, *_ = repo_with_history()
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
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes