kvgit 0.4.1__tar.gz → 0.4.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.
- {kvgit-0.4.1 → kvgit-0.4.2}/PKG-INFO +1 -1
- {kvgit-0.4.1 → kvgit-0.4.2}/kvgit/_open.py +7 -1
- kvgit-0.4.2/kvgit/cache.py +283 -0
- {kvgit-0.4.1 → kvgit-0.4.2}/kvgit/refs.py +19 -14
- {kvgit-0.4.1 → kvgit-0.4.2}/kvgit/repo.py +52 -15
- {kvgit-0.4.1 → kvgit-0.4.2}/kvgit/versioned/kv.py +99 -20
- {kvgit-0.4.1 → kvgit-0.4.2}/kvgit.egg-info/PKG-INFO +1 -1
- {kvgit-0.4.1 → kvgit-0.4.2}/kvgit.egg-info/SOURCES.txt +2 -0
- {kvgit-0.4.1 → kvgit-0.4.2}/pyproject.toml +1 -1
- kvgit-0.4.2/tests/test_cache.py +272 -0
- {kvgit-0.4.1 → kvgit-0.4.2}/tests/test_repo.py +135 -0
- {kvgit-0.4.1 → kvgit-0.4.2}/LICENSE +0 -0
- {kvgit-0.4.1 → kvgit-0.4.2}/README.md +0 -0
- {kvgit-0.4.1 → kvgit-0.4.2}/kvgit/__init__.py +0 -0
- {kvgit-0.4.1 → kvgit-0.4.2}/kvgit/_codec.py +0 -0
- {kvgit-0.4.1 → kvgit-0.4.2}/kvgit/codecs/__init__.py +0 -0
- {kvgit-0.4.1 → kvgit-0.4.2}/kvgit/codecs/_hash.py +0 -0
- {kvgit-0.4.1 → kvgit-0.4.2}/kvgit/codecs/base.py +0 -0
- {kvgit-0.4.1 → kvgit-0.4.2}/kvgit/codecs/numpy.py +0 -0
- {kvgit-0.4.1 → kvgit-0.4.2}/kvgit/codecs/pandas.py +0 -0
- {kvgit-0.4.1 → kvgit-0.4.2}/kvgit/codecs/pickler.py +0 -0
- {kvgit-0.4.1 → kvgit-0.4.2}/kvgit/content_types.py +0 -0
- {kvgit-0.4.1 → kvgit-0.4.2}/kvgit/encoding.py +0 -0
- {kvgit-0.4.1 → kvgit-0.4.2}/kvgit/errors.py +0 -0
- {kvgit-0.4.1 → kvgit-0.4.2}/kvgit/hamt.py +0 -0
- {kvgit-0.4.1 → kvgit-0.4.2}/kvgit/kv/__init__.py +0 -0
- {kvgit-0.4.1 → kvgit-0.4.2}/kvgit/kv/base.py +0 -0
- {kvgit-0.4.1 → kvgit-0.4.2}/kvgit/kv/composite.py +0 -0
- {kvgit-0.4.1 → kvgit-0.4.2}/kvgit/kv/disk.py +0 -0
- {kvgit-0.4.1 → kvgit-0.4.2}/kvgit/kv/indexeddb.py +0 -0
- {kvgit-0.4.1 → kvgit-0.4.2}/kvgit/kv/memory.py +0 -0
- {kvgit-0.4.1 → kvgit-0.4.2}/kvgit/kv/postgres.py +0 -0
- {kvgit-0.4.1 → kvgit-0.4.2}/kvgit/merges.py +0 -0
- {kvgit-0.4.1 → kvgit-0.4.2}/kvgit/namespaced.py +0 -0
- {kvgit-0.4.1 → kvgit-0.4.2}/kvgit/py.typed +0 -0
- {kvgit-0.4.1 → kvgit-0.4.2}/kvgit/versioned/__init__.py +0 -0
- {kvgit-0.4.1 → kvgit-0.4.2}/kvgit/versioned/base.py +0 -0
- {kvgit-0.4.1 → kvgit-0.4.2}/kvgit/versioned/helpers.py +0 -0
- {kvgit-0.4.1 → kvgit-0.4.2}/kvgit/versioned/keyset.py +0 -0
- {kvgit-0.4.1 → kvgit-0.4.2}/kvgit/versioned/merge.py +0 -0
- {kvgit-0.4.1 → kvgit-0.4.2}/kvgit/versioned/protocol.py +0 -0
- {kvgit-0.4.1 → kvgit-0.4.2}/kvgit/worktree.py +0 -0
- {kvgit-0.4.1 → kvgit-0.4.2}/kvgit.egg-info/dependency_links.txt +0 -0
- {kvgit-0.4.1 → kvgit-0.4.2}/kvgit.egg-info/requires.txt +0 -0
- {kvgit-0.4.1 → kvgit-0.4.2}/kvgit.egg-info/top_level.txt +0 -0
- {kvgit-0.4.1 → kvgit-0.4.2}/setup.cfg +0 -0
- {kvgit-0.4.1 → kvgit-0.4.2}/tests/test_content_types.py +0 -0
- {kvgit-0.4.1 → kvgit-0.4.2}/tests/test_delete_branches.py +0 -0
- {kvgit-0.4.1 → kvgit-0.4.2}/tests/test_hamt.py +0 -0
- {kvgit-0.4.1 → kvgit-0.4.2}/tests/test_merge_policy.py +0 -0
- {kvgit-0.4.1 → kvgit-0.4.2}/tests/test_merges.py +0 -0
- {kvgit-0.4.1 → kvgit-0.4.2}/tests/test_namespaced.py +0 -0
- {kvgit-0.4.1 → kvgit-0.4.2}/tests/test_open.py +0 -0
- {kvgit-0.4.1 → kvgit-0.4.2}/tests/test_storage_v4.py +0 -0
- {kvgit-0.4.1 → kvgit-0.4.2}/tests/test_worktree.py +0 -0
|
@@ -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
|
|
6
7
|
from .kv.base import KVStore
|
|
7
8
|
from .kv.memory import Memory
|
|
8
9
|
from .repo import Repo
|
|
@@ -39,6 +40,7 @@ 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,
|
|
42
44
|
) -> Worktree:
|
|
43
45
|
"""Open (or create) a store, and a worktree on ``branch`` of it.
|
|
44
46
|
|
|
@@ -57,6 +59,10 @@ def open(
|
|
|
57
59
|
db_name: The IndexedDB database name, for ``kind="indexeddb"``.
|
|
58
60
|
branch: The branch to check out (default ``"main"``).
|
|
59
61
|
codec: How values are stored; see :class:`Repo`.
|
|
62
|
+
cache_bytes: Memory for what the store never changes; see
|
|
63
|
+
:class:`Repo`.
|
|
60
64
|
"""
|
|
61
65
|
backend = _make_backend(kind, path=path, db_name=db_name)
|
|
62
|
-
return Repo(backend, codec=codec).worktree(
|
|
66
|
+
return Repo(backend, codec=codec, cache_bytes=cache_bytes).worktree(
|
|
67
|
+
branch, create=True
|
|
68
|
+
)
|
|
@@ -0,0 +1,283 @@
|
|
|
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
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import threading
|
|
27
|
+
from collections import OrderedDict
|
|
28
|
+
from collections.abc import Iterable, Mapping
|
|
29
|
+
from typing import Any
|
|
30
|
+
|
|
31
|
+
from .kv.base import KVStore
|
|
32
|
+
from .versioned.keyset import Keyset
|
|
33
|
+
from .versioned.kv import (
|
|
34
|
+
COMMIT_TIME,
|
|
35
|
+
GC_LEASE_KEY,
|
|
36
|
+
INFO_KEY,
|
|
37
|
+
PARENT_COMMIT,
|
|
38
|
+
PARENT_GENS,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
DEFAULT_CACHE_BYTES = 32 * 1024 * 1024
|
|
42
|
+
"""The default budget: tens of thousands of tree nodes."""
|
|
43
|
+
|
|
44
|
+
_CACHEABLE_PREFIXES = (
|
|
45
|
+
Keyset.DEFAULT_PREFIX,
|
|
46
|
+
PARENT_COMMIT.replace("%s", ""),
|
|
47
|
+
PARENT_GENS.replace("%s", ""),
|
|
48
|
+
COMMIT_TIME.replace("%s", ""),
|
|
49
|
+
INFO_KEY.replace("%s", ""),
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
# What an entry costs beyond its bytes: the key, the dict slot, the
|
|
53
|
+
# object headers. An estimate, so the budget bounds memory rather than
|
|
54
|
+
# just payload.
|
|
55
|
+
_ENTRY_OVERHEAD = 96
|
|
56
|
+
|
|
57
|
+
_UNSEEN: Any = object()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def cacheable(key: str) -> bool:
|
|
61
|
+
"""Whether ``key`` is one the store never rewrites."""
|
|
62
|
+
return key.startswith(_CACHEABLE_PREFIXES)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class ContentCache:
|
|
66
|
+
"""Least-recently-used memory of write-once keys, bounded in bytes.
|
|
67
|
+
|
|
68
|
+
Shared by every worktree and snapshot of one :class:`~kvgit.Repo`,
|
|
69
|
+
and safe to use from several threads.
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
def __init__(self, max_bytes: int = DEFAULT_CACHE_BYTES) -> None:
|
|
73
|
+
if max_bytes <= 0:
|
|
74
|
+
raise ValueError(f"max_bytes must be positive, got {max_bytes}")
|
|
75
|
+
self.max_bytes = max_bytes
|
|
76
|
+
self._entries: OrderedDict[str, bytes] = OrderedDict()
|
|
77
|
+
self._bytes = 0
|
|
78
|
+
self._lock = threading.Lock()
|
|
79
|
+
self._lease: Any = _UNSEEN
|
|
80
|
+
self.hits = 0
|
|
81
|
+
"""Reads answered from memory."""
|
|
82
|
+
self.misses = 0
|
|
83
|
+
"""Reads of cacheable keys that went to the store."""
|
|
84
|
+
self.clears = 0
|
|
85
|
+
"""How often a sweep emptied the cache."""
|
|
86
|
+
|
|
87
|
+
def __repr__(self) -> str:
|
|
88
|
+
return (
|
|
89
|
+
f"ContentCache({len(self._entries)} entries, "
|
|
90
|
+
f"{self._bytes}/{self.max_bytes} bytes)"
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
def __len__(self) -> int:
|
|
94
|
+
return len(self._entries)
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
def size_bytes(self) -> int:
|
|
98
|
+
"""What the entries held now cost, by the budget's estimate."""
|
|
99
|
+
return self._bytes
|
|
100
|
+
|
|
101
|
+
def clear(self) -> None:
|
|
102
|
+
with self._lock:
|
|
103
|
+
self._clear()
|
|
104
|
+
|
|
105
|
+
def _clear(self) -> None:
|
|
106
|
+
self._entries.clear()
|
|
107
|
+
self._bytes = 0
|
|
108
|
+
|
|
109
|
+
def lookup(self, keys: Iterable[str]) -> dict[str, bytes]:
|
|
110
|
+
"""The cached values of those ``keys`` held here."""
|
|
111
|
+
found: dict[str, bytes] = {}
|
|
112
|
+
with self._lock:
|
|
113
|
+
for key in keys:
|
|
114
|
+
value = self._entries.get(key)
|
|
115
|
+
if value is not None:
|
|
116
|
+
self._entries.move_to_end(key)
|
|
117
|
+
found[key] = value
|
|
118
|
+
return found
|
|
119
|
+
|
|
120
|
+
def count(self, hits: int, misses: int) -> None:
|
|
121
|
+
with self._lock:
|
|
122
|
+
self.hits += hits
|
|
123
|
+
self.misses += misses
|
|
124
|
+
|
|
125
|
+
def remember(self, items: Mapping[str, bytes]) -> None:
|
|
126
|
+
"""Hold these values, evicting the least recently used to fit."""
|
|
127
|
+
with self._lock:
|
|
128
|
+
for key, value in items.items():
|
|
129
|
+
if not isinstance(value, bytes | bytearray | memoryview):
|
|
130
|
+
continue
|
|
131
|
+
cost = len(value) + len(key) + _ENTRY_OVERHEAD
|
|
132
|
+
if cost > self.max_bytes:
|
|
133
|
+
continue
|
|
134
|
+
old = self._entries.pop(key, None)
|
|
135
|
+
if old is not None:
|
|
136
|
+
self._bytes -= len(old) + len(key) + _ENTRY_OVERHEAD
|
|
137
|
+
self._entries[key] = bytes(value)
|
|
138
|
+
self._bytes += cost
|
|
139
|
+
while self._bytes > self.max_bytes and self._entries:
|
|
140
|
+
key, value = self._entries.popitem(last=False)
|
|
141
|
+
self._bytes -= len(value) + len(key) + _ENTRY_OVERHEAD
|
|
142
|
+
|
|
143
|
+
def forget(self, keys: Iterable[str]) -> None:
|
|
144
|
+
with self._lock:
|
|
145
|
+
for key in keys:
|
|
146
|
+
value = self._entries.pop(key, None)
|
|
147
|
+
if value is not None:
|
|
148
|
+
self._bytes -= len(value) + len(key) + _ENTRY_OVERHEAD
|
|
149
|
+
|
|
150
|
+
def saw_lease(self, record: bytes | None) -> None:
|
|
151
|
+
"""Note the GC lease record a read found; a different one than
|
|
152
|
+
last time means a sweep has run, and what it removed may be
|
|
153
|
+
held here."""
|
|
154
|
+
with self._lock:
|
|
155
|
+
if self._lease is not _UNSEEN and record != self._lease:
|
|
156
|
+
self._clear()
|
|
157
|
+
self.clears += 1
|
|
158
|
+
self._lease = record
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
class CachedStore(KVStore):
|
|
162
|
+
"""A backend seen through a :class:`ContentCache`.
|
|
163
|
+
|
|
164
|
+
Reads of write-once keys are answered from the cache where it can
|
|
165
|
+
and remembered where it cannot; everything else passes through
|
|
166
|
+
untouched. Successful writes of write-once keys are remembered too,
|
|
167
|
+
so a commit's next read of its own tree is a hit, and removals
|
|
168
|
+
evict. Anything else the backend offers (``close``, ``drop``, ...)
|
|
169
|
+
is reached through this object as it would be on the backend.
|
|
170
|
+
"""
|
|
171
|
+
|
|
172
|
+
def __init__(self, backend: KVStore, cache: ContentCache) -> None:
|
|
173
|
+
self.backend = backend
|
|
174
|
+
self.cache = cache
|
|
175
|
+
|
|
176
|
+
def __repr__(self) -> str:
|
|
177
|
+
return f"CachedStore({self.backend!r})"
|
|
178
|
+
|
|
179
|
+
def __getattr__(self, name: str) -> Any:
|
|
180
|
+
if name.startswith("__") or name in ("backend", "cache"):
|
|
181
|
+
raise AttributeError(name)
|
|
182
|
+
return getattr(self.backend, name)
|
|
183
|
+
|
|
184
|
+
# -- reads -----------------------------------------------------------
|
|
185
|
+
|
|
186
|
+
def get(self, key: str) -> bytes | None:
|
|
187
|
+
if cacheable(key):
|
|
188
|
+
held = self.cache.lookup((key,))
|
|
189
|
+
if key in held:
|
|
190
|
+
self.cache.count(1, 0)
|
|
191
|
+
return held[key]
|
|
192
|
+
value = self.backend.get(key)
|
|
193
|
+
self.cache.count(0, 1)
|
|
194
|
+
if value is not None:
|
|
195
|
+
self.cache.remember({key: value})
|
|
196
|
+
return value
|
|
197
|
+
value = self.backend.get(key)
|
|
198
|
+
if key == GC_LEASE_KEY:
|
|
199
|
+
self.cache.saw_lease(value)
|
|
200
|
+
return value
|
|
201
|
+
|
|
202
|
+
def get_many(self, *args) -> Mapping[str, bytes]:
|
|
203
|
+
keys = list(dict.fromkeys(self._normalize_keys(args)))
|
|
204
|
+
wanted = [k for k in keys if cacheable(k)]
|
|
205
|
+
if GC_LEASE_KEY in keys:
|
|
206
|
+
# A read of the lease may reveal a sweep, and nothing the
|
|
207
|
+
# cache held before it may be answered beside it: the whole
|
|
208
|
+
# call goes to the store, and the record is noted before
|
|
209
|
+
# anything it brought back is remembered.
|
|
210
|
+
held: dict[str, bytes] = {}
|
|
211
|
+
found = dict(self.backend.get_many(keys))
|
|
212
|
+
self.cache.saw_lease(found.get(GC_LEASE_KEY))
|
|
213
|
+
else:
|
|
214
|
+
held = self.cache.lookup(wanted) if wanted else {}
|
|
215
|
+
rest = [k for k in keys if k not in held]
|
|
216
|
+
found = dict(self.backend.get_many(rest)) if rest else {}
|
|
217
|
+
fetched = {k: v for k, v in found.items() if cacheable(k)}
|
|
218
|
+
if wanted:
|
|
219
|
+
self.cache.count(len(held), len(wanted) - len(held))
|
|
220
|
+
if fetched:
|
|
221
|
+
self.cache.remember(fetched)
|
|
222
|
+
found.update(held)
|
|
223
|
+
return found
|
|
224
|
+
|
|
225
|
+
def items(self) -> Iterable[tuple[str, bytes]]:
|
|
226
|
+
return self.backend.items()
|
|
227
|
+
|
|
228
|
+
def keys(self, prefix: str = "") -> Iterable[str]:
|
|
229
|
+
return self.backend.keys(prefix)
|
|
230
|
+
|
|
231
|
+
def __contains__(self, key: str) -> bool:
|
|
232
|
+
if cacheable(key) and self.cache.lookup((key,)):
|
|
233
|
+
return True
|
|
234
|
+
return key in self.backend
|
|
235
|
+
|
|
236
|
+
# -- writes ----------------------------------------------------------
|
|
237
|
+
|
|
238
|
+
def set(self, key: str, value: bytes) -> None:
|
|
239
|
+
self.backend.set(key, value)
|
|
240
|
+
self._wrote({key: value}, ())
|
|
241
|
+
|
|
242
|
+
def set_many(
|
|
243
|
+
self, items: Mapping[str, bytes] | None = None, /, **kwargs: bytes
|
|
244
|
+
) -> None:
|
|
245
|
+
items = dict(self._normalize_items(items, kwargs))
|
|
246
|
+
self.backend.set_many(items)
|
|
247
|
+
self._wrote(items, ())
|
|
248
|
+
|
|
249
|
+
def remove(self, key: str) -> None:
|
|
250
|
+
self.backend.remove(key)
|
|
251
|
+
self._wrote({}, (key,))
|
|
252
|
+
|
|
253
|
+
def remove_many(self, *args) -> None:
|
|
254
|
+
keys = list(self._normalize_keys(args))
|
|
255
|
+
self.backend.remove_many(keys)
|
|
256
|
+
self._wrote({}, keys)
|
|
257
|
+
|
|
258
|
+
def cas_many(
|
|
259
|
+
self,
|
|
260
|
+
expected: Mapping[str, bytes | None],
|
|
261
|
+
writes: Mapping[str, bytes],
|
|
262
|
+
removes: Iterable[str] = (),
|
|
263
|
+
) -> bool:
|
|
264
|
+
removes = list(removes)
|
|
265
|
+
if not self.backend.cas_many(expected, writes, removes):
|
|
266
|
+
return False
|
|
267
|
+
self._wrote(writes, removes)
|
|
268
|
+
return True
|
|
269
|
+
|
|
270
|
+
def clear(self) -> None:
|
|
271
|
+
self.backend.clear()
|
|
272
|
+
self.cache.clear()
|
|
273
|
+
|
|
274
|
+
def _wrote(self, writes: Mapping[str, bytes], removes: Iterable[str]) -> None:
|
|
275
|
+
gone = [k for k in removes if cacheable(k)]
|
|
276
|
+
if gone:
|
|
277
|
+
self.cache.forget(gone)
|
|
278
|
+
landed = {k: v for k, v in writes.items() if cacheable(k)}
|
|
279
|
+
if landed:
|
|
280
|
+
self.cache.remember(landed)
|
|
281
|
+
if GC_LEASE_KEY in writes:
|
|
282
|
+
# This process's own sweep taking or releasing the lease.
|
|
283
|
+
self.cache.saw_lease(writes[GC_LEASE_KEY])
|
|
@@ -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.
|
|
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.
|
|
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.
|
|
58
|
+
return iter(VersionedKV.branches(self._repo._store))
|
|
54
59
|
|
|
55
60
|
def __len__(self) -> int:
|
|
56
|
-
return len(VersionedKV.branches(self._repo.
|
|
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.
|
|
72
|
+
_kv.create_branch(self._repo._store, name, target)
|
|
68
73
|
return target
|
|
69
74
|
|
|
70
75
|
def delete(self, name: str) -> None:
|
|
@@ -75,7 +80,7 @@ class Branches(Mapping[str, str]):
|
|
|
75
80
|
Raises:
|
|
76
81
|
UnknownBranchError: if there is no such branch.
|
|
77
82
|
"""
|
|
78
|
-
_kv.delete_branch(self._repo.
|
|
83
|
+
_kv.delete_branch(self._repo._store, name)
|
|
79
84
|
|
|
80
85
|
|
|
81
86
|
class Tags(Mapping[str, str]):
|
|
@@ -95,7 +100,7 @@ class Tags(Mapping[str, str]):
|
|
|
95
100
|
return f"Tags({list(self)!r})"
|
|
96
101
|
|
|
97
102
|
def __getitem__(self, name: str) -> str:
|
|
98
|
-
commit = _kv._resolve_tag(self._repo.
|
|
103
|
+
commit = _kv._resolve_tag(self._repo._store, name)
|
|
99
104
|
if commit is None:
|
|
100
105
|
raise UnknownTagError(f"Tag '{name}' does not exist")
|
|
101
106
|
return commit
|
|
@@ -103,14 +108,14 @@ class Tags(Mapping[str, str]):
|
|
|
103
108
|
def __contains__(self, name: object) -> bool:
|
|
104
109
|
return (
|
|
105
110
|
isinstance(name, str)
|
|
106
|
-
and _kv._resolve_tag(self._repo.
|
|
111
|
+
and _kv._resolve_tag(self._repo._store, name) is not None
|
|
107
112
|
)
|
|
108
113
|
|
|
109
114
|
def __iter__(self) -> Iterator[str]:
|
|
110
|
-
return iter(_kv.tags(self._repo.
|
|
115
|
+
return iter(_kv.tags(self._repo._store))
|
|
111
116
|
|
|
112
117
|
def __len__(self) -> int:
|
|
113
|
-
return len(_kv.tags(self._repo.
|
|
118
|
+
return len(_kv.tags(self._repo._store))
|
|
114
119
|
|
|
115
120
|
def items(self) -> ItemsView[str, str]:
|
|
116
121
|
"""Every tag and its commit: a live view, like the mapping, that
|
|
@@ -125,7 +130,7 @@ class Tags(Mapping[str, str]):
|
|
|
125
130
|
TagExistsError: if the name is taken.
|
|
126
131
|
UnknownCommitError: if ``commit`` is not in the store.
|
|
127
132
|
"""
|
|
128
|
-
_kv.create_tag(self._repo.
|
|
133
|
+
_kv.create_tag(self._repo._store, name, commit, info)
|
|
129
134
|
|
|
130
135
|
def delete(self, name: str) -> None:
|
|
131
136
|
"""Delete a tag. A commit it alone kept alive becomes collectable
|
|
@@ -134,7 +139,7 @@ class Tags(Mapping[str, str]):
|
|
|
134
139
|
Raises:
|
|
135
140
|
UnknownTagError: if there is no such tag.
|
|
136
141
|
"""
|
|
137
|
-
_kv.delete_tag(self._repo.
|
|
142
|
+
_kv.delete_tag(self._repo._store, name)
|
|
138
143
|
|
|
139
144
|
def info(self, name: str) -> TagInfo:
|
|
140
145
|
"""A tag's commit, creation time, info, and whether its commit is
|
|
@@ -143,7 +148,7 @@ class Tags(Mapping[str, str]):
|
|
|
143
148
|
Raises:
|
|
144
149
|
UnknownTagError: if there is no such tag.
|
|
145
150
|
"""
|
|
146
|
-
found = _kv.tag_info(self._repo.
|
|
151
|
+
found = _kv.tag_info(self._repo._store, name)
|
|
147
152
|
if found is None:
|
|
148
153
|
raise UnknownTagError(f"Tag '{name}' does not exist")
|
|
149
154
|
return found
|
|
@@ -7,6 +7,7 @@ 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
11
|
from .content_types import MergeFn
|
|
11
12
|
from .encoding import loads, safe_loads
|
|
12
13
|
from .errors import (
|
|
@@ -69,6 +70,11 @@ class Repo:
|
|
|
69
70
|
default_merge: The merge rule for keys no other rule covers.
|
|
70
71
|
recover_from_corrupt_head: Last-resort recovery for a HEAD that is
|
|
71
72
|
damaged and has no usable backup; see the API reference.
|
|
73
|
+
cache_bytes: Memory for tree nodes and commit records read from
|
|
74
|
+
the store, which never change once written (32 MB by
|
|
75
|
+
default; ``0`` turns the cache off). A sweep by any process
|
|
76
|
+
empties it before this one builds on what the sweep removed;
|
|
77
|
+
see :class:`~kvgit.cache.ContentCache`.
|
|
72
78
|
|
|
73
79
|
Raises:
|
|
74
80
|
StorageVersionError: if the store is stamped with a layout this
|
|
@@ -84,9 +90,22 @@ class Repo:
|
|
|
84
90
|
merge_prefixes: dict[str, MergeRule] | None = None,
|
|
85
91
|
default_merge: MergeRule | None = None,
|
|
86
92
|
recover_from_corrupt_head: CorruptHeadRecoverer | None = None,
|
|
93
|
+
cache_bytes: int = DEFAULT_CACHE_BYTES,
|
|
87
94
|
) -> None:
|
|
88
95
|
_kv._check_storage_version(backend)
|
|
89
|
-
self.
|
|
96
|
+
self._backend = backend
|
|
97
|
+
# 0 turns the cache off; anything else is a budget, and
|
|
98
|
+
# ContentCache refuses one that is negative.
|
|
99
|
+
self.cache: ContentCache | None = (
|
|
100
|
+
ContentCache(cache_bytes) if cache_bytes != 0 else None
|
|
101
|
+
)
|
|
102
|
+
"""What this repository remembers of its store, with its hit and
|
|
103
|
+
miss counts; ``None`` with ``cache_bytes=0``."""
|
|
104
|
+
# Everything the repository reads goes through the cache; the
|
|
105
|
+
# backend itself stays what ``store`` hands out.
|
|
106
|
+
self._store: KVStore = (
|
|
107
|
+
CachedStore(backend, self.cache) if self.cache is not None else backend
|
|
108
|
+
)
|
|
90
109
|
self._codec = Codec(codec, backend)
|
|
91
110
|
self._merge_fns = dict(merge_fns or {})
|
|
92
111
|
self._merge_prefixes = dict(merge_prefixes or {})
|
|
@@ -100,16 +119,22 @@ class Repo:
|
|
|
100
119
|
``delete`` and ``info``."""
|
|
101
120
|
|
|
102
121
|
def __repr__(self) -> str:
|
|
103
|
-
return f"Repo({type(self.
|
|
122
|
+
return f"Repo({type(self._backend).__name__})"
|
|
104
123
|
|
|
105
124
|
@property
|
|
106
125
|
def store(self) -> KVStore:
|
|
107
|
-
"""The backend.
|
|
108
|
-
|
|
126
|
+
"""The backend.
|
|
127
|
+
|
|
128
|
+
Reads made straight through it bypass the repository's cache,
|
|
129
|
+
and so do removals: a key deleted here, rather than by
|
|
130
|
+
:meth:`gc`, may still be answered from memory until the next
|
|
131
|
+
sweep.
|
|
132
|
+
"""
|
|
133
|
+
return self._backend
|
|
109
134
|
|
|
110
135
|
def close(self) -> None:
|
|
111
136
|
"""Close the backend, if it has anything to close."""
|
|
112
|
-
close = getattr(self.
|
|
137
|
+
close = getattr(self._backend, "close", None)
|
|
113
138
|
if callable(close):
|
|
114
139
|
close()
|
|
115
140
|
|
|
@@ -255,7 +280,9 @@ class Repo:
|
|
|
255
280
|
Name exactly one. The snapshot is pinned to the commit resolved
|
|
256
281
|
now: a branch that moves later does not move it.
|
|
257
282
|
"""
|
|
258
|
-
|
|
283
|
+
roots: dict[str, str] = {}
|
|
284
|
+
target = self._resolve_ref(commit=commit, branch=branch, tag=tag, roots=roots)
|
|
285
|
+
return Snapshot(self, target, root=roots.get(target))
|
|
259
286
|
|
|
260
287
|
# -- Maintenance --
|
|
261
288
|
|
|
@@ -287,13 +314,18 @@ class Repo:
|
|
|
287
314
|
commit: str | None = None,
|
|
288
315
|
branch: str | None = None,
|
|
289
316
|
tag: str | None = None,
|
|
317
|
+
roots: dict[str, str] | None = None,
|
|
290
318
|
) -> str:
|
|
291
|
-
"""The commit a ref names; exactly one must be given.
|
|
319
|
+
"""The commit a ref names; exactly one must be given.
|
|
320
|
+
|
|
321
|
+
Checking that the commit exists reads its keyset root; ``roots``,
|
|
322
|
+
when given, keeps it, for a caller about to open that tree.
|
|
323
|
+
"""
|
|
292
324
|
given = [ref for ref in (commit, branch, tag) if ref is not None]
|
|
293
325
|
if len(given) != 1:
|
|
294
326
|
raise ValueError("name exactly one of commit, branch or tag")
|
|
295
327
|
if branch is not None:
|
|
296
|
-
return self.branches
|
|
328
|
+
return self.branches._resolve(branch, roots)
|
|
297
329
|
if tag is not None:
|
|
298
330
|
tagged = _kv._resolve_tag(self._store, tag)
|
|
299
331
|
if tagged is None:
|
|
@@ -301,7 +333,7 @@ class Repo:
|
|
|
301
333
|
target = tagged
|
|
302
334
|
else:
|
|
303
335
|
target = commit # type: ignore[assignment]
|
|
304
|
-
if self._store
|
|
336
|
+
if not _kv._has_root(self._store, target, roots):
|
|
305
337
|
raise UnknownCommitError(f"Commit '{target}' does not exist")
|
|
306
338
|
return target
|
|
307
339
|
|
|
@@ -313,9 +345,11 @@ class Snapshot(Mapping[str, Any]):
|
|
|
313
345
|
is the same state as stored bytes, whatever the codec.
|
|
314
346
|
"""
|
|
315
347
|
|
|
316
|
-
def __init__(self, repo: Repo, commit: str) -> None:
|
|
348
|
+
def __init__(self, repo: Repo, commit: str, *, root: str | None = None) -> None:
|
|
317
349
|
self._repo = repo
|
|
318
350
|
self.commit = commit
|
|
351
|
+
# The keyset root, when whoever made this snapshot already read it.
|
|
352
|
+
self._root = root
|
|
319
353
|
self._keyset: Keyset | None = None
|
|
320
354
|
# The whole keyset as key -> blob pointer, once something iterates
|
|
321
355
|
# it; until then, reads look keys up in the tree one batch at a
|
|
@@ -328,10 +362,13 @@ class Snapshot(Mapping[str, Any]):
|
|
|
328
362
|
|
|
329
363
|
def _tree(self) -> Keyset:
|
|
330
364
|
if self._keyset is None:
|
|
331
|
-
|
|
332
|
-
if
|
|
333
|
-
|
|
334
|
-
|
|
365
|
+
root = self._root
|
|
366
|
+
if root is None:
|
|
367
|
+
root_raw = self._repo._store.get(COMMIT_ROOT % self.commit)
|
|
368
|
+
if root_raw is None:
|
|
369
|
+
raise UnknownCommitError(f"Commit '{self.commit}' does not exist")
|
|
370
|
+
root = loads(root_raw)
|
|
371
|
+
self._keyset = Keyset(self._repo._store, root=root)
|
|
335
372
|
return self._keyset
|
|
336
373
|
|
|
337
374
|
def _index(self) -> dict[str, str]:
|
|
@@ -369,7 +406,7 @@ class Snapshot(Mapping[str, Any]):
|
|
|
369
406
|
wanted.setdefault(pointer, []).append(key)
|
|
370
407
|
if not wanted:
|
|
371
408
|
return {}
|
|
372
|
-
found = self._repo.
|
|
409
|
+
found = self._repo._store.get_many(wanted.keys())
|
|
373
410
|
return {key: raw for pointer, raw in found.items() for key in wanted[pointer]}
|
|
374
411
|
|
|
375
412
|
def __getitem__(self, key: str) -> Any:
|