kvgit 0.3.6__tar.gz → 0.3.7__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 (46) hide show
  1. {kvgit-0.3.6 → kvgit-0.3.7}/PKG-INFO +33 -1
  2. {kvgit-0.3.6 → kvgit-0.3.7}/README.md +32 -0
  3. {kvgit-0.3.6 → kvgit-0.3.7}/kvgit/staged.py +56 -1
  4. {kvgit-0.3.6 → kvgit-0.3.7}/kvgit/versioned/base.py +69 -5
  5. {kvgit-0.3.6 → kvgit-0.3.7}/kvgit/versioned/kv.py +15 -10
  6. {kvgit-0.3.6 → kvgit-0.3.7}/kvgit/versioned/protocol.py +25 -0
  7. {kvgit-0.3.6 → kvgit-0.3.7}/kvgit.egg-info/PKG-INFO +33 -1
  8. {kvgit-0.3.6 → kvgit-0.3.7}/pyproject.toml +1 -1
  9. {kvgit-0.3.6 → kvgit-0.3.7}/LICENSE +0 -0
  10. {kvgit-0.3.6 → kvgit-0.3.7}/kvgit/__init__.py +0 -0
  11. {kvgit-0.3.6 → kvgit-0.3.7}/kvgit/codecs/__init__.py +0 -0
  12. {kvgit-0.3.6 → kvgit-0.3.7}/kvgit/codecs/_hash.py +0 -0
  13. {kvgit-0.3.6 → kvgit-0.3.7}/kvgit/codecs/base.py +0 -0
  14. {kvgit-0.3.6 → kvgit-0.3.7}/kvgit/codecs/numpy.py +0 -0
  15. {kvgit-0.3.6 → kvgit-0.3.7}/kvgit/codecs/pandas.py +0 -0
  16. {kvgit-0.3.6 → kvgit-0.3.7}/kvgit/codecs/pickler.py +0 -0
  17. {kvgit-0.3.6 → kvgit-0.3.7}/kvgit/content_types.py +0 -0
  18. {kvgit-0.3.6 → kvgit-0.3.7}/kvgit/encoding.py +0 -0
  19. {kvgit-0.3.6 → kvgit-0.3.7}/kvgit/errors.py +0 -0
  20. {kvgit-0.3.6 → kvgit-0.3.7}/kvgit/hamt.py +0 -0
  21. {kvgit-0.3.6 → kvgit-0.3.7}/kvgit/kv/__init__.py +0 -0
  22. {kvgit-0.3.6 → kvgit-0.3.7}/kvgit/kv/base.py +0 -0
  23. {kvgit-0.3.6 → kvgit-0.3.7}/kvgit/kv/composite.py +0 -0
  24. {kvgit-0.3.6 → kvgit-0.3.7}/kvgit/kv/disk.py +0 -0
  25. {kvgit-0.3.6 → kvgit-0.3.7}/kvgit/kv/indexeddb.py +0 -0
  26. {kvgit-0.3.6 → kvgit-0.3.7}/kvgit/kv/memory.py +0 -0
  27. {kvgit-0.3.6 → kvgit-0.3.7}/kvgit/merges.py +0 -0
  28. {kvgit-0.3.6 → kvgit-0.3.7}/kvgit/namespaced.py +0 -0
  29. {kvgit-0.3.6 → kvgit-0.3.7}/kvgit/py.typed +0 -0
  30. {kvgit-0.3.6 → kvgit-0.3.7}/kvgit/store.py +0 -0
  31. {kvgit-0.3.6 → kvgit-0.3.7}/kvgit/versioned/__init__.py +0 -0
  32. {kvgit-0.3.6 → kvgit-0.3.7}/kvgit/versioned/helpers.py +0 -0
  33. {kvgit-0.3.6 → kvgit-0.3.7}/kvgit/versioned/keyset.py +0 -0
  34. {kvgit-0.3.6 → kvgit-0.3.7}/kvgit/versioned/merge.py +0 -0
  35. {kvgit-0.3.6 → kvgit-0.3.7}/kvgit.egg-info/SOURCES.txt +0 -0
  36. {kvgit-0.3.6 → kvgit-0.3.7}/kvgit.egg-info/dependency_links.txt +0 -0
  37. {kvgit-0.3.6 → kvgit-0.3.7}/kvgit.egg-info/requires.txt +0 -0
  38. {kvgit-0.3.6 → kvgit-0.3.7}/kvgit.egg-info/top_level.txt +0 -0
  39. {kvgit-0.3.6 → kvgit-0.3.7}/setup.cfg +0 -0
  40. {kvgit-0.3.6 → kvgit-0.3.7}/tests/test_content_types.py +0 -0
  41. {kvgit-0.3.6 → kvgit-0.3.7}/tests/test_delete_branches.py +0 -0
  42. {kvgit-0.3.6 → kvgit-0.3.7}/tests/test_hamt.py +0 -0
  43. {kvgit-0.3.6 → kvgit-0.3.7}/tests/test_merges.py +0 -0
  44. {kvgit-0.3.6 → kvgit-0.3.7}/tests/test_namespaced.py +0 -0
  45. {kvgit-0.3.6 → kvgit-0.3.7}/tests/test_staged.py +0 -0
  46. {kvgit-0.3.6 → kvgit-0.3.7}/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.6
3
+ Version: 0.3.7
4
4
  Summary: Versioned key-value store with git-like commit, branch, and merge semantics.
5
5
  Author: ashenfad
6
6
  License: MIT
@@ -94,6 +94,38 @@ main.tag("v1")
94
94
  print(main.peek("score", tag="v1")) # 0
95
95
  ```
96
96
 
97
+ ## Merging
98
+
99
+ `Staged.merge()` merges another head -- usually another branch's HEAD --
100
+ into the current branch: lowest common ancestor, three-way resolve, and
101
+ a two-parent merge commit guarded on your own head:
102
+
103
+ ```python
104
+ dev["score"] = 500
105
+ dev.commit()
106
+
107
+ result = main.merge(dev.current_commit) # True when merged
108
+ print(main["score"]) # 500 (fast-forward: main hadn't diverged)
109
+ ```
110
+
111
+ Overlapping changes need a merge function per key, or a `default_merge`
112
+ fallback. `kvgit.merges.text` resolves line-oriented text with git-style
113
+ `<<<<<<<` markers (see `make_text_merge` for custom labels); anything it
114
+ cannot mark -- binary, non-UTF-8, oversized -- raises `CantMark`, filed
115
+ as an ordinary conflict:
116
+
117
+ ```python
118
+ from kvgit.merges import text
119
+
120
+ result = main.merge(dev.current_commit, default_merge=text)
121
+ ```
122
+
123
+ A `post_check(key, merged_bytes)` predicate runs over every
124
+ merge-produced value; returning `False` files that key as conflicted.
125
+ `on_conflict="abandon"` leaves the branch untouched instead of raising.
126
+ Merging refuses with `ValueError` when the staging buffer holds
127
+ uncommitted changes -- commit or reset first.
128
+
97
129
  ## Chunked codecs (numpy / pandas)
98
130
 
99
131
  Large numpy arrays and pandas DataFrames -- and any sliced views of them -- can be stored once and shared across keys, commits, and branches:
@@ -45,6 +45,38 @@ main.tag("v1")
45
45
  print(main.peek("score", tag="v1")) # 0
46
46
  ```
47
47
 
48
+ ## Merging
49
+
50
+ `Staged.merge()` merges another head -- usually another branch's HEAD --
51
+ into the current branch: lowest common ancestor, three-way resolve, and
52
+ a two-parent merge commit guarded on your own head:
53
+
54
+ ```python
55
+ dev["score"] = 500
56
+ dev.commit()
57
+
58
+ result = main.merge(dev.current_commit) # True when merged
59
+ print(main["score"]) # 500 (fast-forward: main hadn't diverged)
60
+ ```
61
+
62
+ Overlapping changes need a merge function per key, or a `default_merge`
63
+ fallback. `kvgit.merges.text` resolves line-oriented text with git-style
64
+ `<<<<<<<` markers (see `make_text_merge` for custom labels); anything it
65
+ cannot mark -- binary, non-UTF-8, oversized -- raises `CantMark`, filed
66
+ as an ordinary conflict:
67
+
68
+ ```python
69
+ from kvgit.merges import text
70
+
71
+ result = main.merge(dev.current_commit, default_merge=text)
72
+ ```
73
+
74
+ A `post_check(key, merged_bytes)` predicate runs over every
75
+ merge-produced value; returning `False` files that key as conflicted.
76
+ `on_conflict="abandon"` leaves the branch untouched instead of raising.
77
+ Merging refuses with `ValueError` when the staging buffer holds
78
+ uncommitted changes -- commit or reset first.
79
+
48
80
  ## Chunked codecs (numpy / pandas)
49
81
 
50
82
  Large numpy arrays and pandas DataFrames -- and any sliced views of them -- can be stored once and shared across keys, commits, and branches:
@@ -8,7 +8,13 @@ from typing import Any
8
8
  from .codecs._hash import hash_bytes
9
9
  from .content_types import MergeFn
10
10
  from .versioned.kv import CHUNK_PREFIX, VersionedKV
11
- from .versioned.protocol import BytesMergeFn, MergeResult, TagInfo, Versioned
11
+ from .versioned.protocol import (
12
+ BytesMergeFn,
13
+ MergeResult,
14
+ PostCheck,
15
+ TagInfo,
16
+ Versioned,
17
+ )
12
18
 
13
19
 
14
20
  class _ChunkSink:
@@ -355,6 +361,55 @@ class Staged(MutableMapping[str, Any]):
355
361
  self._cache.clear()
356
362
  return result
357
363
 
364
+ def merge(
365
+ self,
366
+ their_head: str,
367
+ *,
368
+ on_conflict: str = "raise",
369
+ merge_fns: dict[str, MergeFn] | None = None,
370
+ default_merge: MergeFn | None = None,
371
+ post_check: PostCheck | None = None,
372
+ info: dict | None = None,
373
+ ) -> MergeResult:
374
+ """Merge another head (usually another branch's HEAD) into this branch.
375
+
376
+ Lowest common ancestor + three-way resolve + a two-parent merge
377
+ commit, CAS-guarded on our own head. Refuses with ``ValueError``
378
+ when the staging buffer holds uncommitted changes — commit or
379
+ reset first, so the merge reads committed heads on both sides.
380
+
381
+ ``merge_fns`` / ``default_merge`` take decoded values (wrapped
382
+ like :meth:`commit`); ``post_check`` takes merged bytes.
383
+ """
384
+ if self._updates or self._removals:
385
+ raise ValueError("cannot merge with staged changes; commit or reset first")
386
+ # Same assembly as commit(): registered fns first, per-call
387
+ # overrides on top — a configured Staged merges without repeats.
388
+ effective_fns = dict(self._merge_fns)
389
+ if merge_fns:
390
+ effective_fns.update(merge_fns)
391
+ effective_default = default_merge or self._default_merge
392
+ bytes_merge_fns = (
393
+ {key: self._wrap_merge_fn(fn) for key, fn in effective_fns.items()}
394
+ if effective_fns
395
+ else None
396
+ )
397
+ bytes_default = (
398
+ self._wrap_merge_fn(effective_default) if effective_default else None
399
+ )
400
+ result = self._versioned.merge_heads(
401
+ their_head,
402
+ on_conflict=on_conflict,
403
+ merge_fns=bytes_merge_fns,
404
+ default_merge=bytes_default,
405
+ post_check=post_check,
406
+ info=info,
407
+ )
408
+ if result.merged:
409
+ # HEAD moved: cached values may be stale after a merge.
410
+ self._cache.clear()
411
+ return result
412
+
358
413
  def reset(self) -> None:
359
414
  """Discard all staged changes."""
360
415
  self._updates.clear()
@@ -6,7 +6,7 @@ from collections.abc import Iterable
6
6
  from ..errors import ConcurrencyError, MergeConflict
7
7
  from .helpers import diff_keysets, walk_history
8
8
  from .merge import MergeResolution, resolve_merge
9
- from .protocol import BytesMergeFn, DiffResult, MergeResult
9
+ from .protocol import BytesMergeFn, DiffResult, MergeResult, PostCheck
10
10
 
11
11
 
12
12
  class VersionedBase(ABC):
@@ -223,10 +223,20 @@ class VersionedBase(ABC):
223
223
  on_conflict: str,
224
224
  merge_fns: dict[str, BytesMergeFn] | None,
225
225
  default_merge: BytesMergeFn | None,
226
+ post_check: PostCheck | None = None,
226
227
  info: dict | None,
227
228
  saved_state: tuple | None = None,
229
+ cas_from: str | None = None,
230
+ parents: tuple[str, str] | None = None,
228
231
  ) -> MergeResult:
229
- """Perform a three-way merge between our branch and their HEAD."""
232
+ """Perform a three-way merge between our branch and their HEAD.
233
+
234
+ ``their_head`` is any commit in the store — the moved HEAD of our
235
+ own branch (the concurrent-write path) or another branch's HEAD
236
+ (cross-branch merge). ``cas_from`` names the commit the merge
237
+ commits on top of (defaults to ``their_head``, the concurrent
238
+ case); cross-branch callers pass their own head.
239
+ """
230
240
  lca = self._find_lca(self._current_commit, their_head)
231
241
  if lca is None:
232
242
  if saved_state is not None:
@@ -275,6 +285,14 @@ class VersionedBase(ABC):
275
285
  merge_fns=effective_fns,
276
286
  default_merge=effective_default,
277
287
  )
288
+ if post_check is not None:
289
+ refused = {
290
+ key
291
+ for key, value in resolution.merged_values.items()
292
+ if not post_check(key, value)
293
+ }
294
+ if refused:
295
+ raise MergeConflict(refused)
278
296
  except MergeConflict:
279
297
  if saved_state is not None:
280
298
  self._restore_state(saved_state)
@@ -291,14 +309,19 @@ class VersionedBase(ABC):
291
309
  raise
292
310
 
293
311
  auto_merged = resolution.auto_merged_keys
294
- parents = (their_head, self._current_commit)
312
+ if parents is None:
313
+ # Concurrent-write default: keep following the moved HEAD, as
314
+ # before. Cross-branch callers pass (our_head, their_head) so
315
+ # linear history stays on the merging branch (git convention).
316
+ parents = (their_head, self._current_commit)
295
317
 
296
318
  self._create_merge_commit(resolution, parents, info)
297
319
  merge_hash = self._current_commit
298
320
  merged_keyset = self._commit_keys
299
321
 
300
- # CAS HEAD from their_head to merge_hash
301
- if self._cas_head(their_head, merge_hash):
322
+ # CAS HEAD onto the merge commit: from their_head in the
323
+ # concurrent-write path, from our own head cross-branch.
324
+ if self._cas_head(cas_from or their_head, merge_hash):
302
325
  self._base_commit = merge_hash
303
326
  result = MergeResult(
304
327
  merged=True,
@@ -330,6 +353,47 @@ class VersionedBase(ABC):
330
353
  "HEAD changed during three-way merge. Refresh and retry."
331
354
  )
332
355
 
356
+ def merge_heads(
357
+ self,
358
+ their_head: str,
359
+ *,
360
+ on_conflict: str = "raise",
361
+ merge_fns: dict[str, BytesMergeFn] | None = None,
362
+ default_merge: BytesMergeFn | None = None,
363
+ post_check: PostCheck | None = None,
364
+ info: dict | None = None,
365
+ ) -> MergeResult:
366
+ """Merge another head into this branch.
367
+
368
+ ``their_head`` is any commit in the store — usually another
369
+ branch's HEAD. Finds the lowest common ancestor with our head,
370
+ three-way resolves, and creates a two-parent merge commit,
371
+ CAS-guarded on our own head (a race raises ``ConcurrencyError``
372
+ and changes nothing). No common ancestor raises
373
+ ``ConcurrencyError`` likewise without changing anything; retrying
374
+ cannot help that case, unlike a race.
375
+
376
+ ``post_check`` runs over each merge-function-produced value;
377
+ a False files that key as conflicted (handled per
378
+ ``on_conflict`` like any other conflict).
379
+ """
380
+ if on_conflict not in ("raise", "abandon"):
381
+ raise ValueError(
382
+ f"on_conflict must be 'raise' or 'abandon', got {on_conflict!r}"
383
+ )
384
+ our_head = self._current_commit
385
+ return self._three_way_merge(
386
+ their_head,
387
+ on_conflict=on_conflict,
388
+ merge_fns=merge_fns,
389
+ default_merge=default_merge,
390
+ post_check=post_check,
391
+ info=info,
392
+ saved_state=self._snapshot_state(),
393
+ cas_from=our_head,
394
+ parents=(our_head, their_head),
395
+ )
396
+
333
397
  # -- Abstract methods (implemented by subclasses) --
334
398
 
335
399
  @property
@@ -1182,14 +1182,19 @@ class VersionedKV(VersionedBase):
1182
1182
  diffs[vk] = value
1183
1183
 
1184
1184
  # Build merged meta from the parents' meta. ``self._meta`` is
1185
- # already our parent's meta (in memory). Their parent's meta
1186
- # we have to walk via the HAMT.
1187
- their_root = _load_root(self.store, parents[0])
1188
- their_meta: dict[str, MetaEntry] = {}
1189
- if their_root is not None:
1190
- their_ks = Keyset(self.store, root=their_root)
1191
- for key, entry in their_ks.items():
1192
- their_meta[key] = entry.meta
1185
+ # already our parent's meta (in memory). Any other parent's meta
1186
+ # we walk via the HAMT — position-independent, since merge
1187
+ # commits record ours-first: union every parent so their-side
1188
+ # keys resolve however the tuple is ordered. Ties keep ours
1189
+ # (checked first below), matching the old parents[0]-is-theirs
1190
+ # behavior exactly.
1191
+ parent_meta: dict[str, MetaEntry] = {}
1192
+ for parent in parents:
1193
+ parent_root = _load_root(self.store, parent)
1194
+ if parent_root is None:
1195
+ continue
1196
+ for key, entry in Keyset(self.store, root=parent_root).items():
1197
+ parent_meta.setdefault(key, entry.meta)
1193
1198
 
1194
1199
  merged_meta: dict[str, MetaEntry] = {}
1195
1200
  for key in merged_keyset:
@@ -1200,8 +1205,8 @@ class VersionedKV(VersionedBase):
1200
1205
  )
1201
1206
  elif key in self._meta:
1202
1207
  merged_meta[key] = self._meta[key]
1203
- elif key in their_meta:
1204
- merged_meta[key] = their_meta[key]
1208
+ elif key in parent_meta:
1209
+ merged_meta[key] = parent_meta[key]
1205
1210
 
1206
1211
  # Apply the merge result on top of our parent's HAMT. We compute
1207
1212
  # the minimal updates and removals so structural sharing kicks in
@@ -10,6 +10,15 @@ BytesMergeFn = Callable[[bytes | None, bytes | None, bytes | None], bytes]
10
10
  Any argument can be None (key absent or removed on that side).
11
11
  """
12
12
 
13
+ PostCheck = Callable[[str, bytes], bool]
14
+ """Post-merge predicate: (key, merged_bytes) -> accept?
15
+
16
+ Runs over values a merge function produced. Returning False files the
17
+ key as conflicted, as if no merge function had resolved it. kvgit never
18
+ inspects the bytes itself — callers that know what their values mean
19
+ (marker scans, schema checks) decide.
20
+ """
21
+
13
22
 
14
23
  @dataclass(frozen=True)
15
24
  class DiffResult:
@@ -110,6 +119,22 @@ class Versioned(Protocol):
110
119
  chunk_refs: dict[str, list[str]] | None = None,
111
120
  ) -> MergeResult: ...
112
121
 
122
+ def merge_heads(
123
+ self,
124
+ their_head: str,
125
+ *,
126
+ on_conflict: str = "raise",
127
+ merge_fns: dict[str, BytesMergeFn] | None = None,
128
+ default_merge: BytesMergeFn | None = None,
129
+ post_check: PostCheck | None = None,
130
+ info: dict | None = None,
131
+ ) -> MergeResult:
132
+ """Merge another head (any commit in the store, usually another
133
+ branch's HEAD) into this branch: LCA + three-way resolve + a
134
+ two-parent merge commit, CAS-guarded on our own head.
135
+ """
136
+ ...
137
+
113
138
  def refresh(self) -> None: ...
114
139
 
115
140
  def checkout(
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kvgit
3
- Version: 0.3.6
3
+ Version: 0.3.7
4
4
  Summary: Versioned key-value store with git-like commit, branch, and merge semantics.
5
5
  Author: ashenfad
6
6
  License: MIT
@@ -94,6 +94,38 @@ main.tag("v1")
94
94
  print(main.peek("score", tag="v1")) # 0
95
95
  ```
96
96
 
97
+ ## Merging
98
+
99
+ `Staged.merge()` merges another head -- usually another branch's HEAD --
100
+ into the current branch: lowest common ancestor, three-way resolve, and
101
+ a two-parent merge commit guarded on your own head:
102
+
103
+ ```python
104
+ dev["score"] = 500
105
+ dev.commit()
106
+
107
+ result = main.merge(dev.current_commit) # True when merged
108
+ print(main["score"]) # 500 (fast-forward: main hadn't diverged)
109
+ ```
110
+
111
+ Overlapping changes need a merge function per key, or a `default_merge`
112
+ fallback. `kvgit.merges.text` resolves line-oriented text with git-style
113
+ `<<<<<<<` markers (see `make_text_merge` for custom labels); anything it
114
+ cannot mark -- binary, non-UTF-8, oversized -- raises `CantMark`, filed
115
+ as an ordinary conflict:
116
+
117
+ ```python
118
+ from kvgit.merges import text
119
+
120
+ result = main.merge(dev.current_commit, default_merge=text)
121
+ ```
122
+
123
+ A `post_check(key, merged_bytes)` predicate runs over every
124
+ merge-produced value; returning `False` files that key as conflicted.
125
+ `on_conflict="abandon"` leaves the branch untouched instead of raising.
126
+ Merging refuses with `ValueError` when the staging buffer holds
127
+ uncommitted changes -- commit or reset first.
128
+
97
129
  ## Chunked codecs (numpy / pandas)
98
130
 
99
131
  Large numpy arrays and pandas DataFrames -- and any sliced views of them -- can be stored once and shared across keys, commits, and branches:
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "kvgit"
7
- version = "0.3.6"
7
+ version = "0.3.7"
8
8
  description = "Versioned key-value store with git-like commit, branch, and merge semantics."
9
9
  authors = [{ name = "ashenfad" }]
10
10
  requires-python = ">=3.10"
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
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