kvgit 0.4.2__tar.gz → 0.4.3__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 (57) hide show
  1. {kvgit-0.4.2 → kvgit-0.4.3}/PKG-INFO +4 -2
  2. {kvgit-0.4.2 → kvgit-0.4.3}/README.md +3 -1
  3. {kvgit-0.4.2 → kvgit-0.4.3}/kvgit/__init__.py +2 -0
  4. {kvgit-0.4.2 → kvgit-0.4.3}/kvgit/_codec.py +29 -0
  5. {kvgit-0.4.2 → kvgit-0.4.3}/kvgit/_open.py +10 -3
  6. {kvgit-0.4.2 → kvgit-0.4.3}/kvgit/cache.py +86 -0
  7. {kvgit-0.4.2 → kvgit-0.4.3}/kvgit/errors.py +22 -0
  8. {kvgit-0.4.2 → kvgit-0.4.3}/kvgit/refs.py +17 -0
  9. {kvgit-0.4.2 → kvgit-0.4.3}/kvgit/repo.py +17 -1
  10. {kvgit-0.4.2 → kvgit-0.4.3}/kvgit/versioned/base.py +207 -44
  11. {kvgit-0.4.2 → kvgit-0.4.3}/kvgit/versioned/kv.py +163 -60
  12. {kvgit-0.4.2 → kvgit-0.4.3}/kvgit/worktree.py +50 -15
  13. {kvgit-0.4.2 → kvgit-0.4.3}/kvgit.egg-info/PKG-INFO +4 -2
  14. {kvgit-0.4.2 → kvgit-0.4.3}/kvgit.egg-info/SOURCES.txt +2 -0
  15. {kvgit-0.4.2 → kvgit-0.4.3}/pyproject.toml +1 -1
  16. kvgit-0.4.3/tests/test_commit_on_moved.py +190 -0
  17. {kvgit-0.4.2 → kvgit-0.4.3}/tests/test_repo.py +151 -0
  18. kvgit-0.4.3/tests/test_value_cache.py +222 -0
  19. {kvgit-0.4.2 → kvgit-0.4.3}/tests/test_worktree.py +29 -0
  20. {kvgit-0.4.2 → kvgit-0.4.3}/LICENSE +0 -0
  21. {kvgit-0.4.2 → kvgit-0.4.3}/kvgit/codecs/__init__.py +0 -0
  22. {kvgit-0.4.2 → kvgit-0.4.3}/kvgit/codecs/_hash.py +0 -0
  23. {kvgit-0.4.2 → kvgit-0.4.3}/kvgit/codecs/base.py +0 -0
  24. {kvgit-0.4.2 → kvgit-0.4.3}/kvgit/codecs/numpy.py +0 -0
  25. {kvgit-0.4.2 → kvgit-0.4.3}/kvgit/codecs/pandas.py +0 -0
  26. {kvgit-0.4.2 → kvgit-0.4.3}/kvgit/codecs/pickler.py +0 -0
  27. {kvgit-0.4.2 → kvgit-0.4.3}/kvgit/content_types.py +0 -0
  28. {kvgit-0.4.2 → kvgit-0.4.3}/kvgit/encoding.py +0 -0
  29. {kvgit-0.4.2 → kvgit-0.4.3}/kvgit/hamt.py +0 -0
  30. {kvgit-0.4.2 → kvgit-0.4.3}/kvgit/kv/__init__.py +0 -0
  31. {kvgit-0.4.2 → kvgit-0.4.3}/kvgit/kv/base.py +0 -0
  32. {kvgit-0.4.2 → kvgit-0.4.3}/kvgit/kv/composite.py +0 -0
  33. {kvgit-0.4.2 → kvgit-0.4.3}/kvgit/kv/disk.py +0 -0
  34. {kvgit-0.4.2 → kvgit-0.4.3}/kvgit/kv/indexeddb.py +0 -0
  35. {kvgit-0.4.2 → kvgit-0.4.3}/kvgit/kv/memory.py +0 -0
  36. {kvgit-0.4.2 → kvgit-0.4.3}/kvgit/kv/postgres.py +0 -0
  37. {kvgit-0.4.2 → kvgit-0.4.3}/kvgit/merges.py +0 -0
  38. {kvgit-0.4.2 → kvgit-0.4.3}/kvgit/namespaced.py +0 -0
  39. {kvgit-0.4.2 → kvgit-0.4.3}/kvgit/py.typed +0 -0
  40. {kvgit-0.4.2 → kvgit-0.4.3}/kvgit/versioned/__init__.py +0 -0
  41. {kvgit-0.4.2 → kvgit-0.4.3}/kvgit/versioned/helpers.py +0 -0
  42. {kvgit-0.4.2 → kvgit-0.4.3}/kvgit/versioned/keyset.py +0 -0
  43. {kvgit-0.4.2 → kvgit-0.4.3}/kvgit/versioned/merge.py +0 -0
  44. {kvgit-0.4.2 → kvgit-0.4.3}/kvgit/versioned/protocol.py +0 -0
  45. {kvgit-0.4.2 → kvgit-0.4.3}/kvgit.egg-info/dependency_links.txt +0 -0
  46. {kvgit-0.4.2 → kvgit-0.4.3}/kvgit.egg-info/requires.txt +0 -0
  47. {kvgit-0.4.2 → kvgit-0.4.3}/kvgit.egg-info/top_level.txt +0 -0
  48. {kvgit-0.4.2 → kvgit-0.4.3}/setup.cfg +0 -0
  49. {kvgit-0.4.2 → kvgit-0.4.3}/tests/test_cache.py +0 -0
  50. {kvgit-0.4.2 → kvgit-0.4.3}/tests/test_content_types.py +0 -0
  51. {kvgit-0.4.2 → kvgit-0.4.3}/tests/test_delete_branches.py +0 -0
  52. {kvgit-0.4.2 → kvgit-0.4.3}/tests/test_hamt.py +0 -0
  53. {kvgit-0.4.2 → kvgit-0.4.3}/tests/test_merge_policy.py +0 -0
  54. {kvgit-0.4.2 → kvgit-0.4.3}/tests/test_merges.py +0 -0
  55. {kvgit-0.4.2 → kvgit-0.4.3}/tests/test_namespaced.py +0 -0
  56. {kvgit-0.4.2 → kvgit-0.4.3}/tests/test_open.py +0 -0
  57. {kvgit-0.4.2 → kvgit-0.4.3}/tests/test_storage_v4.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kvgit
3
- Version: 0.4.2
3
+ Version: 0.4.3
4
4
  Summary: Versioned key-value store with git-like commit, branch, and merge semantics.
5
5
  Author: ashenfad
6
6
  License: MIT
@@ -144,7 +144,9 @@ with Repo(Disk("/tmp/kvgit-demo")) as on_disk:
144
144
  | `git gc [--prune=now]` | `repo.gc([min_age=0])` |
145
145
 
146
146
  Where it differs: there is no index; several worktrees can hold one
147
- branch, and their commits race and merge rather than being refused; a
147
+ branch, and their commits race and merge rather than being refused
148
+ (`commit(on_moved="raise")` refuses instead, raising
149
+ `BranchMovedError`, for data whose keys aren't independent); a
148
150
  conflicted merge raises `MergeConflict` and changes nothing (unless a
149
151
  merge function such as `text_merge` writes markers into the value);
150
152
  commit hashes are not git's, and there are no remotes.
@@ -90,7 +90,9 @@ with Repo(Disk("/tmp/kvgit-demo")) as on_disk:
90
90
  | `git gc [--prune=now]` | `repo.gc([min_age=0])` |
91
91
 
92
92
  Where it differs: there is no index; several worktrees can hold one
93
- branch, and their commits race and merge rather than being refused; a
93
+ branch, and their commits race and merge rather than being refused
94
+ (`commit(on_moved="raise")` refuses instead, raising
95
+ `BranchMovedError`, for data whose keys aren't independent); a
94
96
  conflicted merge raises `MergeConflict` and changes nothing (unless a
95
97
  merge function such as `text_merge` writes markers into the value);
96
98
  commit hashes are not git's, and there are no remotes.
@@ -4,6 +4,7 @@ from ._open import open
4
4
  from .content_types import MergeFn, counter, last_writer_wins, text_merge
5
5
  from .errors import (
6
6
  BranchExistsError,
7
+ BranchMovedError,
7
8
  ConcurrencyError,
8
9
  CorruptHeadError,
9
10
  GcBusy,
@@ -40,6 +41,7 @@ from .worktree import Status, Worktree
40
41
  __all__ = [
41
42
  "ROOT_COMMIT",
42
43
  "BranchExistsError",
44
+ "BranchMovedError",
43
45
  "Branches",
44
46
  "CantMark",
45
47
  "Commit",
@@ -61,6 +61,27 @@ class _ChunkReader:
61
61
  return None
62
62
 
63
63
 
64
+ class _CountingReader:
65
+ """A chunk reader that tallies the bytes of the chunks it returns."""
66
+
67
+ def __init__(self, reader: _ChunkReader) -> None:
68
+ self._reader = reader
69
+ self.nbytes = 0
70
+
71
+ def get(self, ref: str) -> bytes:
72
+ raw = self._reader.get(ref)
73
+ self.nbytes += len(raw)
74
+ return raw
75
+
76
+ def get_many(self, refs):
77
+ found = self._reader.get_many(refs)
78
+ self.nbytes += sum(len(v) for v in found.values())
79
+ return found
80
+
81
+ def prefetch(self, refs) -> None:
82
+ self._reader.prefetch(refs)
83
+
84
+
64
85
  def _is_chunk_aware(fn) -> bool:
65
86
  """Whether an encoder/decoder takes a required sink/reader argument.
66
87
 
@@ -148,6 +169,14 @@ class Codec:
148
169
  return self.decoder(raw, self._reader)
149
170
  return self.decoder(raw)
150
171
 
172
+ def decode_sized(self, raw: bytes) -> tuple[Any, int]:
173
+ """A value, and the bytes it was decoded from: its blob plus any
174
+ chunks a chunked decoder read, which the value usually keeps."""
175
+ if self.decoder_chunked:
176
+ reader = _CountingReader(self._reader) # type: ignore[arg-type]
177
+ return self.decoder(raw, reader), len(raw) + reader.nbytes
178
+ return self.decoder(raw), len(raw)
179
+
151
180
  def encode_merged(self, value: Any) -> bytes:
152
181
  """Encode a value a merge function produced.
153
182
 
@@ -3,7 +3,7 @@
3
3
  from typing import Literal
4
4
 
5
5
  from ._codec import CodecSpec
6
- from .cache import DEFAULT_CACHE_BYTES
6
+ from .cache import DEFAULT_CACHE_BYTES, DEFAULT_VALUE_CACHE_BYTES
7
7
  from .kv.base import KVStore
8
8
  from .kv.memory import Memory
9
9
  from .repo import Repo
@@ -41,6 +41,7 @@ def open(
41
41
  branch: str = "main",
42
42
  codec: CodecSpec = "pickle",
43
43
  cache_bytes: int = DEFAULT_CACHE_BYTES,
44
+ value_cache_bytes: int = DEFAULT_VALUE_CACHE_BYTES,
44
45
  ) -> Worktree:
45
46
  """Open (or create) a store, and a worktree on ``branch`` of it.
46
47
 
@@ -61,8 +62,14 @@ def open(
61
62
  codec: How values are stored; see :class:`Repo`.
62
63
  cache_bytes: Memory for what the store never changes; see
63
64
  :class:`Repo`.
65
+ value_cache_bytes: Memory for the values the worktree reads;
66
+ see :class:`Repo`.
64
67
  """
65
68
  backend = _make_backend(kind, path=path, db_name=db_name)
66
- return Repo(backend, codec=codec, cache_bytes=cache_bytes).worktree(
67
- branch, create=True
69
+ repo = Repo(
70
+ backend,
71
+ codec=codec,
72
+ cache_bytes=cache_bytes,
73
+ value_cache_bytes=value_cache_bytes,
68
74
  )
75
+ return repo.worktree(branch, create=True)
@@ -19,6 +19,10 @@ commit exists — what ``snapshot(commit=)`` and a tag's ``dangling``
19
19
  ask — so it is always read, and whatever is reached through a root the
20
20
  store still holds is safe to answer from here. Absence is never cached
21
21
  either: a key missing now may be written by the next commit.
22
+
23
+ A worktree keeps a second, separate memory: the decoded values it has
24
+ read at its head (:class:`ValueCache`). That one holds blobs, so it is
25
+ per worktree, emptied whenever the head moves, and bounded in bytes.
22
26
  """
23
27
 
24
28
  from __future__ import annotations
@@ -41,6 +45,9 @@ from .versioned.kv import (
41
45
  DEFAULT_CACHE_BYTES = 32 * 1024 * 1024
42
46
  """The default budget: tens of thousands of tree nodes."""
43
47
 
48
+ DEFAULT_VALUE_CACHE_BYTES = 32 * 1024 * 1024
49
+ """The default budget for each worktree's decoded values."""
50
+
44
51
  _CACHEABLE_PREFIXES = (
45
52
  Keyset.DEFAULT_PREFIX,
46
53
  PARENT_COMMIT.replace("%s", ""),
@@ -281,3 +288,82 @@ class CachedStore(KVStore):
281
288
  if GC_LEASE_KEY in writes:
282
289
  # This process's own sweep taking or releasing the lease.
283
290
  self.cache.saw_lease(writes[GC_LEASE_KEY])
291
+
292
+
293
+ class ValueCache:
294
+ """A worktree's decoded values of committed keys, least recently
295
+ used first out, bounded in bytes.
296
+
297
+ It holds only what the worktree's head already stores, so anything
298
+ it lets go is read again from the store; pending writes are kept by
299
+ the worktree itself and never pass through here. A value's cost is
300
+ the bytes it was decoded from (its blob, plus any chunks a chunked
301
+ codec read), and a value costing more than a quarter of the budget
302
+ is not held at all, so one large read cannot push out everything
303
+ else and the largest values, the ones a caller may want only a few
304
+ bytes of, are not kept alive by the cache. A budget of ``0`` holds
305
+ nothing.
306
+
307
+ Safe to use from several threads.
308
+ """
309
+
310
+ def __init__(self, max_bytes: int = DEFAULT_VALUE_CACHE_BYTES) -> None:
311
+ if max_bytes < 0:
312
+ raise ValueError(f"max_bytes must not be negative, got {max_bytes}")
313
+ self.max_bytes = max_bytes
314
+ self._entries: OrderedDict[str, tuple[Any, int]] = OrderedDict()
315
+ self._bytes = 0
316
+ self._lock = threading.Lock()
317
+
318
+ def __repr__(self) -> str:
319
+ return (
320
+ f"ValueCache({len(self._entries)} entries, "
321
+ f"{self._bytes}/{self.max_bytes} bytes)"
322
+ )
323
+
324
+ def __len__(self) -> int:
325
+ return len(self._entries)
326
+
327
+ def __contains__(self, key: object) -> bool:
328
+ return key in self._entries
329
+
330
+ @property
331
+ def size_bytes(self) -> int:
332
+ """What the values held now cost, by the budget's estimate."""
333
+ return self._bytes
334
+
335
+ @property
336
+ def max_value_bytes(self) -> int:
337
+ """The largest cost of a value that is held."""
338
+ return self.max_bytes // 4
339
+
340
+ def get(self, key: str, default: Any = None) -> Any:
341
+ """The value held for ``key``, marked as just used; ``default``
342
+ if none is."""
343
+ with self._lock:
344
+ entry = self._entries.get(key)
345
+ if entry is None:
346
+ return default
347
+ self._entries.move_to_end(key)
348
+ return entry[0]
349
+
350
+ def remember(self, key: str, value: Any, nbytes: int) -> None:
351
+ """Hold ``value``, decoded from ``nbytes`` bytes, evicting the
352
+ least recently used to fit."""
353
+ cost = nbytes + len(key) + _ENTRY_OVERHEAD
354
+ if cost > self.max_value_bytes:
355
+ return
356
+ with self._lock:
357
+ old = self._entries.pop(key, None)
358
+ if old is not None:
359
+ self._bytes -= old[1]
360
+ self._entries[key] = (value, cost)
361
+ self._bytes += cost
362
+ while self._bytes > self.max_bytes:
363
+ _, (_, evicted) = self._entries.popitem(last=False)
364
+ self._bytes -= evicted
365
+
366
+ def clear(self) -> None:
367
+ with self._lock:
368
+ self._entries.clear()
369
+ self._bytes = 0
@@ -18,6 +18,28 @@ class ConcurrencyError(KvgitError):
18
18
  """
19
19
 
20
20
 
21
+ class BranchMovedError(ConcurrencyError):
22
+ """Raised by a commit made with ``on_moved="raise"`` that found its
23
+ branch moved: the branch's head is no longer the commit the handle
24
+ is based on. Nothing was published and pending changes are kept;
25
+ refresh and redo the work, or give up.
26
+
27
+ Attributes:
28
+ branch: The branch.
29
+ base: The commit the handle is based on, which the branch no
30
+ longer names.
31
+ head: The branch's head as the commit found it.
32
+ """
33
+
34
+ def __init__(self, branch: str, base: str, head: str) -> None:
35
+ self.branch = branch
36
+ self.base = base
37
+ self.head = head
38
+ super().__init__(
39
+ f"Branch '{branch}' moved from {base} to {head}; nothing was committed"
40
+ )
41
+
42
+
21
43
  class MergeConflict(KvgitError):
22
44
  """Raised when a three-way merge encounters unresolvable conflicts.
23
45
 
@@ -77,8 +77,19 @@ class Branches(Mapping[str, str]):
77
77
  ``gc``; worktrees still holding it read from their heads and raise
78
78
  :class:`UnknownBranchError` on commit.
79
79
 
80
+ The removal is conditional on the head this call read: if another
81
+ writer moves the branch, or deletes and recreates it, in between,
82
+ nothing is removed and the call raises rather than retrying, so a
83
+ newer head is never deleted on the strength of an older one. A
84
+ branch made again at the same commit is indistinguishable from
85
+ the one read, and is deleted, as with git's
86
+ ``update-ref -d <ref> <old-oid>``.
87
+
80
88
  Raises:
81
89
  UnknownBranchError: if there is no such branch.
90
+ ConcurrencyError: if the branch changed while being deleted.
91
+ ValueError: if ``name`` is empty or in the reserved tag
92
+ namespace.
82
93
  """
83
94
  _kv.delete_branch(self._repo._store, name)
84
95
 
@@ -136,8 +147,14 @@ class Tags(Mapping[str, str]):
136
147
  """Delete a tag. A commit it alone kept alive becomes collectable
137
148
  at the next ``gc``.
138
149
 
150
+ The removal is conditional on the head and info record this call
151
+ read: a tag deleted and made again by another writer in between,
152
+ even at the same commit, keeps its new commit and info, and the
153
+ call raises rather than retrying.
154
+
139
155
  Raises:
140
156
  UnknownTagError: if there is no such tag.
157
+ ConcurrencyError: if the tag changed while being deleted.
141
158
  """
142
159
  _kv.delete_tag(self._repo._store, name)
143
160
 
@@ -7,7 +7,12 @@ from dataclasses import dataclass
7
7
  from typing import Any
8
8
 
9
9
  from ._codec import Codec, CodecSpec
10
- from .cache import DEFAULT_CACHE_BYTES, CachedStore, ContentCache
10
+ from .cache import (
11
+ DEFAULT_CACHE_BYTES,
12
+ DEFAULT_VALUE_CACHE_BYTES,
13
+ CachedStore,
14
+ ContentCache,
15
+ )
11
16
  from .content_types import MergeFn
12
17
  from .encoding import loads, safe_loads
13
18
  from .errors import (
@@ -75,6 +80,11 @@ class Repo:
75
80
  default; ``0`` turns the cache off). A sweep by any process
76
81
  empties it before this one builds on what the sweep removed;
77
82
  see :class:`~kvgit.cache.ContentCache`.
83
+ value_cache_bytes: Memory for the decoded values each worktree
84
+ reads, held until its head moves (32 MB per worktree by
85
+ default; ``0`` holds none). A value costing more than a
86
+ quarter of it is never held; see
87
+ :class:`~kvgit.cache.ValueCache`.
78
88
 
79
89
  Raises:
80
90
  StorageVersionError: if the store is stamped with a layout this
@@ -91,8 +101,14 @@ class Repo:
91
101
  default_merge: MergeRule | None = None,
92
102
  recover_from_corrupt_head: CorruptHeadRecoverer | None = None,
93
103
  cache_bytes: int = DEFAULT_CACHE_BYTES,
104
+ value_cache_bytes: int = DEFAULT_VALUE_CACHE_BYTES,
94
105
  ) -> None:
95
106
  _kv._check_storage_version(backend)
107
+ if value_cache_bytes < 0:
108
+ raise ValueError(
109
+ f"value_cache_bytes must not be negative, got {value_cache_bytes}"
110
+ )
111
+ self._value_cache_bytes = value_cache_bytes
96
112
  self._backend = backend
97
113
  # 0 turns the cache off; anything else is a budget, and
98
114
  # ContentCache refuses one that is negative.