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.
Files changed (45) hide show
  1. {kvgit-0.3.2 → kvgit-0.3.3}/PKG-INFO +2 -2
  2. {kvgit-0.3.2 → kvgit-0.3.3}/kvgit/codecs/numpy.py +2 -2
  3. {kvgit-0.3.2 → kvgit-0.3.3}/kvgit/codecs/pickler.py +2 -1
  4. {kvgit-0.3.2 → kvgit-0.3.3}/kvgit/content_types.py +2 -1
  5. {kvgit-0.3.2 → kvgit-0.3.3}/kvgit/encoding.py +2 -1
  6. {kvgit-0.3.2 → kvgit-0.3.3}/kvgit/kv/indexeddb.py +4 -3
  7. {kvgit-0.3.2 → kvgit-0.3.3}/kvgit/namespaced.py +2 -2
  8. {kvgit-0.3.2 → kvgit-0.3.3}/kvgit/staged.py +2 -2
  9. {kvgit-0.3.2 → kvgit-0.3.3}/kvgit/store.py +7 -2
  10. {kvgit-0.3.2 → kvgit-0.3.3}/kvgit/versioned/base.py +1 -1
  11. {kvgit-0.3.2 → kvgit-0.3.3}/kvgit/versioned/helpers.py +1 -1
  12. {kvgit-0.3.2 → kvgit-0.3.3}/kvgit/versioned/kv.py +296 -52
  13. {kvgit-0.3.2 → kvgit-0.3.3}/kvgit/versioned/merge.py +3 -3
  14. {kvgit-0.3.2 → kvgit-0.3.3}/kvgit/versioned/protocol.py +2 -2
  15. {kvgit-0.3.2 → kvgit-0.3.3}/kvgit.egg-info/PKG-INFO +2 -2
  16. {kvgit-0.3.2 → kvgit-0.3.3}/kvgit.egg-info/requires.txt +1 -1
  17. kvgit-0.3.3/pyproject.toml +152 -0
  18. {kvgit-0.3.2 → kvgit-0.3.3}/tests/test_hamt.py +4 -5
  19. kvgit-0.3.2/pyproject.toml +0 -69
  20. {kvgit-0.3.2 → kvgit-0.3.3}/LICENSE +0 -0
  21. {kvgit-0.3.2 → kvgit-0.3.3}/README.md +0 -0
  22. {kvgit-0.3.2 → kvgit-0.3.3}/kvgit/__init__.py +1 -1
  23. {kvgit-0.3.2 → kvgit-0.3.3}/kvgit/codecs/__init__.py +0 -0
  24. {kvgit-0.3.2 → kvgit-0.3.3}/kvgit/codecs/_hash.py +0 -0
  25. {kvgit-0.3.2 → kvgit-0.3.3}/kvgit/codecs/base.py +0 -0
  26. {kvgit-0.3.2 → kvgit-0.3.3}/kvgit/codecs/pandas.py +0 -0
  27. {kvgit-0.3.2 → kvgit-0.3.3}/kvgit/errors.py +0 -0
  28. {kvgit-0.3.2 → kvgit-0.3.3}/kvgit/hamt.py +0 -0
  29. {kvgit-0.3.2 → kvgit-0.3.3}/kvgit/kv/__init__.py +0 -0
  30. {kvgit-0.3.2 → kvgit-0.3.3}/kvgit/kv/base.py +0 -0
  31. {kvgit-0.3.2 → kvgit-0.3.3}/kvgit/kv/composite.py +0 -0
  32. {kvgit-0.3.2 → kvgit-0.3.3}/kvgit/kv/disk.py +0 -0
  33. {kvgit-0.3.2 → kvgit-0.3.3}/kvgit/kv/memory.py +0 -0
  34. {kvgit-0.3.2 → kvgit-0.3.3}/kvgit/py.typed +0 -0
  35. {kvgit-0.3.2 → kvgit-0.3.3}/kvgit/versioned/__init__.py +0 -0
  36. {kvgit-0.3.2 → kvgit-0.3.3}/kvgit/versioned/keyset.py +0 -0
  37. {kvgit-0.3.2 → kvgit-0.3.3}/kvgit.egg-info/SOURCES.txt +0 -0
  38. {kvgit-0.3.2 → kvgit-0.3.3}/kvgit.egg-info/dependency_links.txt +0 -0
  39. {kvgit-0.3.2 → kvgit-0.3.3}/kvgit.egg-info/top_level.txt +0 -0
  40. {kvgit-0.3.2 → kvgit-0.3.3}/setup.cfg +0 -0
  41. {kvgit-0.3.2 → kvgit-0.3.3}/tests/test_content_types.py +0 -0
  42. {kvgit-0.3.2 → kvgit-0.3.3}/tests/test_delete_branches.py +0 -0
  43. {kvgit-0.3.2 → kvgit-0.3.3}/tests/test_namespaced.py +0 -0
  44. {kvgit-0.3.2 → kvgit-0.3.3}/tests/test_staged.py +0 -0
  45. {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.2
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 Any, TYPE_CHECKING
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: "np.ndarray") -> tuple["np.ndarray", int]:
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
@@ -16,7 +16,8 @@ from __future__ import annotations
16
16
 
17
17
  import io
18
18
  import pickle
19
- from typing import Any, Iterable
19
+ from collections.abc import Iterable
20
+ from typing import Any
20
21
 
21
22
  from .base import ChunkReader, ChunkSink, Codec
22
23
 
@@ -1,6 +1,7 @@
1
1
  """Merge functions for typed values."""
2
2
 
3
- from typing import Any, Callable
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.
@@ -28,5 +28,6 @@ def safe_loads(raw: bytes):
28
28
  """
29
29
  try:
30
30
  return json.loads(raw)
31
- except Exception:
31
+ except Exception: # noqa: BLE001 — the point of the function: any
32
+ # decode failure (garbage, partial write, version skew) is a miss.
32
33
  return None
@@ -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
- # Corrupted or unexpected JS value — treat as missing
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, Iterable
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, Callable
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, Callable, Literal
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 typing import Iterable
4
+ from collections.abc import Iterable
5
5
 
6
6
  from ..errors import ConcurrencyError, MergeConflict
7
7
  from .helpers import diff_keysets, walk_history
@@ -1,7 +1,7 @@
1
1
  """Shared diff and history helpers."""
2
2
 
3
3
  from collections import deque
4
- from typing import Callable, Iterable
4
+ from collections.abc import Callable, Iterable
5
5
 
6
6
  from .protocol import DiffResult
7
7
 
@@ -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. ``clean_orphans``
24
- sweeps unreferenced chunks subject to the usual ``min_age`` guard.
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, *, repair: bool = True) -> str | None:
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
- When *repair* is True (default), a corrupt HEAD is automatically
146
- healed by writing the recovered commit hash back to the store.
147
- Pass ``repair=False`` for side-effect-free reads (e.g. properties).
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 before each CAS)
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, and HAMT nodes that are not reachable from
274
- any reachable commit.
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
- The ``min_age`` guard (default 1 hour) prevents recently created
283
- commits from being falsely swept during concurrent writes.
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 chunks referenced by young orphan commits — those
372
- # may belong to in-flight writers whose CAS has not landed yet.
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
- # Batched walk for the orphan's blob references.
385
- orphan_entries = Keyset(store, root=orphan_root).materialize()
386
- for entry in orphan_entries.values():
387
- if entry.blob not in reachable_blobs:
388
- all_removals.append(entry.blob)
389
- # Chunks in the orphan are only swept later via
390
- # the chunk-namespace scan below; here we only
391
- # need to ensure we've picked up the blob refs.
392
- except Exception:
393
- pass
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
- # Orphan HAMT nodes: any keyset node not reachable from a live commit
404
- keyset_prefix = Keyset.DEFAULT_PREFIX
405
- for key in store.keys():
406
- if not (isinstance(key, str) and key.startswith(keyset_prefix)):
407
- continue
408
- node_hash = key[len(keyset_prefix) :]
409
- if node_hash and node_hash not in reachable_nodes:
410
- all_removals.append(key)
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
- # Orphan chunks: any chunk not reachable from a live commit
413
- # (or a young orphan, see above) is fair game.
414
- for key in store.keys():
415
- if not (isinstance(key, str) and key.startswith(CHUNK_PREFIX)):
416
- continue
417
- chunk_hash = key[len(CHUNK_PREFIX) :]
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, repair=False)
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
- Saves the current HEAD as prev HEAD before advancing, so a
733
- corrupt write can be recovered from.
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
- prev_key = BRANCH_HEAD_PREV % self._branch
737
- self.store.set(prev_key, dumps(expected))
738
- return self.store.cas(branch_key, dumps(new_head), expected=dumps(expected))
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 %s" % self._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 Callable, Iterable, Protocol, runtime_checkable
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.2
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
@@ -7,7 +7,7 @@ pandas>=2.0
7
7
  [dev]
8
8
  pytest
9
9
  diskcache
10
- ruff
10
+ ruff<0.17,>=0.16
11
11
  numpy>=1.24
12
12
  pandas>=2.0
13
13
 
@@ -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
- new_h, pending = h.updated({f"k{i}": b"v" for i in range(5)})
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, pending1 = h0.updated({f"k{i}": b"v" for i in range(3)})
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, pending = h0.updated({"a": b"1"})
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
- items1, nodes1 = h1.walk(skip_nodes=seen)
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
@@ -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