kvgit 0.4.1__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.1 → kvgit-0.4.3}/PKG-INFO +4 -2
  2. {kvgit-0.4.1 → kvgit-0.4.3}/README.md +3 -1
  3. {kvgit-0.4.1 → kvgit-0.4.3}/kvgit/__init__.py +2 -0
  4. {kvgit-0.4.1 → kvgit-0.4.3}/kvgit/_codec.py +29 -0
  5. {kvgit-0.4.1 → kvgit-0.4.3}/kvgit/_open.py +14 -1
  6. kvgit-0.4.3/kvgit/cache.py +369 -0
  7. {kvgit-0.4.1 → kvgit-0.4.3}/kvgit/errors.py +22 -0
  8. {kvgit-0.4.1 → kvgit-0.4.3}/kvgit/refs.py +36 -14
  9. {kvgit-0.4.1 → kvgit-0.4.3}/kvgit/repo.py +68 -15
  10. {kvgit-0.4.1 → kvgit-0.4.3}/kvgit/versioned/base.py +207 -44
  11. {kvgit-0.4.1 → kvgit-0.4.3}/kvgit/versioned/kv.py +263 -81
  12. {kvgit-0.4.1 → kvgit-0.4.3}/kvgit/worktree.py +50 -15
  13. {kvgit-0.4.1 → kvgit-0.4.3}/kvgit.egg-info/PKG-INFO +4 -2
  14. {kvgit-0.4.1 → kvgit-0.4.3}/kvgit.egg-info/SOURCES.txt +4 -0
  15. {kvgit-0.4.1 → kvgit-0.4.3}/pyproject.toml +1 -1
  16. kvgit-0.4.3/tests/test_cache.py +272 -0
  17. kvgit-0.4.3/tests/test_commit_on_moved.py +190 -0
  18. {kvgit-0.4.1 → kvgit-0.4.3}/tests/test_repo.py +286 -0
  19. kvgit-0.4.3/tests/test_value_cache.py +222 -0
  20. {kvgit-0.4.1 → kvgit-0.4.3}/tests/test_worktree.py +29 -0
  21. {kvgit-0.4.1 → kvgit-0.4.3}/LICENSE +0 -0
  22. {kvgit-0.4.1 → kvgit-0.4.3}/kvgit/codecs/__init__.py +0 -0
  23. {kvgit-0.4.1 → kvgit-0.4.3}/kvgit/codecs/_hash.py +0 -0
  24. {kvgit-0.4.1 → kvgit-0.4.3}/kvgit/codecs/base.py +0 -0
  25. {kvgit-0.4.1 → kvgit-0.4.3}/kvgit/codecs/numpy.py +0 -0
  26. {kvgit-0.4.1 → kvgit-0.4.3}/kvgit/codecs/pandas.py +0 -0
  27. {kvgit-0.4.1 → kvgit-0.4.3}/kvgit/codecs/pickler.py +0 -0
  28. {kvgit-0.4.1 → kvgit-0.4.3}/kvgit/content_types.py +0 -0
  29. {kvgit-0.4.1 → kvgit-0.4.3}/kvgit/encoding.py +0 -0
  30. {kvgit-0.4.1 → kvgit-0.4.3}/kvgit/hamt.py +0 -0
  31. {kvgit-0.4.1 → kvgit-0.4.3}/kvgit/kv/__init__.py +0 -0
  32. {kvgit-0.4.1 → kvgit-0.4.3}/kvgit/kv/base.py +0 -0
  33. {kvgit-0.4.1 → kvgit-0.4.3}/kvgit/kv/composite.py +0 -0
  34. {kvgit-0.4.1 → kvgit-0.4.3}/kvgit/kv/disk.py +0 -0
  35. {kvgit-0.4.1 → kvgit-0.4.3}/kvgit/kv/indexeddb.py +0 -0
  36. {kvgit-0.4.1 → kvgit-0.4.3}/kvgit/kv/memory.py +0 -0
  37. {kvgit-0.4.1 → kvgit-0.4.3}/kvgit/kv/postgres.py +0 -0
  38. {kvgit-0.4.1 → kvgit-0.4.3}/kvgit/merges.py +0 -0
  39. {kvgit-0.4.1 → kvgit-0.4.3}/kvgit/namespaced.py +0 -0
  40. {kvgit-0.4.1 → kvgit-0.4.3}/kvgit/py.typed +0 -0
  41. {kvgit-0.4.1 → kvgit-0.4.3}/kvgit/versioned/__init__.py +0 -0
  42. {kvgit-0.4.1 → kvgit-0.4.3}/kvgit/versioned/helpers.py +0 -0
  43. {kvgit-0.4.1 → kvgit-0.4.3}/kvgit/versioned/keyset.py +0 -0
  44. {kvgit-0.4.1 → kvgit-0.4.3}/kvgit/versioned/merge.py +0 -0
  45. {kvgit-0.4.1 → kvgit-0.4.3}/kvgit/versioned/protocol.py +0 -0
  46. {kvgit-0.4.1 → kvgit-0.4.3}/kvgit.egg-info/dependency_links.txt +0 -0
  47. {kvgit-0.4.1 → kvgit-0.4.3}/kvgit.egg-info/requires.txt +0 -0
  48. {kvgit-0.4.1 → kvgit-0.4.3}/kvgit.egg-info/top_level.txt +0 -0
  49. {kvgit-0.4.1 → kvgit-0.4.3}/setup.cfg +0 -0
  50. {kvgit-0.4.1 → kvgit-0.4.3}/tests/test_content_types.py +0 -0
  51. {kvgit-0.4.1 → kvgit-0.4.3}/tests/test_delete_branches.py +0 -0
  52. {kvgit-0.4.1 → kvgit-0.4.3}/tests/test_hamt.py +0 -0
  53. {kvgit-0.4.1 → kvgit-0.4.3}/tests/test_merge_policy.py +0 -0
  54. {kvgit-0.4.1 → kvgit-0.4.3}/tests/test_merges.py +0 -0
  55. {kvgit-0.4.1 → kvgit-0.4.3}/tests/test_namespaced.py +0 -0
  56. {kvgit-0.4.1 → kvgit-0.4.3}/tests/test_open.py +0 -0
  57. {kvgit-0.4.1 → 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.1
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,6 +3,7 @@
3
3
  from typing import Literal
4
4
 
5
5
  from ._codec import CodecSpec
6
+ from .cache import DEFAULT_CACHE_BYTES, DEFAULT_VALUE_CACHE_BYTES
6
7
  from .kv.base import KVStore
7
8
  from .kv.memory import Memory
8
9
  from .repo import Repo
@@ -39,6 +40,8 @@ def open(
39
40
  db_name: str = "kvgit",
40
41
  branch: str = "main",
41
42
  codec: CodecSpec = "pickle",
43
+ cache_bytes: int = DEFAULT_CACHE_BYTES,
44
+ value_cache_bytes: int = DEFAULT_VALUE_CACHE_BYTES,
42
45
  ) -> Worktree:
43
46
  """Open (or create) a store, and a worktree on ``branch`` of it.
44
47
 
@@ -57,6 +60,16 @@ def open(
57
60
  db_name: The IndexedDB database name, for ``kind="indexeddb"``.
58
61
  branch: The branch to check out (default ``"main"``).
59
62
  codec: How values are stored; see :class:`Repo`.
63
+ cache_bytes: Memory for what the store never changes; see
64
+ :class:`Repo`.
65
+ value_cache_bytes: Memory for the values the worktree reads;
66
+ see :class:`Repo`.
60
67
  """
61
68
  backend = _make_backend(kind, path=path, db_name=db_name)
62
- return Repo(backend, codec=codec).worktree(branch, create=True)
69
+ repo = Repo(
70
+ backend,
71
+ codec=codec,
72
+ cache_bytes=cache_bytes,
73
+ value_cache_bytes=value_cache_bytes,
74
+ )
75
+ return repo.worktree(branch, create=True)
@@ -0,0 +1,369 @@
1
+ """A repository's memory of what its store can never change.
2
+
3
+ Tree nodes and a commit's records — its parents, their generations,
4
+ its time and its info — are written once, under keys named by content
5
+ or by commit, and never rewritten. A read of one can be answered from
6
+ memory for as long as the store still holds it, which saves the round
7
+ trip to a networked backend that most reads of them are.
8
+
9
+ What the cache must not outlive is a deletion. Only a sweep deletes
10
+ these keys, and every sweep rewrites the GC lease record, which each
11
+ commit reads before it builds. So the cache remembers the record it
12
+ last saw and empties itself when a read of the lease finds another:
13
+ the sweep of any process on any host is noticed before this process
14
+ builds on what it may have removed. A sweep run through this process's
15
+ own store evicts what it removes as it removes it.
16
+
17
+ A commit's root is never cached. It is the store's answer to whether a
18
+ commit exists — what ``snapshot(commit=)`` and a tag's ``dangling``
19
+ ask — so it is always read, and whatever is reached through a root the
20
+ store still holds is safe to answer from here. Absence is never cached
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.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import threading
31
+ from collections import OrderedDict
32
+ from collections.abc import Iterable, Mapping
33
+ from typing import Any
34
+
35
+ from .kv.base import KVStore
36
+ from .versioned.keyset import Keyset
37
+ from .versioned.kv import (
38
+ COMMIT_TIME,
39
+ GC_LEASE_KEY,
40
+ INFO_KEY,
41
+ PARENT_COMMIT,
42
+ PARENT_GENS,
43
+ )
44
+
45
+ DEFAULT_CACHE_BYTES = 32 * 1024 * 1024
46
+ """The default budget: tens of thousands of tree nodes."""
47
+
48
+ DEFAULT_VALUE_CACHE_BYTES = 32 * 1024 * 1024
49
+ """The default budget for each worktree's decoded values."""
50
+
51
+ _CACHEABLE_PREFIXES = (
52
+ Keyset.DEFAULT_PREFIX,
53
+ PARENT_COMMIT.replace("%s", ""),
54
+ PARENT_GENS.replace("%s", ""),
55
+ COMMIT_TIME.replace("%s", ""),
56
+ INFO_KEY.replace("%s", ""),
57
+ )
58
+
59
+ # What an entry costs beyond its bytes: the key, the dict slot, the
60
+ # object headers. An estimate, so the budget bounds memory rather than
61
+ # just payload.
62
+ _ENTRY_OVERHEAD = 96
63
+
64
+ _UNSEEN: Any = object()
65
+
66
+
67
+ def cacheable(key: str) -> bool:
68
+ """Whether ``key`` is one the store never rewrites."""
69
+ return key.startswith(_CACHEABLE_PREFIXES)
70
+
71
+
72
+ class ContentCache:
73
+ """Least-recently-used memory of write-once keys, bounded in bytes.
74
+
75
+ Shared by every worktree and snapshot of one :class:`~kvgit.Repo`,
76
+ and safe to use from several threads.
77
+ """
78
+
79
+ def __init__(self, max_bytes: int = DEFAULT_CACHE_BYTES) -> None:
80
+ if max_bytes <= 0:
81
+ raise ValueError(f"max_bytes must be positive, got {max_bytes}")
82
+ self.max_bytes = max_bytes
83
+ self._entries: OrderedDict[str, bytes] = OrderedDict()
84
+ self._bytes = 0
85
+ self._lock = threading.Lock()
86
+ self._lease: Any = _UNSEEN
87
+ self.hits = 0
88
+ """Reads answered from memory."""
89
+ self.misses = 0
90
+ """Reads of cacheable keys that went to the store."""
91
+ self.clears = 0
92
+ """How often a sweep emptied the cache."""
93
+
94
+ def __repr__(self) -> str:
95
+ return (
96
+ f"ContentCache({len(self._entries)} entries, "
97
+ f"{self._bytes}/{self.max_bytes} bytes)"
98
+ )
99
+
100
+ def __len__(self) -> int:
101
+ return len(self._entries)
102
+
103
+ @property
104
+ def size_bytes(self) -> int:
105
+ """What the entries held now cost, by the budget's estimate."""
106
+ return self._bytes
107
+
108
+ def clear(self) -> None:
109
+ with self._lock:
110
+ self._clear()
111
+
112
+ def _clear(self) -> None:
113
+ self._entries.clear()
114
+ self._bytes = 0
115
+
116
+ def lookup(self, keys: Iterable[str]) -> dict[str, bytes]:
117
+ """The cached values of those ``keys`` held here."""
118
+ found: dict[str, bytes] = {}
119
+ with self._lock:
120
+ for key in keys:
121
+ value = self._entries.get(key)
122
+ if value is not None:
123
+ self._entries.move_to_end(key)
124
+ found[key] = value
125
+ return found
126
+
127
+ def count(self, hits: int, misses: int) -> None:
128
+ with self._lock:
129
+ self.hits += hits
130
+ self.misses += misses
131
+
132
+ def remember(self, items: Mapping[str, bytes]) -> None:
133
+ """Hold these values, evicting the least recently used to fit."""
134
+ with self._lock:
135
+ for key, value in items.items():
136
+ if not isinstance(value, bytes | bytearray | memoryview):
137
+ continue
138
+ cost = len(value) + len(key) + _ENTRY_OVERHEAD
139
+ if cost > self.max_bytes:
140
+ continue
141
+ old = self._entries.pop(key, None)
142
+ if old is not None:
143
+ self._bytes -= len(old) + len(key) + _ENTRY_OVERHEAD
144
+ self._entries[key] = bytes(value)
145
+ self._bytes += cost
146
+ while self._bytes > self.max_bytes and self._entries:
147
+ key, value = self._entries.popitem(last=False)
148
+ self._bytes -= len(value) + len(key) + _ENTRY_OVERHEAD
149
+
150
+ def forget(self, keys: Iterable[str]) -> None:
151
+ with self._lock:
152
+ for key in keys:
153
+ value = self._entries.pop(key, None)
154
+ if value is not None:
155
+ self._bytes -= len(value) + len(key) + _ENTRY_OVERHEAD
156
+
157
+ def saw_lease(self, record: bytes | None) -> None:
158
+ """Note the GC lease record a read found; a different one than
159
+ last time means a sweep has run, and what it removed may be
160
+ held here."""
161
+ with self._lock:
162
+ if self._lease is not _UNSEEN and record != self._lease:
163
+ self._clear()
164
+ self.clears += 1
165
+ self._lease = record
166
+
167
+
168
+ class CachedStore(KVStore):
169
+ """A backend seen through a :class:`ContentCache`.
170
+
171
+ Reads of write-once keys are answered from the cache where it can
172
+ and remembered where it cannot; everything else passes through
173
+ untouched. Successful writes of write-once keys are remembered too,
174
+ so a commit's next read of its own tree is a hit, and removals
175
+ evict. Anything else the backend offers (``close``, ``drop``, ...)
176
+ is reached through this object as it would be on the backend.
177
+ """
178
+
179
+ def __init__(self, backend: KVStore, cache: ContentCache) -> None:
180
+ self.backend = backend
181
+ self.cache = cache
182
+
183
+ def __repr__(self) -> str:
184
+ return f"CachedStore({self.backend!r})"
185
+
186
+ def __getattr__(self, name: str) -> Any:
187
+ if name.startswith("__") or name in ("backend", "cache"):
188
+ raise AttributeError(name)
189
+ return getattr(self.backend, name)
190
+
191
+ # -- reads -----------------------------------------------------------
192
+
193
+ def get(self, key: str) -> bytes | None:
194
+ if cacheable(key):
195
+ held = self.cache.lookup((key,))
196
+ if key in held:
197
+ self.cache.count(1, 0)
198
+ return held[key]
199
+ value = self.backend.get(key)
200
+ self.cache.count(0, 1)
201
+ if value is not None:
202
+ self.cache.remember({key: value})
203
+ return value
204
+ value = self.backend.get(key)
205
+ if key == GC_LEASE_KEY:
206
+ self.cache.saw_lease(value)
207
+ return value
208
+
209
+ def get_many(self, *args) -> Mapping[str, bytes]:
210
+ keys = list(dict.fromkeys(self._normalize_keys(args)))
211
+ wanted = [k for k in keys if cacheable(k)]
212
+ if GC_LEASE_KEY in keys:
213
+ # A read of the lease may reveal a sweep, and nothing the
214
+ # cache held before it may be answered beside it: the whole
215
+ # call goes to the store, and the record is noted before
216
+ # anything it brought back is remembered.
217
+ held: dict[str, bytes] = {}
218
+ found = dict(self.backend.get_many(keys))
219
+ self.cache.saw_lease(found.get(GC_LEASE_KEY))
220
+ else:
221
+ held = self.cache.lookup(wanted) if wanted else {}
222
+ rest = [k for k in keys if k not in held]
223
+ found = dict(self.backend.get_many(rest)) if rest else {}
224
+ fetched = {k: v for k, v in found.items() if cacheable(k)}
225
+ if wanted:
226
+ self.cache.count(len(held), len(wanted) - len(held))
227
+ if fetched:
228
+ self.cache.remember(fetched)
229
+ found.update(held)
230
+ return found
231
+
232
+ def items(self) -> Iterable[tuple[str, bytes]]:
233
+ return self.backend.items()
234
+
235
+ def keys(self, prefix: str = "") -> Iterable[str]:
236
+ return self.backend.keys(prefix)
237
+
238
+ def __contains__(self, key: str) -> bool:
239
+ if cacheable(key) and self.cache.lookup((key,)):
240
+ return True
241
+ return key in self.backend
242
+
243
+ # -- writes ----------------------------------------------------------
244
+
245
+ def set(self, key: str, value: bytes) -> None:
246
+ self.backend.set(key, value)
247
+ self._wrote({key: value}, ())
248
+
249
+ def set_many(
250
+ self, items: Mapping[str, bytes] | None = None, /, **kwargs: bytes
251
+ ) -> None:
252
+ items = dict(self._normalize_items(items, kwargs))
253
+ self.backend.set_many(items)
254
+ self._wrote(items, ())
255
+
256
+ def remove(self, key: str) -> None:
257
+ self.backend.remove(key)
258
+ self._wrote({}, (key,))
259
+
260
+ def remove_many(self, *args) -> None:
261
+ keys = list(self._normalize_keys(args))
262
+ self.backend.remove_many(keys)
263
+ self._wrote({}, keys)
264
+
265
+ def cas_many(
266
+ self,
267
+ expected: Mapping[str, bytes | None],
268
+ writes: Mapping[str, bytes],
269
+ removes: Iterable[str] = (),
270
+ ) -> bool:
271
+ removes = list(removes)
272
+ if not self.backend.cas_many(expected, writes, removes):
273
+ return False
274
+ self._wrote(writes, removes)
275
+ return True
276
+
277
+ def clear(self) -> None:
278
+ self.backend.clear()
279
+ self.cache.clear()
280
+
281
+ def _wrote(self, writes: Mapping[str, bytes], removes: Iterable[str]) -> None:
282
+ gone = [k for k in removes if cacheable(k)]
283
+ if gone:
284
+ self.cache.forget(gone)
285
+ landed = {k: v for k, v in writes.items() if cacheable(k)}
286
+ if landed:
287
+ self.cache.remember(landed)
288
+ if GC_LEASE_KEY in writes:
289
+ # This process's own sweep taking or releasing the lease.
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
 
@@ -35,10 +35,15 @@ class Branches(Mapping[str, str]):
35
35
  return f"Branches({list(self)!r})"
36
36
 
37
37
  def __getitem__(self, name: str) -> str:
38
+ return self._resolve(name)
39
+
40
+ def _resolve(self, name: str, roots: dict[str, str] | None = None) -> str:
41
+ """The branch's head, recording its root in ``roots`` when given
42
+ (see :meth:`Repo._resolve_ref`)."""
38
43
  _kv._reject_reserved_branch(name)
39
- store = self._repo.store
44
+ store = self._repo._store
40
45
  commit = _kv._resolve_head(
41
- store, name, recover_from_corrupt_head=self._repo._recover
46
+ store, name, recover_from_corrupt_head=self._repo._recover, roots=roots
42
47
  )
43
48
  if commit is not None:
44
49
  return commit
@@ -47,13 +52,13 @@ class Branches(Mapping[str, str]):
47
52
  raise UnknownBranchError(f"Branch '{name}' does not exist")
48
53
 
49
54
  def __contains__(self, name: object) -> bool:
50
- return isinstance(name, str) and VersionedKV.exists(self._repo.store, name)
55
+ return isinstance(name, str) and VersionedKV.exists(self._repo._store, name)
51
56
 
52
57
  def __iter__(self) -> Iterator[str]:
53
- return iter(VersionedKV.branches(self._repo.store))
58
+ return iter(VersionedKV.branches(self._repo._store))
54
59
 
55
60
  def __len__(self) -> int:
56
- return len(VersionedKV.branches(self._repo.store))
61
+ return len(VersionedKV.branches(self._repo._store))
57
62
 
58
63
  def create(self, name: str, at: str | None = None) -> str:
59
64
  """Create branch ``name`` at commit ``at`` (default: the empty root
@@ -64,7 +69,7 @@ class Branches(Mapping[str, str]):
64
69
  UnknownCommitError: if ``at`` is not in the store.
65
70
  """
66
71
  target = at or ROOT_COMMIT
67
- _kv.create_branch(self._repo.store, name, target)
72
+ _kv.create_branch(self._repo._store, name, target)
68
73
  return target
69
74
 
70
75
  def delete(self, name: str) -> None:
@@ -72,10 +77,21 @@ class Branches(Mapping[str, str]):
72
77
  ``gc``; worktrees still holding it read from their heads and raise
73
78
  :class:`UnknownBranchError` on commit.
74
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
+
75
88
  Raises:
76
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.
77
93
  """
78
- _kv.delete_branch(self._repo.store, name)
94
+ _kv.delete_branch(self._repo._store, name)
79
95
 
80
96
 
81
97
  class Tags(Mapping[str, str]):
@@ -95,7 +111,7 @@ class Tags(Mapping[str, str]):
95
111
  return f"Tags({list(self)!r})"
96
112
 
97
113
  def __getitem__(self, name: str) -> str:
98
- commit = _kv._resolve_tag(self._repo.store, name)
114
+ commit = _kv._resolve_tag(self._repo._store, name)
99
115
  if commit is None:
100
116
  raise UnknownTagError(f"Tag '{name}' does not exist")
101
117
  return commit
@@ -103,14 +119,14 @@ class Tags(Mapping[str, str]):
103
119
  def __contains__(self, name: object) -> bool:
104
120
  return (
105
121
  isinstance(name, str)
106
- and _kv._resolve_tag(self._repo.store, name) is not None
122
+ and _kv._resolve_tag(self._repo._store, name) is not None
107
123
  )
108
124
 
109
125
  def __iter__(self) -> Iterator[str]:
110
- return iter(_kv.tags(self._repo.store))
126
+ return iter(_kv.tags(self._repo._store))
111
127
 
112
128
  def __len__(self) -> int:
113
- return len(_kv.tags(self._repo.store))
129
+ return len(_kv.tags(self._repo._store))
114
130
 
115
131
  def items(self) -> ItemsView[str, str]:
116
132
  """Every tag and its commit: a live view, like the mapping, that
@@ -125,16 +141,22 @@ class Tags(Mapping[str, str]):
125
141
  TagExistsError: if the name is taken.
126
142
  UnknownCommitError: if ``commit`` is not in the store.
127
143
  """
128
- _kv.create_tag(self._repo.store, name, commit, info)
144
+ _kv.create_tag(self._repo._store, name, commit, info)
129
145
 
130
146
  def delete(self, name: str) -> None:
131
147
  """Delete a tag. A commit it alone kept alive becomes collectable
132
148
  at the next ``gc``.
133
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
+
134
155
  Raises:
135
156
  UnknownTagError: if there is no such tag.
157
+ ConcurrencyError: if the tag changed while being deleted.
136
158
  """
137
- _kv.delete_tag(self._repo.store, name)
159
+ _kv.delete_tag(self._repo._store, name)
138
160
 
139
161
  def info(self, name: str) -> TagInfo:
140
162
  """A tag's commit, creation time, info, and whether its commit is
@@ -143,7 +165,7 @@ class Tags(Mapping[str, str]):
143
165
  Raises:
144
166
  UnknownTagError: if there is no such tag.
145
167
  """
146
- found = _kv.tag_info(self._repo.store, name)
168
+ found = _kv.tag_info(self._repo._store, name)
147
169
  if found is None:
148
170
  raise UnknownTagError(f"Tag '{name}' does not exist")
149
171
  return found