kvgit 0.3.2__tar.gz → 0.3.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.
- {kvgit-0.3.2 → kvgit-0.3.3}/PKG-INFO +2 -2
- {kvgit-0.3.2 → kvgit-0.3.3}/kvgit/codecs/numpy.py +2 -2
- {kvgit-0.3.2 → kvgit-0.3.3}/kvgit/codecs/pickler.py +2 -1
- {kvgit-0.3.2 → kvgit-0.3.3}/kvgit/content_types.py +2 -1
- {kvgit-0.3.2 → kvgit-0.3.3}/kvgit/encoding.py +2 -1
- {kvgit-0.3.2 → kvgit-0.3.3}/kvgit/kv/indexeddb.py +4 -3
- {kvgit-0.3.2 → kvgit-0.3.3}/kvgit/namespaced.py +2 -2
- {kvgit-0.3.2 → kvgit-0.3.3}/kvgit/staged.py +2 -2
- {kvgit-0.3.2 → kvgit-0.3.3}/kvgit/store.py +7 -2
- {kvgit-0.3.2 → kvgit-0.3.3}/kvgit/versioned/base.py +1 -1
- {kvgit-0.3.2 → kvgit-0.3.3}/kvgit/versioned/helpers.py +1 -1
- {kvgit-0.3.2 → kvgit-0.3.3}/kvgit/versioned/kv.py +296 -52
- {kvgit-0.3.2 → kvgit-0.3.3}/kvgit/versioned/merge.py +3 -3
- {kvgit-0.3.2 → kvgit-0.3.3}/kvgit/versioned/protocol.py +2 -2
- {kvgit-0.3.2 → kvgit-0.3.3}/kvgit.egg-info/PKG-INFO +2 -2
- {kvgit-0.3.2 → kvgit-0.3.3}/kvgit.egg-info/requires.txt +1 -1
- kvgit-0.3.3/pyproject.toml +152 -0
- {kvgit-0.3.2 → kvgit-0.3.3}/tests/test_hamt.py +4 -5
- kvgit-0.3.2/pyproject.toml +0 -69
- {kvgit-0.3.2 → kvgit-0.3.3}/LICENSE +0 -0
- {kvgit-0.3.2 → kvgit-0.3.3}/README.md +0 -0
- {kvgit-0.3.2 → kvgit-0.3.3}/kvgit/__init__.py +1 -1
- {kvgit-0.3.2 → kvgit-0.3.3}/kvgit/codecs/__init__.py +0 -0
- {kvgit-0.3.2 → kvgit-0.3.3}/kvgit/codecs/_hash.py +0 -0
- {kvgit-0.3.2 → kvgit-0.3.3}/kvgit/codecs/base.py +0 -0
- {kvgit-0.3.2 → kvgit-0.3.3}/kvgit/codecs/pandas.py +0 -0
- {kvgit-0.3.2 → kvgit-0.3.3}/kvgit/errors.py +0 -0
- {kvgit-0.3.2 → kvgit-0.3.3}/kvgit/hamt.py +0 -0
- {kvgit-0.3.2 → kvgit-0.3.3}/kvgit/kv/__init__.py +0 -0
- {kvgit-0.3.2 → kvgit-0.3.3}/kvgit/kv/base.py +0 -0
- {kvgit-0.3.2 → kvgit-0.3.3}/kvgit/kv/composite.py +0 -0
- {kvgit-0.3.2 → kvgit-0.3.3}/kvgit/kv/disk.py +0 -0
- {kvgit-0.3.2 → kvgit-0.3.3}/kvgit/kv/memory.py +0 -0
- {kvgit-0.3.2 → kvgit-0.3.3}/kvgit/py.typed +0 -0
- {kvgit-0.3.2 → kvgit-0.3.3}/kvgit/versioned/__init__.py +0 -0
- {kvgit-0.3.2 → kvgit-0.3.3}/kvgit/versioned/keyset.py +0 -0
- {kvgit-0.3.2 → kvgit-0.3.3}/kvgit.egg-info/SOURCES.txt +0 -0
- {kvgit-0.3.2 → kvgit-0.3.3}/kvgit.egg-info/dependency_links.txt +0 -0
- {kvgit-0.3.2 → kvgit-0.3.3}/kvgit.egg-info/top_level.txt +0 -0
- {kvgit-0.3.2 → kvgit-0.3.3}/setup.cfg +0 -0
- {kvgit-0.3.2 → kvgit-0.3.3}/tests/test_content_types.py +0 -0
- {kvgit-0.3.2 → kvgit-0.3.3}/tests/test_delete_branches.py +0 -0
- {kvgit-0.3.2 → kvgit-0.3.3}/tests/test_namespaced.py +0 -0
- {kvgit-0.3.2 → kvgit-0.3.3}/tests/test_staged.py +0 -0
- {kvgit-0.3.2 → kvgit-0.3.3}/tests/test_store_factory.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: kvgit
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.3
|
|
4
4
|
Summary: Versioned key-value store with git-like commit, branch, and merge semantics.
|
|
5
5
|
Author: ashenfad
|
|
6
6
|
License: MIT
|
|
@@ -42,7 +42,7 @@ Requires-Dist: pandas>=2.0; extra == "all"
|
|
|
42
42
|
Provides-Extra: dev
|
|
43
43
|
Requires-Dist: pytest; extra == "dev"
|
|
44
44
|
Requires-Dist: diskcache; extra == "dev"
|
|
45
|
-
Requires-Dist: ruff; extra == "dev"
|
|
45
|
+
Requires-Dist: ruff<0.17,>=0.16; extra == "dev"
|
|
46
46
|
Requires-Dist: numpy>=1.24; extra == "dev"
|
|
47
47
|
Requires-Dist: pandas>=2.0; extra == "dev"
|
|
48
48
|
Dynamic: license-file
|
|
@@ -33,7 +33,7 @@ storing each unique buffer once instead of N times.)
|
|
|
33
33
|
|
|
34
34
|
from __future__ import annotations
|
|
35
35
|
|
|
36
|
-
from typing import
|
|
36
|
+
from typing import TYPE_CHECKING, Any
|
|
37
37
|
|
|
38
38
|
if TYPE_CHECKING:
|
|
39
39
|
import numpy as np
|
|
@@ -66,7 +66,7 @@ def _descr_to_dtype(descr):
|
|
|
66
66
|
return npf.descr_to_dtype(descr)
|
|
67
67
|
|
|
68
68
|
|
|
69
|
-
def _root_and_offset(arr:
|
|
69
|
+
def _root_and_offset(arr: np.ndarray) -> tuple[np.ndarray, int]:
|
|
70
70
|
"""Walk ``.base`` to the root buffer; return (root, byte_offset).
|
|
71
71
|
|
|
72
72
|
Returns ``(arr, 0)`` if the .base chain leads somewhere we can't
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"""Merge functions for typed values."""
|
|
2
2
|
|
|
3
|
-
from
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from typing import Any
|
|
4
5
|
|
|
5
6
|
MergeFn = Callable[[Any | None, Any, Any], Any]
|
|
6
7
|
"""Merge function: (old_value | None, our_value, their_value) -> merged_value.
|
|
@@ -47,8 +47,8 @@ the handler is set, causing a deadlock.
|
|
|
47
47
|
|
|
48
48
|
from collections.abc import Iterable, Mapping
|
|
49
49
|
|
|
50
|
-
from pyodide.ffi import create_proxy, run_sync, to_js # type: ignore[import-not-found]
|
|
51
50
|
from js import Promise, indexedDB, undefined # type: ignore[import-not-found]
|
|
51
|
+
from pyodide.ffi import create_proxy, run_sync, to_js # type: ignore[import-not-found]
|
|
52
52
|
|
|
53
53
|
from .base import KVStore
|
|
54
54
|
|
|
@@ -155,8 +155,9 @@ def _to_bytes(js_value) -> bytes | None:
|
|
|
155
155
|
return None
|
|
156
156
|
try:
|
|
157
157
|
return js_value.to_py().tobytes()
|
|
158
|
-
except Exception:
|
|
159
|
-
#
|
|
158
|
+
except Exception: # noqa: BLE001 — a JS->Python conversion can fail
|
|
159
|
+
# with anything the FFI decides to raise; there is no narrower
|
|
160
|
+
# type to name. Corrupted or unexpected JS value — treat as missing.
|
|
160
161
|
return None
|
|
161
162
|
|
|
162
163
|
|
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
-
from collections.abc import Iterator, MutableMapping
|
|
6
|
-
from typing import Any
|
|
5
|
+
from collections.abc import Iterable, Iterator, MutableMapping
|
|
6
|
+
from typing import Any
|
|
7
7
|
|
|
8
8
|
|
|
9
9
|
class Namespaced(MutableMapping[str, Any]):
|
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
import inspect
|
|
4
4
|
import pickle
|
|
5
|
-
from collections.abc import Iterable, Iterator, MutableMapping
|
|
6
|
-
from typing import Any
|
|
5
|
+
from collections.abc import Callable, Iterable, Iterator, MutableMapping
|
|
6
|
+
from typing import Any
|
|
7
7
|
|
|
8
8
|
from .codecs._hash import hash_bytes
|
|
9
9
|
from .content_types import MergeFn
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
"""Store factory function."""
|
|
2
2
|
|
|
3
3
|
import pickle
|
|
4
|
-
from collections.abc import Iterable
|
|
5
|
-
from typing import Any,
|
|
4
|
+
from collections.abc import Callable, Iterable
|
|
5
|
+
from typing import Any, Literal
|
|
6
6
|
|
|
7
7
|
from .kv.base import KVStore
|
|
8
8
|
from .kv.memory import Memory
|
|
@@ -123,6 +123,11 @@ def delete_branches(
|
|
|
123
123
|
not swept out from under it; an admin who knows the store is quiet
|
|
124
124
|
can pass ``0`` to reclaim young commits immediately.
|
|
125
125
|
|
|
126
|
+
That sweep does not reclaim chunks — the content-addressed bytes a
|
|
127
|
+
chunked codec writes — because a chunk key is not the deleted
|
|
128
|
+
branch's to give away. On a store using chunked codecs, follow up
|
|
129
|
+
with ``deep_clean`` on a quiescent store to get that space back.
|
|
130
|
+
|
|
126
131
|
Args:
|
|
127
132
|
names: Branch names to delete — one name or an iterable of
|
|
128
133
|
them. Missing names are no-ops. (A bare string is treated
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"""Shared commit/merge orchestration for versioned stores."""
|
|
2
2
|
|
|
3
3
|
from abc import ABC, abstractmethod
|
|
4
|
-
from
|
|
4
|
+
from collections.abc import Iterable
|
|
5
5
|
|
|
6
6
|
from ..errors import ConcurrencyError, MergeConflict
|
|
7
7
|
from .helpers import diff_keysets, walk_history
|
|
@@ -13,6 +13,11 @@ Storage layout (v3):
|
|
|
13
13
|
- ``kvgit:chunk:<chunk_hash>`` — content-addressed chunk bytes (v3)
|
|
14
14
|
- ``<commit_hash>:<user_key>`` — blob value bytes
|
|
15
15
|
|
|
16
|
+
``__branch_head_prev__`` is written only after a HEAD swap succeeds, so
|
|
17
|
+
it always names a commit ``__branch_head__`` really held. Recovery reads
|
|
18
|
+
it, so a value that was never HEAD would graft onto the branch a lineage
|
|
19
|
+
it never had.
|
|
20
|
+
|
|
16
21
|
The keyset (key -> blob_pointer + meta) is stored as a content-addressable
|
|
17
22
|
HAMT, so unchanged subtrees are shared across commits by hash equality. A
|
|
18
23
|
single-key change writes O(log N) new nodes instead of rewriting a full
|
|
@@ -20,8 +25,15 @@ keyset snapshot per commit.
|
|
|
20
25
|
|
|
21
26
|
Chunks (v3) are content-addressed bytes referenced by per-key
|
|
22
27
|
``MetaEntry.chunks``. They let chunked codecs (numpy, pandas, ...) share
|
|
23
|
-
large buffers across keys, commits, and branches.
|
|
24
|
-
|
|
28
|
+
large buffers across keys, commits, and branches.
|
|
29
|
+
|
|
30
|
+
Chunks are the *only* class above keyed purely by content. Blobs carry
|
|
31
|
+
the commit hash in the key; HAMT nodes embed that blob pointer, so a
|
|
32
|
+
node hash is commit-scoped too. That difference decides who may delete
|
|
33
|
+
what: ``clean_orphans`` never deletes chunks, because a chunk an orphan
|
|
34
|
+
owns may be the same key a commit made a moment ago just deduped onto,
|
|
35
|
+
and the sweep has no way to know. Only ``deep_clean``, which requires a
|
|
36
|
+
quiescent store, reclaims chunks.
|
|
25
37
|
|
|
26
38
|
v3 is a strict superset of v2:
|
|
27
39
|
|
|
@@ -139,12 +151,22 @@ def _load_root(store: KVStore, commit_hash: str) -> str | None:
|
|
|
139
151
|
return val if isinstance(val, str) else None
|
|
140
152
|
|
|
141
153
|
|
|
142
|
-
def _resolve_head(store: KVStore, branch: str
|
|
154
|
+
def _resolve_head(store: KVStore, branch: str) -> str | None:
|
|
143
155
|
"""Resolve a branch HEAD, falling back to prev HEAD or commit scan.
|
|
144
156
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
157
|
+
**Never writes.** Every read path in the library goes through here,
|
|
158
|
+
so healing the damage in place would make an ordinary ``get`` a
|
|
159
|
+
mutation — impossible for a read-only consumer, and a race between
|
|
160
|
+
two readers repairing the same branch to different answers. The
|
|
161
|
+
recovery is returned to the caller and forgotten; :func:`repair_head`
|
|
162
|
+
is the explicit call that makes it durable, and the write path heals
|
|
163
|
+
HEAD itself as part of the CAS that has to move it anyway.
|
|
164
|
+
|
|
165
|
+
The cost of not persisting is paid per read on a damaged store: two
|
|
166
|
+
extra ``get`` calls for the prev-HEAD tier, and a full store scan
|
|
167
|
+
for the tier below it. That scan already ran per *write* before this
|
|
168
|
+
change (``latest_head`` never repaired), and a store sitting on a
|
|
169
|
+
corrupt HEAD has a bigger problem than read latency.
|
|
148
170
|
|
|
149
171
|
Returns a valid commit hash, or None if unrecoverable.
|
|
150
172
|
"""
|
|
@@ -158,7 +180,7 @@ def _resolve_head(store: KVStore, branch: str, *, repair: bool = True) -> str |
|
|
|
158
180
|
):
|
|
159
181
|
return commit_hash
|
|
160
182
|
|
|
161
|
-
# 2. Try previous HEAD (backup written
|
|
183
|
+
# 2. Try previous HEAD (backup written after each successful CAS)
|
|
162
184
|
prev_bytes = store.get(BRANCH_HEAD_PREV % branch)
|
|
163
185
|
if prev_bytes is not None:
|
|
164
186
|
commit_hash = safe_loads(prev_bytes)
|
|
@@ -169,8 +191,6 @@ def _resolve_head(store: KVStore, branch: str, *, repair: bool = True) -> str |
|
|
|
169
191
|
logger.warning(
|
|
170
192
|
"Branch '%s': HEAD corrupt, recovered from prev HEAD", branch
|
|
171
193
|
)
|
|
172
|
-
if repair:
|
|
173
|
-
store.set(BRANCH_HEAD % branch, dumps(commit_hash))
|
|
174
194
|
return commit_hash
|
|
175
195
|
|
|
176
196
|
# 3. HEAD existed but is corrupt and no prev — scan for best commit
|
|
@@ -180,8 +200,6 @@ def _resolve_head(store: KVStore, branch: str, *, repair: bool = True) -> str |
|
|
|
180
200
|
logger.warning(
|
|
181
201
|
"Branch '%s': HEAD corrupt, recovered via commit scan", branch
|
|
182
202
|
)
|
|
183
|
-
if repair:
|
|
184
|
-
store.set(BRANCH_HEAD % branch, dumps(commit_hash))
|
|
185
203
|
return commit_hash
|
|
186
204
|
|
|
187
205
|
return None
|
|
@@ -208,7 +226,9 @@ def _scan_for_best_commit(store: KVStore, branch: str) -> str | None:
|
|
|
208
226
|
val = safe_loads(time_bytes)
|
|
209
227
|
if isinstance(val, (int, float)):
|
|
210
228
|
ts = float(val)
|
|
211
|
-
except Exception:
|
|
229
|
+
except Exception: # noqa: BLE001 — recovery scan over a store
|
|
230
|
+
# already known to be damaged; an unreadable timestamp
|
|
231
|
+
# must degrade to 0.0, never abort the scan.
|
|
212
232
|
pass
|
|
213
233
|
all_commits[h] = ts
|
|
214
234
|
|
|
@@ -266,12 +286,81 @@ def _scan_for_best_commit(store: KVStore, branch: str) -> str | None:
|
|
|
266
286
|
return max(tips, key=lambda h: all_commits.get(h, 0))
|
|
267
287
|
|
|
268
288
|
|
|
289
|
+
def _heal_head(store: KVStore, branch: str, recovered: bytes) -> bool:
|
|
290
|
+
"""Atomically replace an unresolvable HEAD with a recovered value.
|
|
291
|
+
|
|
292
|
+
Returns True only when HEAD was damaged *and* this call is the one
|
|
293
|
+
that replaced it. Three cases are deliberately left alone:
|
|
294
|
+
|
|
295
|
+
* HEAD resolves fine — it did not break, it *moved*, and another
|
|
296
|
+
writer won a legitimate race. Overwriting it would destroy a good
|
|
297
|
+
commit to make a losing CAS succeed.
|
|
298
|
+
* HEAD already holds ``recovered`` — nothing to do.
|
|
299
|
+
* HEAD is absent — the branch was deleted. Re-creating the key is
|
|
300
|
+
exactly the resurrection ``delete_branch`` drops the prev-HEAD
|
|
301
|
+
backup to prevent.
|
|
302
|
+
|
|
303
|
+
The replacement is a CAS against the exact damaged bytes, so two
|
|
304
|
+
processes healing the same branch cannot both win, and a HEAD that
|
|
305
|
+
someone else repaired (or advanced) in the meantime is never
|
|
306
|
+
clobbered.
|
|
307
|
+
"""
|
|
308
|
+
branch_key = BRANCH_HEAD % branch
|
|
309
|
+
raw = store.get(branch_key)
|
|
310
|
+
if raw is None or raw == recovered:
|
|
311
|
+
return False
|
|
312
|
+
commit_hash = safe_loads(raw)
|
|
313
|
+
if (
|
|
314
|
+
isinstance(commit_hash, str)
|
|
315
|
+
and store.get(COMMIT_ROOT % commit_hash) is not None
|
|
316
|
+
):
|
|
317
|
+
return False
|
|
318
|
+
if not store.cas(branch_key, recovered, expected=raw):
|
|
319
|
+
return False
|
|
320
|
+
logger.warning("Branch '%s': corrupt HEAD replaced with recovered commit", branch)
|
|
321
|
+
return True
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def repair_head(store: KVStore, branch: str = "main") -> str | None:
|
|
325
|
+
"""Persist a recovered HEAD for a damaged branch.
|
|
326
|
+
|
|
327
|
+
Read paths recover a corrupt ``__branch_head__`` in memory and leave
|
|
328
|
+
the store untouched, so the damage stays visible until someone
|
|
329
|
+
decides what to do about it. This is that decision: resolve the
|
|
330
|
+
branch the way a read would, and write the answer back.
|
|
331
|
+
|
|
332
|
+
Handle-independent, like :func:`clean_orphans` — it takes a raw
|
|
333
|
+
``KVStore`` and touches nothing else, so it works with or without a
|
|
334
|
+
``VersionedKV`` anchored on the branch.
|
|
335
|
+
|
|
336
|
+
Idempotent, and a no-op on a healthy branch. The write is a CAS
|
|
337
|
+
against the damaged bytes, so it cannot overwrite a HEAD another
|
|
338
|
+
process fixed, or advanced, in the meantime.
|
|
339
|
+
|
|
340
|
+
When that CAS does not win — another process repaired the branch,
|
|
341
|
+
advanced it, or deleted it between resolving and healing — the
|
|
342
|
+
recovery candidate is stale, and returning it would name an older
|
|
343
|
+
commit than HEAD actually holds. The branch is re-resolved instead,
|
|
344
|
+
so the answer describes the store rather than the attempt.
|
|
345
|
+
|
|
346
|
+
Returns:
|
|
347
|
+
The commit HEAD now names, or None if the branch does not exist
|
|
348
|
+
or nothing recoverable was found.
|
|
349
|
+
"""
|
|
350
|
+
commit_hash = _resolve_head(store, branch)
|
|
351
|
+
if commit_hash is None:
|
|
352
|
+
return None
|
|
353
|
+
if _heal_head(store, branch, dumps(commit_hash)):
|
|
354
|
+
return commit_hash
|
|
355
|
+
return _resolve_head(store, branch)
|
|
356
|
+
|
|
357
|
+
|
|
269
358
|
def clean_orphans(store: KVStore, min_age: float = 3600) -> int:
|
|
270
359
|
"""Remove orphaned commits unreachable from any branch HEAD.
|
|
271
360
|
|
|
272
361
|
Traces all reachable commits from live branch HEADs, then deletes
|
|
273
|
-
commit metadata, blobs
|
|
274
|
-
|
|
362
|
+
the commit metadata, blobs and HAMT nodes owned by the orphaned
|
|
363
|
+
commits and not shared with anything still reachable.
|
|
275
364
|
|
|
276
365
|
Handle-independent by design: it marks from ALL live branch HEADs
|
|
277
366
|
and touches nothing but ``store``, so it works with or without a
|
|
@@ -279,12 +368,63 @@ def clean_orphans(store: KVStore, min_age: float = 3600) -> int:
|
|
|
279
368
|
and the anchor-free admin path (:func:`kvgit.delete_branches`)
|
|
280
369
|
share this one implementation.
|
|
281
370
|
|
|
282
|
-
|
|
283
|
-
|
|
371
|
+
Safe under concurrent writers: every deletion candidate is
|
|
372
|
+
discovered by walking an orphan commit's own keyset, and every
|
|
373
|
+
class it deletes is commit-scoped (blob keys carry the commit
|
|
374
|
+
hash; HAMT nodes embed that pointer), so a commit that lands
|
|
375
|
+
mid-sweep can never contribute one.
|
|
376
|
+
|
|
377
|
+
**Does not reclaim chunks.** Chunk keys are pure content hashes,
|
|
378
|
+
so an orphan's chunk and a brand-new commit's chunk are the same
|
|
379
|
+
key whenever the bytes match — "the orphan owned it" does not
|
|
380
|
+
imply "safe to delete", at any window size. Chunks are the large
|
|
381
|
+
objects (numpy and pandas buffers), so on a store using chunked
|
|
382
|
+
codecs they accumulate between maintenance passes; a store that
|
|
383
|
+
never uses a chunked codec has none and loses nothing here. Run
|
|
384
|
+
:func:`deep_clean` on a quiescent store to reclaim them, along
|
|
385
|
+
with nodes and chunks no commit points at.
|
|
386
|
+
|
|
387
|
+
The ``min_age`` guard (default 1 hour) still applies: it decides
|
|
388
|
+
which unreachable commits are old enough to delete at all.
|
|
284
389
|
|
|
285
390
|
Returns:
|
|
286
391
|
Number of orphaned commits removed.
|
|
287
392
|
"""
|
|
393
|
+
return _sweep(store, min_age, deep=False)
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def deep_clean(store: KVStore, min_age: float = 3600) -> int:
|
|
397
|
+
"""Sweep orphans *and* every unreferenced node and chunk. Unsafe.
|
|
398
|
+
|
|
399
|
+
Does everything :func:`clean_orphans` does, then additionally scans
|
|
400
|
+
the whole ``kvgit:keyset:`` and ``kvgit:chunk:`` namespaces and
|
|
401
|
+
deletes anything not reachable from a live branch head or a young
|
|
402
|
+
orphan commit. That namespace scan is the only way to reclaim
|
|
403
|
+
nodes and chunks that no commit references any more — leftovers
|
|
404
|
+
from a crash, from an interrupted write, or from a store swept by
|
|
405
|
+
an earlier kvgit — because no orphan keyset points at them.
|
|
406
|
+
|
|
407
|
+
It is also the only way to reclaim **any** chunk at all, including
|
|
408
|
+
ones a deleted orphan uniquely owned: the incremental sweep leaves
|
|
409
|
+
every chunk in place, so on a store using chunked codecs this is
|
|
410
|
+
the maintenance pass that gives the space back.
|
|
411
|
+
|
|
412
|
+
**Not safe against concurrent writers.** The scan runs after the
|
|
413
|
+
mark phase, so it sees, and deletes, artifacts written by any
|
|
414
|
+
commit that landed in between — including a commit that has since
|
|
415
|
+
become a live branch HEAD. Run it only on a quiescent store: no
|
|
416
|
+
other process or thread writing, for the whole call. ``min_age``
|
|
417
|
+
does not protect you here; it governs commit deletion, not the
|
|
418
|
+
namespace scan.
|
|
419
|
+
|
|
420
|
+
Returns:
|
|
421
|
+
Number of orphaned commits removed.
|
|
422
|
+
"""
|
|
423
|
+
return _sweep(store, min_age, deep=True)
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def _sweep(store: KVStore, min_age: float, *, deep: bool) -> int:
|
|
427
|
+
"""Shared mark-and-sweep behind ``clean_orphans`` / ``deep_clean``."""
|
|
288
428
|
gc_logger = logging.getLogger("kvgit.orphans")
|
|
289
429
|
cutoff_time = time.time() - min_age
|
|
290
430
|
|
|
@@ -304,6 +444,11 @@ def clean_orphans(store: KVStore, min_age: float = 3600) -> int:
|
|
|
304
444
|
reachable_commits: set[str] = set()
|
|
305
445
|
reachable_blobs: set[str] = set()
|
|
306
446
|
reachable_nodes: set[str] = set()
|
|
447
|
+
# Only the deep path deletes chunks, and it is the only consumer of
|
|
448
|
+
# this set. The incremental path leaves it empty on purpose rather
|
|
449
|
+
# than paying to accumulate every chunk hash in the store — if you
|
|
450
|
+
# ever add a chunk deletion outside the ``if deep:`` block below,
|
|
451
|
+
# you must populate this unconditionally first.
|
|
307
452
|
reachable_chunks: set[str] = set()
|
|
308
453
|
|
|
309
454
|
def _walk_commit_for_marks(commit_hash: str) -> None:
|
|
@@ -319,7 +464,7 @@ def clean_orphans(store: KVStore, min_age: float = 3600) -> int:
|
|
|
319
464
|
entries, new_nodes = Keyset(store, root=root).walk(skip_nodes=reachable_nodes)
|
|
320
465
|
for entry in entries.values():
|
|
321
466
|
reachable_blobs.add(entry.blob)
|
|
322
|
-
if entry.meta.chunks:
|
|
467
|
+
if deep and entry.meta.chunks:
|
|
323
468
|
reachable_chunks.update(entry.meta.chunks)
|
|
324
469
|
reachable_nodes.update(new_nodes)
|
|
325
470
|
|
|
@@ -368,29 +513,56 @@ def clean_orphans(store: KVStore, min_age: float = 3600) -> int:
|
|
|
368
513
|
except (TypeError, ValueError):
|
|
369
514
|
continue
|
|
370
515
|
|
|
371
|
-
# Protect
|
|
372
|
-
#
|
|
516
|
+
# Protect what young orphan commits reference — they may belong to
|
|
517
|
+
# in-flight writers whose CAS has not landed yet. Their nodes and
|
|
518
|
+
# blobs matter to both paths (an aged orphan sharing a subtree with
|
|
519
|
+
# a young one must not take it down); their chunks matter to
|
|
520
|
+
# ``deep_clean``, whose namespace scan would otherwise eat them.
|
|
373
521
|
for young in young_orphan_commits:
|
|
374
522
|
_walk_commit_for_marks(young)
|
|
375
523
|
|
|
376
524
|
# Collect everything to delete in one batch so the sweep is atomic
|
|
377
525
|
# at the store level (defends against partial sweeps under crash).
|
|
378
526
|
all_removals: list[str] = []
|
|
527
|
+
keyset_prefix = Keyset.DEFAULT_PREFIX
|
|
379
528
|
|
|
529
|
+
# Every deletion candidate comes from walking an orphan's own
|
|
530
|
+
# keyset — never from a namespace scan. That is what makes this
|
|
531
|
+
# safe under concurrent writers: a commit that lands after the
|
|
532
|
+
# mark phase is in nobody's orphan tree, so nothing it wrote can
|
|
533
|
+
# end up on this list. ``skip_nodes=reachable_nodes`` prunes
|
|
534
|
+
# subtrees shared with a live commit or a young orphan, which is
|
|
535
|
+
# both the correct thing (nothing under them is deletable) and
|
|
536
|
+
# the cheap thing (shared structure is walked once, not per
|
|
537
|
+
# orphan). Two orphans sharing a subtree may each name the same
|
|
538
|
+
# hash; ``remove_many`` tolerates duplicates.
|
|
539
|
+
#
|
|
540
|
+
# Chunks are deliberately absent here. Blob keys are
|
|
541
|
+
# ``<commit_hash>:<key>`` and HAMT nodes embed that pointer, so
|
|
542
|
+
# both are commit-scoped: an orphan's node hash can only collide
|
|
543
|
+
# with a commit in its own ancestry, which the mark phase already
|
|
544
|
+
# covered. A chunk key is ``kvgit:chunk:<content_hash>`` and
|
|
545
|
+
# carries nothing commit-derived, so an orphan's chunk and a
|
|
546
|
+
# brand-new commit's chunk are the *same key* whenever the bytes
|
|
547
|
+
# match. "In the orphan's tree" therefore does not imply "safe to
|
|
548
|
+
# delete", and no amount of scoping fixes that — the new commit
|
|
549
|
+
# was never marked. Chunk reclamation lives in ``deep_clean``.
|
|
380
550
|
for orphan_hash in orphans:
|
|
381
551
|
orphan_root = _load_root(store, orphan_hash)
|
|
382
552
|
if orphan_root is not None and orphan_root != EMPTY_HASH:
|
|
383
553
|
try:
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
554
|
+
orphan_entries, orphan_nodes = Keyset(store, root=orphan_root).walk(
|
|
555
|
+
skip_nodes=reachable_nodes
|
|
556
|
+
)
|
|
557
|
+
except Exception: # noqa: BLE001 — deliberate: a damaged
|
|
558
|
+
# orphan must not stall the sweep; drop its payload and
|
|
559
|
+
# still reclaim its commit metadata. Narrowing this would
|
|
560
|
+
# let one corrupt keyset block GC for the whole store.
|
|
561
|
+
orphan_entries, orphan_nodes = {}, set()
|
|
562
|
+
for entry in orphan_entries.values():
|
|
563
|
+
if entry.blob not in reachable_blobs:
|
|
564
|
+
all_removals.append(entry.blob)
|
|
565
|
+
all_removals.extend(keyset_prefix + node for node in orphan_nodes)
|
|
394
566
|
all_removals.extend(
|
|
395
567
|
[
|
|
396
568
|
COMMIT_ROOT % orphan_hash,
|
|
@@ -400,23 +572,26 @@ def clean_orphans(store: KVStore, min_age: float = 3600) -> int:
|
|
|
400
572
|
]
|
|
401
573
|
)
|
|
402
574
|
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
575
|
+
if deep:
|
|
576
|
+
# Namespace scans. The node scan reclaims nodes no orphan
|
|
577
|
+
# keyset points at; the chunk scan is the *only* place chunks
|
|
578
|
+
# are ever deleted, orphan-owned ones included. Both are
|
|
579
|
+
# unsafe against a concurrent writer, because anything
|
|
580
|
+
# committed since the mark phase looks unreferenced here.
|
|
581
|
+
# Quiescent stores only.
|
|
582
|
+
for key in store.keys():
|
|
583
|
+
if not (isinstance(key, str) and key.startswith(keyset_prefix)):
|
|
584
|
+
continue
|
|
585
|
+
node_hash = key[len(keyset_prefix) :]
|
|
586
|
+
if node_hash and node_hash not in reachable_nodes:
|
|
587
|
+
all_removals.append(key)
|
|
411
588
|
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
if chunk_hash and chunk_hash not in reachable_chunks:
|
|
419
|
-
all_removals.append(key)
|
|
589
|
+
for key in store.keys():
|
|
590
|
+
if not (isinstance(key, str) and key.startswith(CHUNK_PREFIX)):
|
|
591
|
+
continue
|
|
592
|
+
chunk_hash = key[len(CHUNK_PREFIX) :]
|
|
593
|
+
if chunk_hash and chunk_hash not in reachable_chunks:
|
|
594
|
+
all_removals.append(key)
|
|
420
595
|
|
|
421
596
|
if all_removals:
|
|
422
597
|
store.remove_many(*all_removals)
|
|
@@ -497,7 +672,7 @@ class VersionedKV(VersionedBase):
|
|
|
497
672
|
@property
|
|
498
673
|
def latest_head(self) -> str | None:
|
|
499
674
|
"""Read HEAD directly from the KV store (reflects other writers)."""
|
|
500
|
-
return _resolve_head(self.store, self._branch
|
|
675
|
+
return _resolve_head(self.store, self._branch)
|
|
501
676
|
|
|
502
677
|
# -- Read operations --
|
|
503
678
|
|
|
@@ -729,13 +904,46 @@ class VersionedKV(VersionedBase):
|
|
|
729
904
|
def _cas_head(self, expected: str, new_head: str) -> bool:
|
|
730
905
|
"""Atomically advance branch HEAD via KVStore CAS.
|
|
731
906
|
|
|
732
|
-
|
|
733
|
-
|
|
907
|
+
The prev-HEAD backup is written **after** the swap succeeds,
|
|
908
|
+
never before. Written first, it lands whether or not the CAS
|
|
909
|
+
does, so a writer that loses the race still leaves its own stale
|
|
910
|
+
``expected`` as the branch's recovery target — clobbering the
|
|
911
|
+
winner's backup, and, when ``expected`` came from the commit
|
|
912
|
+
scan, naming a commit that was never HEAD at all. Writing it
|
|
913
|
+
afterwards makes it always a value ``__branch_head__`` really
|
|
914
|
+
held.
|
|
915
|
+
|
|
916
|
+
What this does **not** buy is a backup that is always exactly
|
|
917
|
+
one commit back. The swap and the backup write are two steps,
|
|
918
|
+
and anything that separates them — a crash, or simply losing
|
|
919
|
+
the CPU while another writer completes both of its own — lets
|
|
920
|
+
the older writer's backup land last. HEAD then sits two or more
|
|
921
|
+
commits ahead of a backup that is still a real former HEAD, and
|
|
922
|
+
recovery skips whatever came between.
|
|
923
|
+
|
|
924
|
+
So the guarantee is the narrower one: the backup always names a
|
|
925
|
+
commit ``__branch_head__`` really held, never a commit invented
|
|
926
|
+
by a losing writer. Recovery may lose more than one commit; it
|
|
927
|
+
cannot graft on a lineage the branch never had. There is no way
|
|
928
|
+
to do better with what the store offers — ``KVStore.cas`` takes
|
|
929
|
+
a single key and no backend exposes a transaction spanning two,
|
|
930
|
+
so HEAD and its backup cannot move in one step.
|
|
931
|
+
|
|
932
|
+
A CAS that fails against a *damaged* HEAD is retried once
|
|
933
|
+
through :func:`_heal_head`, which repairs it atomically. That is
|
|
934
|
+
the only place a corrupt HEAD is written back, now that reads
|
|
935
|
+
do not.
|
|
734
936
|
"""
|
|
735
937
|
branch_key = BRANCH_HEAD % self._branch
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
938
|
+
expected_bytes = dumps(expected)
|
|
939
|
+
new_bytes = dumps(new_head)
|
|
940
|
+
|
|
941
|
+
won = self.store.cas(branch_key, new_bytes, expected=expected_bytes)
|
|
942
|
+
if not won and _heal_head(self.store, self._branch, expected_bytes):
|
|
943
|
+
won = self.store.cas(branch_key, new_bytes, expected=expected_bytes)
|
|
944
|
+
if won:
|
|
945
|
+
self.store.set(BRANCH_HEAD_PREV % self._branch, expected_bytes)
|
|
946
|
+
return won
|
|
739
947
|
|
|
740
948
|
def _load_keyset(self, commit_hash: str) -> dict[str, str]:
|
|
741
949
|
"""Load just the keyset for a commit (key -> versioned_key mapping).
|
|
@@ -807,7 +1015,7 @@ class VersionedKV(VersionedBase):
|
|
|
807
1015
|
"""Reload state from HEAD."""
|
|
808
1016
|
commit_hash = _resolve_head(self.store, self._branch)
|
|
809
1017
|
if commit_hash is None:
|
|
810
|
-
raise ValueError("No HEAD commit found for branch
|
|
1018
|
+
raise ValueError(f"No HEAD commit found for branch {self._branch}")
|
|
811
1019
|
self._load_commit(commit_hash, update_base=True)
|
|
812
1020
|
|
|
813
1021
|
def checkout(
|
|
@@ -912,6 +1120,21 @@ class VersionedKV(VersionedBase):
|
|
|
912
1120
|
return None
|
|
913
1121
|
return loads(info_bytes)
|
|
914
1122
|
|
|
1123
|
+
# -- Recovery --
|
|
1124
|
+
|
|
1125
|
+
def repair_head(self) -> str | None:
|
|
1126
|
+
"""Persist a recovered HEAD for this branch.
|
|
1127
|
+
|
|
1128
|
+
Thin instance wrapper over :func:`repair_head`. Reads recover a
|
|
1129
|
+
damaged HEAD without writing it back; this is the explicit call
|
|
1130
|
+
that makes the recovery durable.
|
|
1131
|
+
|
|
1132
|
+
Returns:
|
|
1133
|
+
The commit HEAD now names, or None if nothing was
|
|
1134
|
+
recoverable.
|
|
1135
|
+
"""
|
|
1136
|
+
return repair_head(self.store, self._branch)
|
|
1137
|
+
|
|
915
1138
|
# -- Orphan cleanup --
|
|
916
1139
|
|
|
917
1140
|
def clean_orphans(self, min_age: float = 3600) -> int:
|
|
@@ -921,11 +1144,32 @@ class VersionedKV(VersionedBase):
|
|
|
921
1144
|
mark-and-sweep against ``self.store``. Kept as a method so
|
|
922
1145
|
existing callers (and ``delete_branch``) read naturally.
|
|
923
1146
|
|
|
1147
|
+
Reclaims commit metadata, blobs and HAMT nodes, but **not
|
|
1148
|
+
chunks** — see :func:`clean_orphans` for why, and
|
|
1149
|
+
:meth:`deep_clean` for the pass that reclaims them.
|
|
1150
|
+
|
|
924
1151
|
Returns:
|
|
925
1152
|
Number of orphaned commits removed.
|
|
926
1153
|
"""
|
|
927
1154
|
return clean_orphans(self.store, min_age)
|
|
928
1155
|
|
|
1156
|
+
def deep_clean(self, min_age: float = 3600) -> int:
|
|
1157
|
+
"""Orphan sweep plus a full unreferenced-node/chunk scan.
|
|
1158
|
+
|
|
1159
|
+
Thin instance wrapper over :func:`deep_clean`. **Requires a
|
|
1160
|
+
quiescent store** — the namespace scan will delete artifacts
|
|
1161
|
+
written by a concurrent writer, including ones a live branch
|
|
1162
|
+
HEAD has since come to depend on. Use :meth:`clean_orphans`
|
|
1163
|
+
for routine cleanup; schedule this one when you can quiesce
|
|
1164
|
+
the store, since it is the only pass that reclaims chunks —
|
|
1165
|
+
both the ones no commit references and the ones deleted
|
|
1166
|
+
orphans owned.
|
|
1167
|
+
|
|
1168
|
+
Returns:
|
|
1169
|
+
Number of orphaned commits removed.
|
|
1170
|
+
"""
|
|
1171
|
+
return deep_clean(self.store, min_age)
|
|
1172
|
+
|
|
929
1173
|
# -- Internal --
|
|
930
1174
|
|
|
931
1175
|
def _load_commit(self, commit_hash: str, *, update_base: bool) -> None:
|
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
"""Shared three-way merge resolution."""
|
|
2
2
|
|
|
3
|
+
from collections.abc import Callable
|
|
3
4
|
from dataclasses import dataclass
|
|
4
|
-
from typing import Callable
|
|
5
5
|
|
|
6
6
|
from ..errors import MergeConflict
|
|
7
7
|
from .protocol import BytesMergeFn, DiffResult
|
|
8
8
|
|
|
9
|
-
|
|
10
9
|
BlobReader = Callable[[str], bytes | None]
|
|
11
10
|
"""Read a blob by its content identifier (versioned key or hex SHA)."""
|
|
12
11
|
|
|
@@ -117,7 +116,8 @@ def resolve_merge(
|
|
|
117
116
|
result_val = fn(old_val, our_val, their_val)
|
|
118
117
|
merged_values[key] = result_val
|
|
119
118
|
auto_merged.append(key)
|
|
120
|
-
except Exception as e:
|
|
119
|
+
except Exception as e: # noqa: BLE001 — `fn` is caller-supplied;
|
|
120
|
+
# any failure it raises is reported as a merge conflict.
|
|
121
121
|
conflicts.add(key)
|
|
122
122
|
merge_errors[key] = e
|
|
123
123
|
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
"""Versioned protocol and types."""
|
|
2
2
|
|
|
3
|
+
from collections.abc import Callable, Iterable
|
|
3
4
|
from dataclasses import dataclass
|
|
4
|
-
from typing import
|
|
5
|
-
|
|
5
|
+
from typing import Protocol, runtime_checkable
|
|
6
6
|
|
|
7
7
|
BytesMergeFn = Callable[[bytes | None, bytes | None, bytes | None], bytes]
|
|
8
8
|
"""Merge function: (old_value, our_value, their_value) -> merged_value.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: kvgit
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.3
|
|
4
4
|
Summary: Versioned key-value store with git-like commit, branch, and merge semantics.
|
|
5
5
|
Author: ashenfad
|
|
6
6
|
License: MIT
|
|
@@ -42,7 +42,7 @@ Requires-Dist: pandas>=2.0; extra == "all"
|
|
|
42
42
|
Provides-Extra: dev
|
|
43
43
|
Requires-Dist: pytest; extra == "dev"
|
|
44
44
|
Requires-Dist: diskcache; extra == "dev"
|
|
45
|
-
Requires-Dist: ruff; extra == "dev"
|
|
45
|
+
Requires-Dist: ruff<0.17,>=0.16; extra == "dev"
|
|
46
46
|
Requires-Dist: numpy>=1.24; extra == "dev"
|
|
47
47
|
Requires-Dist: pandas>=2.0; extra == "dev"
|
|
48
48
|
Dynamic: license-file
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68.0"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "kvgit"
|
|
7
|
+
version = "0.3.3"
|
|
8
|
+
description = "Versioned key-value store with git-like commit, branch, and merge semantics."
|
|
9
|
+
authors = [{ name = "ashenfad" }]
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
readme = "README.md"
|
|
12
|
+
license = { text = "MIT" }
|
|
13
|
+
keywords = ["versioning", "git", "key-value", "branching", "merge"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 4 - Beta",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"Topic :: Database",
|
|
18
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Programming Language :: Python :: 3.10",
|
|
21
|
+
"Programming Language :: Python :: 3.11",
|
|
22
|
+
"Programming Language :: Python :: 3.12",
|
|
23
|
+
"Programming Language :: Python :: 3.13",
|
|
24
|
+
"Programming Language :: Python :: 3.14",
|
|
25
|
+
"License :: OSI Approved :: MIT License",
|
|
26
|
+
"Operating System :: OS Independent",
|
|
27
|
+
"Typing :: Typed",
|
|
28
|
+
]
|
|
29
|
+
dependencies = []
|
|
30
|
+
|
|
31
|
+
[project.urls]
|
|
32
|
+
"Homepage" = "https://github.com/ashenfad/kvgit"
|
|
33
|
+
"Bug Tracker" = "https://github.com/ashenfad/kvgit/issues"
|
|
34
|
+
"Documentation" = "https://github.com/ashenfad/kvgit#readme"
|
|
35
|
+
"Source" = "https://github.com/ashenfad/kvgit"
|
|
36
|
+
|
|
37
|
+
[project.optional-dependencies]
|
|
38
|
+
disk = ["diskcache"]
|
|
39
|
+
numpy = ["numpy>=1.24"]
|
|
40
|
+
pandas = ["numpy>=1.24", "pandas>=2.0"]
|
|
41
|
+
scientific = ["numpy>=1.24", "pandas>=2.0"]
|
|
42
|
+
all = ["diskcache", "numpy>=1.24", "pandas>=2.0"]
|
|
43
|
+
dev = [
|
|
44
|
+
"pytest",
|
|
45
|
+
"diskcache",
|
|
46
|
+
# Pinned to the minor line the [tool.ruff.lint] config below was
|
|
47
|
+
# written against. Ruff's rule *defaults* move between minors, so a
|
|
48
|
+
# floating pin makes "lint passes" mean something different on each
|
|
49
|
+
# upgrade.
|
|
50
|
+
"ruff>=0.16,<0.17",
|
|
51
|
+
"numpy>=1.24",
|
|
52
|
+
"pandas>=2.0",
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
[tool.setuptools.packages.find]
|
|
56
|
+
include = ["kvgit*"]
|
|
57
|
+
|
|
58
|
+
[tool.mypy]
|
|
59
|
+
warn_unused_configs = true
|
|
60
|
+
|
|
61
|
+
[[tool.mypy.overrides]]
|
|
62
|
+
module = ["diskcache"]
|
|
63
|
+
ignore_missing_imports = true
|
|
64
|
+
|
|
65
|
+
[tool.pytest.ini_options]
|
|
66
|
+
testpaths = ["tests"]
|
|
67
|
+
|
|
68
|
+
[tool.ruff]
|
|
69
|
+
# ruff 0.16 formats Python code blocks inside Markdown. This project's docs
|
|
70
|
+
# align trailing comments by hand, so letting the formatter at them produces
|
|
71
|
+
# churn unrelated to whatever change is being made, on any commit touching
|
|
72
|
+
# docs/ -- including docs-only ones. `force-exclude` is required because
|
|
73
|
+
# pre-commit passes filenames explicitly, and ruff ignores `extend-exclude`
|
|
74
|
+
# for explicitly-passed paths without it.
|
|
75
|
+
extend-exclude = ["*.md"]
|
|
76
|
+
force-exclude = true
|
|
77
|
+
|
|
78
|
+
[tool.ruff.lint]
|
|
79
|
+
# Every rule this project enforces is named here. Ruff's *default*
|
|
80
|
+
# selection is not stable across releases — 0.15 defaulted to
|
|
81
|
+
# `E4,E7,E9,F`, 0.16 replaced that with a far wider recommended set — so
|
|
82
|
+
# relying on the default let "lint passes" change meaning on an upgrade
|
|
83
|
+
# with no code change at all. Naming the linters means a ruff bump can
|
|
84
|
+
# only add rules inside families we opted into, and the `ruff` pin in
|
|
85
|
+
# the `dev` extra keeps even that from arriving unannounced.
|
|
86
|
+
select = [
|
|
87
|
+
"E4", # pycodestyle: import placement
|
|
88
|
+
"E7", # pycodestyle: statement and comparison errors
|
|
89
|
+
"E9", # pycodestyle: syntax and IO errors
|
|
90
|
+
"W", # pycodestyle warnings
|
|
91
|
+
"F", # pyflakes
|
|
92
|
+
"I", # isort
|
|
93
|
+
"UP", # pyupgrade
|
|
94
|
+
"B", # flake8-bugbear
|
|
95
|
+
"C4", # flake8-comprehensions
|
|
96
|
+
"PIE", # flake8-pie
|
|
97
|
+
"SIM", # flake8-simplify
|
|
98
|
+
"ASYNC", # flake8-async
|
|
99
|
+
"BLE", # flake8-blind-except
|
|
100
|
+
"RUF", # ruff-specific rules
|
|
101
|
+
]
|
|
102
|
+
# E501 is deliberately absent: `ruff format` owns line width, and the
|
|
103
|
+
# two disagree on long strings and comments.
|
|
104
|
+
#
|
|
105
|
+
# PERF is absent for the same reason SIM118 is ignored below: it assumes
|
|
106
|
+
# a dict. Three of its four PERF102 hits sit inside the *definitions* of
|
|
107
|
+
# `Hamt.keys`, `Hamt.values` and `Keyset.values`, where "use the keys()
|
|
108
|
+
# method" is infinite recursion rather than a speedup; the fourth is a
|
|
109
|
+
# KVStore. PERF203/401/403 are style calls we don't want enforced.
|
|
110
|
+
|
|
111
|
+
ignore = [
|
|
112
|
+
# SIM118 reads any `x.keys()` as a dict and rewrites `k in x.keys()`
|
|
113
|
+
# to `k in x`. Every one of the ~37 hits here is `.keys()` on a
|
|
114
|
+
# KVStore or a Versioned — this project's own protocol, which
|
|
115
|
+
# deliberately does not implement `__iter__`: `iter(Memory())`,
|
|
116
|
+
# `iter(Disk(...))` and `iter(VersionedKV(...))` all raise
|
|
117
|
+
# TypeError. The suggested rewrite is a runtime error at every
|
|
118
|
+
# site, so the rule is switched off rather than papered over with
|
|
119
|
+
# three dozen individual noqas.
|
|
120
|
+
"SIM118",
|
|
121
|
+
# SIM105 wants `contextlib.suppress` instead of try/except/pass.
|
|
122
|
+
# Both sites are per-key paths in the store layer (Disk.remove,
|
|
123
|
+
# Namespaced.get_many), where building a context manager per key
|
|
124
|
+
# costs more than the bare handler it would replace.
|
|
125
|
+
"SIM105",
|
|
126
|
+
# RUF001/2/3 flag en dashes, em dashes and curly quotes as
|
|
127
|
+
# "ambiguous unicode". The prose in this repo uses them on purpose.
|
|
128
|
+
"RUF001",
|
|
129
|
+
"RUF002",
|
|
130
|
+
"RUF003",
|
|
131
|
+
]
|
|
132
|
+
|
|
133
|
+
[tool.ruff.lint.isort]
|
|
134
|
+
# Keep `from x import a, b as c` on one statement rather than exploding
|
|
135
|
+
# an aliased name into its own import block.
|
|
136
|
+
combine-as-imports = true
|
|
137
|
+
|
|
138
|
+
[tool.ruff.lint.per-file-ignores]
|
|
139
|
+
# Codec tests gate themselves with `pytest.importorskip(...)` before
|
|
140
|
+
# importing optional deps (numpy, pandas) and the matching codec modules,
|
|
141
|
+
# which forces module-level imports to follow the skip call. E402 is the
|
|
142
|
+
# wrong rule here — the pattern is the official pytest idiom.
|
|
143
|
+
"tests/codecs/*.py" = ["E402"]
|
|
144
|
+
# The Pyodide FS probes exist to prove that ordinary blocking Python
|
|
145
|
+
# file I/O — exactly what diskcache issues — works over an IDBFS/OPFS
|
|
146
|
+
# mount. ASYNC230 objects to `open()` inside an async def, but the
|
|
147
|
+
# blocking call *is* the thing under test.
|
|
148
|
+
"tests/kv/test_pyodide_fs.py" = ["ASYNC230"]
|
|
149
|
+
# Crash-injection stress harness: it kills workers mid-write and then
|
|
150
|
+
# pokes at whatever survived. Catching Exception at every probe is the
|
|
151
|
+
# design, not an oversight.
|
|
152
|
+
"tests/stress_kill.py" = ["BLE001"]
|
|
@@ -8,7 +8,6 @@ import pytest
|
|
|
8
8
|
from kvgit.hamt import EMPTY_HASH, Hamt, HamtDiff
|
|
9
9
|
from kvgit.kv.memory import Memory
|
|
10
10
|
|
|
11
|
-
|
|
12
11
|
# ---- helpers ----
|
|
13
12
|
|
|
14
13
|
|
|
@@ -315,7 +314,7 @@ def test_batch_update_filters_orphan_intermediates():
|
|
|
315
314
|
superseded intermediate leaves to pending."""
|
|
316
315
|
store = _store()
|
|
317
316
|
h = Hamt(store, bucket_max=8)
|
|
318
|
-
|
|
317
|
+
_new_h, pending = h.updated({f"k{i}": b"v" for i in range(5)})
|
|
319
318
|
|
|
320
319
|
# Only the final leaf should be in pending — no orphans from intermediate steps
|
|
321
320
|
assert len(pending) == 1
|
|
@@ -545,7 +544,7 @@ def test_large_random_with_deletes():
|
|
|
545
544
|
def test_chained_updated_calls_accumulate_pending():
|
|
546
545
|
store = _store()
|
|
547
546
|
h0 = Hamt(store, bucket_max=4)
|
|
548
|
-
h1,
|
|
547
|
+
h1, _pending1 = h0.updated({f"k{i}": b"v" for i in range(3)})
|
|
549
548
|
h2, pending2 = h1.updated({f"k{i}": b"w" for i in range(3, 6)})
|
|
550
549
|
|
|
551
550
|
# Nothing in store yet
|
|
@@ -587,7 +586,7 @@ def test_flush_clears_pending():
|
|
|
587
586
|
def test_pending_not_visible_to_separate_hamt_instance():
|
|
588
587
|
store = _store()
|
|
589
588
|
h0 = Hamt(store, bucket_max=4)
|
|
590
|
-
new_h,
|
|
589
|
+
new_h, _pending = h0.updated({"a": b"1"})
|
|
591
590
|
|
|
592
591
|
# A fresh Hamt opened on the same store at the new root cannot see
|
|
593
592
|
# the value, because the underlying nodes haven't been flushed.
|
|
@@ -864,7 +863,7 @@ def test_walk_skip_nodes_cumulative_across_shared_subtree():
|
|
|
864
863
|
|
|
865
864
|
# First walk seeds the seen-set.
|
|
866
865
|
seen: set[str] = set()
|
|
867
|
-
|
|
866
|
+
_items1, nodes1 = h1.walk(skip_nodes=seen)
|
|
868
867
|
seen |= nodes1
|
|
869
868
|
|
|
870
869
|
# Second walk: only the path from h2.root down to the changed
|
kvgit-0.3.2/pyproject.toml
DELETED
|
@@ -1,69 +0,0 @@
|
|
|
1
|
-
[build-system]
|
|
2
|
-
requires = ["setuptools>=68.0"]
|
|
3
|
-
build-backend = "setuptools.build_meta"
|
|
4
|
-
|
|
5
|
-
[project]
|
|
6
|
-
name = "kvgit"
|
|
7
|
-
version = "0.3.2"
|
|
8
|
-
description = "Versioned key-value store with git-like commit, branch, and merge semantics."
|
|
9
|
-
authors = [{ name = "ashenfad" }]
|
|
10
|
-
requires-python = ">=3.10"
|
|
11
|
-
readme = "README.md"
|
|
12
|
-
license = { text = "MIT" }
|
|
13
|
-
keywords = ["versioning", "git", "key-value", "branching", "merge"]
|
|
14
|
-
classifiers = [
|
|
15
|
-
"Development Status :: 4 - Beta",
|
|
16
|
-
"Intended Audience :: Developers",
|
|
17
|
-
"Topic :: Database",
|
|
18
|
-
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
19
|
-
"Programming Language :: Python :: 3",
|
|
20
|
-
"Programming Language :: Python :: 3.10",
|
|
21
|
-
"Programming Language :: Python :: 3.11",
|
|
22
|
-
"Programming Language :: Python :: 3.12",
|
|
23
|
-
"Programming Language :: Python :: 3.13",
|
|
24
|
-
"Programming Language :: Python :: 3.14",
|
|
25
|
-
"License :: OSI Approved :: MIT License",
|
|
26
|
-
"Operating System :: OS Independent",
|
|
27
|
-
"Typing :: Typed",
|
|
28
|
-
]
|
|
29
|
-
dependencies = []
|
|
30
|
-
|
|
31
|
-
[project.urls]
|
|
32
|
-
"Homepage" = "https://github.com/ashenfad/kvgit"
|
|
33
|
-
"Bug Tracker" = "https://github.com/ashenfad/kvgit/issues"
|
|
34
|
-
"Documentation" = "https://github.com/ashenfad/kvgit#readme"
|
|
35
|
-
"Source" = "https://github.com/ashenfad/kvgit"
|
|
36
|
-
|
|
37
|
-
[project.optional-dependencies]
|
|
38
|
-
disk = ["diskcache"]
|
|
39
|
-
numpy = ["numpy>=1.24"]
|
|
40
|
-
pandas = ["numpy>=1.24", "pandas>=2.0"]
|
|
41
|
-
scientific = ["numpy>=1.24", "pandas>=2.0"]
|
|
42
|
-
all = ["diskcache", "numpy>=1.24", "pandas>=2.0"]
|
|
43
|
-
dev = [
|
|
44
|
-
"pytest",
|
|
45
|
-
"diskcache",
|
|
46
|
-
"ruff",
|
|
47
|
-
"numpy>=1.24",
|
|
48
|
-
"pandas>=2.0",
|
|
49
|
-
]
|
|
50
|
-
|
|
51
|
-
[tool.setuptools.packages.find]
|
|
52
|
-
include = ["kvgit*"]
|
|
53
|
-
|
|
54
|
-
[tool.mypy]
|
|
55
|
-
warn_unused_configs = true
|
|
56
|
-
|
|
57
|
-
[[tool.mypy.overrides]]
|
|
58
|
-
module = ["diskcache"]
|
|
59
|
-
ignore_missing_imports = true
|
|
60
|
-
|
|
61
|
-
[tool.pytest.ini_options]
|
|
62
|
-
testpaths = ["tests"]
|
|
63
|
-
|
|
64
|
-
[tool.ruff.lint.per-file-ignores]
|
|
65
|
-
# Codec tests gate themselves with `pytest.importorskip(...)` before
|
|
66
|
-
# importing optional deps (numpy, pandas) and the matching codec modules,
|
|
67
|
-
# which forces module-level imports to follow the skip call. E402 is the
|
|
68
|
-
# wrong rule here — the pattern is the official pytest idiom.
|
|
69
|
-
"tests/codecs/*.py" = ["E402"]
|
|
File without changes
|
|
File without changes
|
|
@@ -3,10 +3,10 @@
|
|
|
3
3
|
from .content_types import MergeFn, counter, last_writer_wins
|
|
4
4
|
from .errors import ConcurrencyError, MergeConflict
|
|
5
5
|
from .namespaced import Namespaced
|
|
6
|
-
from .versioned.protocol import MergeResult, Versioned
|
|
7
6
|
from .staged import Staged
|
|
8
7
|
from .store import delete_branches, store
|
|
9
8
|
from .versioned.kv import VersionedKV
|
|
9
|
+
from .versioned.protocol import MergeResult, Versioned
|
|
10
10
|
|
|
11
11
|
__all__ = [
|
|
12
12
|
"ConcurrencyError",
|
|
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
|