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.
- {kvgit-0.4.0 → kvgit-0.4.1}/PKG-INFO +1 -1
- {kvgit-0.4.0 → kvgit-0.4.1}/kvgit/hamt.py +21 -3
- {kvgit-0.4.0 → kvgit-0.4.1}/kvgit/repo.py +43 -15
- {kvgit-0.4.0 → kvgit-0.4.1}/kvgit/versioned/keyset.py +5 -0
- {kvgit-0.4.0 → kvgit-0.4.1}/kvgit.egg-info/PKG-INFO +1 -1
- {kvgit-0.4.0 → kvgit-0.4.1}/pyproject.toml +1 -1
- {kvgit-0.4.0 → kvgit-0.4.1}/tests/test_hamt.py +18 -0
- {kvgit-0.4.0 → kvgit-0.4.1}/tests/test_repo.py +82 -0
- {kvgit-0.4.0 → kvgit-0.4.1}/LICENSE +0 -0
- {kvgit-0.4.0 → kvgit-0.4.1}/README.md +0 -0
- {kvgit-0.4.0 → kvgit-0.4.1}/kvgit/__init__.py +0 -0
- {kvgit-0.4.0 → kvgit-0.4.1}/kvgit/_codec.py +0 -0
- {kvgit-0.4.0 → kvgit-0.4.1}/kvgit/_open.py +0 -0
- {kvgit-0.4.0 → kvgit-0.4.1}/kvgit/codecs/__init__.py +0 -0
- {kvgit-0.4.0 → kvgit-0.4.1}/kvgit/codecs/_hash.py +0 -0
- {kvgit-0.4.0 → kvgit-0.4.1}/kvgit/codecs/base.py +0 -0
- {kvgit-0.4.0 → kvgit-0.4.1}/kvgit/codecs/numpy.py +0 -0
- {kvgit-0.4.0 → kvgit-0.4.1}/kvgit/codecs/pandas.py +0 -0
- {kvgit-0.4.0 → kvgit-0.4.1}/kvgit/codecs/pickler.py +0 -0
- {kvgit-0.4.0 → kvgit-0.4.1}/kvgit/content_types.py +0 -0
- {kvgit-0.4.0 → kvgit-0.4.1}/kvgit/encoding.py +0 -0
- {kvgit-0.4.0 → kvgit-0.4.1}/kvgit/errors.py +0 -0
- {kvgit-0.4.0 → kvgit-0.4.1}/kvgit/kv/__init__.py +0 -0
- {kvgit-0.4.0 → kvgit-0.4.1}/kvgit/kv/base.py +0 -0
- {kvgit-0.4.0 → kvgit-0.4.1}/kvgit/kv/composite.py +0 -0
- {kvgit-0.4.0 → kvgit-0.4.1}/kvgit/kv/disk.py +0 -0
- {kvgit-0.4.0 → kvgit-0.4.1}/kvgit/kv/indexeddb.py +0 -0
- {kvgit-0.4.0 → kvgit-0.4.1}/kvgit/kv/memory.py +0 -0
- {kvgit-0.4.0 → kvgit-0.4.1}/kvgit/kv/postgres.py +0 -0
- {kvgit-0.4.0 → kvgit-0.4.1}/kvgit/merges.py +0 -0
- {kvgit-0.4.0 → kvgit-0.4.1}/kvgit/namespaced.py +0 -0
- {kvgit-0.4.0 → kvgit-0.4.1}/kvgit/py.typed +0 -0
- {kvgit-0.4.0 → kvgit-0.4.1}/kvgit/refs.py +0 -0
- {kvgit-0.4.0 → kvgit-0.4.1}/kvgit/versioned/__init__.py +0 -0
- {kvgit-0.4.0 → kvgit-0.4.1}/kvgit/versioned/base.py +0 -0
- {kvgit-0.4.0 → kvgit-0.4.1}/kvgit/versioned/helpers.py +0 -0
- {kvgit-0.4.0 → kvgit-0.4.1}/kvgit/versioned/kv.py +0 -0
- {kvgit-0.4.0 → kvgit-0.4.1}/kvgit/versioned/merge.py +0 -0
- {kvgit-0.4.0 → kvgit-0.4.1}/kvgit/versioned/protocol.py +0 -0
- {kvgit-0.4.0 → kvgit-0.4.1}/kvgit/worktree.py +0 -0
- {kvgit-0.4.0 → kvgit-0.4.1}/kvgit.egg-info/SOURCES.txt +0 -0
- {kvgit-0.4.0 → kvgit-0.4.1}/kvgit.egg-info/dependency_links.txt +0 -0
- {kvgit-0.4.0 → kvgit-0.4.1}/kvgit.egg-info/requires.txt +0 -0
- {kvgit-0.4.0 → kvgit-0.4.1}/kvgit.egg-info/top_level.txt +0 -0
- {kvgit-0.4.0 → kvgit-0.4.1}/setup.cfg +0 -0
- {kvgit-0.4.0 → kvgit-0.4.1}/tests/test_content_types.py +0 -0
- {kvgit-0.4.0 → kvgit-0.4.1}/tests/test_delete_branches.py +0 -0
- {kvgit-0.4.0 → kvgit-0.4.1}/tests/test_merge_policy.py +0 -0
- {kvgit-0.4.0 → kvgit-0.4.1}/tests/test_merges.py +0 -0
- {kvgit-0.4.0 → kvgit-0.4.1}/tests/test_namespaced.py +0 -0
- {kvgit-0.4.0 → kvgit-0.4.1}/tests/test_open.py +0 -0
- {kvgit-0.4.0 → kvgit-0.4.1}/tests/test_storage_v4.py +0 -0
- {kvgit-0.4.0 → kvgit-0.4.1}/tests/test_worktree.py +0 -0
|
@@ -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
|
|
112
|
-
#
|
|
113
|
-
#
|
|
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
|
-
|
|
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
|
|
342
|
-
|
|
343
|
-
|
|
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
|
|
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)
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "kvgit"
|
|
7
|
-
version = "0.4.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|