kvgit 0.2.0__tar.gz → 0.2.1__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.2.0 → kvgit-0.2.1}/PKG-INFO +1 -1
- {kvgit-0.2.0 → kvgit-0.2.1}/kvgit/staged.py +36 -11
- {kvgit-0.2.0 → kvgit-0.2.1}/kvgit.egg-info/PKG-INFO +1 -1
- {kvgit-0.2.0 → kvgit-0.2.1}/pyproject.toml +1 -1
- {kvgit-0.2.0 → kvgit-0.2.1}/tests/test_staged.py +111 -0
- {kvgit-0.2.0 → kvgit-0.2.1}/LICENSE +0 -0
- {kvgit-0.2.0 → kvgit-0.2.1}/README.md +0 -0
- {kvgit-0.2.0 → kvgit-0.2.1}/kvgit/__init__.py +0 -0
- {kvgit-0.2.0 → kvgit-0.2.1}/kvgit/content_types.py +0 -0
- {kvgit-0.2.0 → kvgit-0.2.1}/kvgit/encoding.py +0 -0
- {kvgit-0.2.0 → kvgit-0.2.1}/kvgit/errors.py +0 -0
- {kvgit-0.2.0 → kvgit-0.2.1}/kvgit/hamt.py +0 -0
- {kvgit-0.2.0 → kvgit-0.2.1}/kvgit/kv/__init__.py +0 -0
- {kvgit-0.2.0 → kvgit-0.2.1}/kvgit/kv/base.py +0 -0
- {kvgit-0.2.0 → kvgit-0.2.1}/kvgit/kv/composite.py +0 -0
- {kvgit-0.2.0 → kvgit-0.2.1}/kvgit/kv/disk.py +0 -0
- {kvgit-0.2.0 → kvgit-0.2.1}/kvgit/kv/indexeddb.py +0 -0
- {kvgit-0.2.0 → kvgit-0.2.1}/kvgit/kv/memory.py +0 -0
- {kvgit-0.2.0 → kvgit-0.2.1}/kvgit/namespaced.py +0 -0
- {kvgit-0.2.0 → kvgit-0.2.1}/kvgit/py.typed +0 -0
- {kvgit-0.2.0 → kvgit-0.2.1}/kvgit/store.py +0 -0
- {kvgit-0.2.0 → kvgit-0.2.1}/kvgit/versioned/__init__.py +0 -0
- {kvgit-0.2.0 → kvgit-0.2.1}/kvgit/versioned/base.py +0 -0
- {kvgit-0.2.0 → kvgit-0.2.1}/kvgit/versioned/gp.py +0 -0
- {kvgit-0.2.0 → kvgit-0.2.1}/kvgit/versioned/helpers.py +0 -0
- {kvgit-0.2.0 → kvgit-0.2.1}/kvgit/versioned/keyset.py +0 -0
- {kvgit-0.2.0 → kvgit-0.2.1}/kvgit/versioned/kv.py +0 -0
- {kvgit-0.2.0 → kvgit-0.2.1}/kvgit/versioned/merge.py +0 -0
- {kvgit-0.2.0 → kvgit-0.2.1}/kvgit/versioned/protocol.py +0 -0
- {kvgit-0.2.0 → kvgit-0.2.1}/kvgit.egg-info/SOURCES.txt +0 -0
- {kvgit-0.2.0 → kvgit-0.2.1}/kvgit.egg-info/dependency_links.txt +0 -0
- {kvgit-0.2.0 → kvgit-0.2.1}/kvgit.egg-info/requires.txt +0 -0
- {kvgit-0.2.0 → kvgit-0.2.1}/kvgit.egg-info/top_level.txt +0 -0
- {kvgit-0.2.0 → kvgit-0.2.1}/setup.cfg +0 -0
- {kvgit-0.2.0 → kvgit-0.2.1}/tests/test_content_types.py +0 -0
- {kvgit-0.2.0 → kvgit-0.2.1}/tests/test_hamt.py +0 -0
- {kvgit-0.2.0 → kvgit-0.2.1}/tests/test_namespaced.py +0 -0
- {kvgit-0.2.0 → kvgit-0.2.1}/tests/test_store_factory.py +0 -0
|
@@ -140,6 +140,7 @@ class Staged(MutableMapping[str, Any]):
|
|
|
140
140
|
def commit(
|
|
141
141
|
self,
|
|
142
142
|
*,
|
|
143
|
+
keys: set[str] | None = None,
|
|
143
144
|
on_conflict: str = "raise",
|
|
144
145
|
merge_fns: dict[str, MergeFn] | None = None,
|
|
145
146
|
default_merge: MergeFn | None = None,
|
|
@@ -148,20 +149,36 @@ class Staged(MutableMapping[str, Any]):
|
|
|
148
149
|
"""Flush staged changes to the underlying Versioned store.
|
|
149
150
|
|
|
150
151
|
Encodes staged values to bytes, wraps merge functions, and
|
|
151
|
-
calls ``Versioned.commit()``. On success, clears the
|
|
152
|
-
buffer.
|
|
152
|
+
calls ``Versioned.commit()``. On success, clears the committed
|
|
153
|
+
entries from the staging buffer.
|
|
154
|
+
|
|
155
|
+
Args:
|
|
156
|
+
keys: If provided, only commit these specific keys.
|
|
157
|
+
Keys not in ``_updates`` or ``_removals`` are silently
|
|
158
|
+
ignored. Uncommitted keys remain staged for a future
|
|
159
|
+
commit. When ``None`` (default), all staged changes
|
|
160
|
+
are committed.
|
|
153
161
|
|
|
154
162
|
Returns:
|
|
155
163
|
A MergeResult (truthy when committed).
|
|
156
164
|
"""
|
|
157
|
-
# Encode staged updates to bytes
|
|
165
|
+
# Encode staged updates to bytes — scoped to keys if provided
|
|
158
166
|
encoded_updates: dict[str, bytes] | None = None
|
|
159
|
-
if
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
167
|
+
if keys is not None:
|
|
168
|
+
# Iterate the (typically small) keys set, not the full _updates dict
|
|
169
|
+
matched_updates = {k: self._updates[k] for k in keys if k in self._updates}
|
|
170
|
+
if matched_updates:
|
|
171
|
+
encoded_updates = {
|
|
172
|
+
k: self._encoder(v) for k, v in matched_updates.items()
|
|
173
|
+
}
|
|
174
|
+
# .intersection() accepts any iterable, not just sets
|
|
175
|
+
removals = self._removals.intersection(keys) or None
|
|
176
|
+
else:
|
|
177
|
+
if self._updates:
|
|
178
|
+
encoded_updates = {
|
|
179
|
+
key: self._encoder(value) for key, value in self._updates.items()
|
|
180
|
+
}
|
|
181
|
+
removals = self._removals if self._removals else None
|
|
165
182
|
|
|
166
183
|
# Build effective merge fns and wrap to bytes-level
|
|
167
184
|
effective_fns = dict(self._merge_fns)
|
|
@@ -188,8 +205,16 @@ class Staged(MutableMapping[str, Any]):
|
|
|
188
205
|
info=info,
|
|
189
206
|
)
|
|
190
207
|
if result.merged:
|
|
191
|
-
|
|
192
|
-
|
|
208
|
+
if keys is not None:
|
|
209
|
+
# Only clear the committed keys from staging
|
|
210
|
+
for k in keys:
|
|
211
|
+
self._updates.pop(k, None)
|
|
212
|
+
self._removals.discard(k)
|
|
213
|
+
else:
|
|
214
|
+
self._updates.clear()
|
|
215
|
+
self._removals.clear()
|
|
216
|
+
# Always clear the full read cache — HEAD moved, so cached
|
|
217
|
+
# values from other keys may be stale after a merge.
|
|
193
218
|
self._cache.clear()
|
|
194
219
|
return result
|
|
195
220
|
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "kvgit"
|
|
7
|
-
version = "0.2.
|
|
7
|
+
version = "0.2.1"
|
|
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"
|
|
@@ -173,6 +173,117 @@ class TestStagedCommit:
|
|
|
173
173
|
assert s.versioned.commit_info() == {"author": "test"}
|
|
174
174
|
|
|
175
175
|
|
|
176
|
+
class TestStagedPartialCommit:
|
|
177
|
+
"""Tests for commit(keys=...) partial commit support."""
|
|
178
|
+
|
|
179
|
+
def test_partial_commit_only_flushes_specified_keys(self):
|
|
180
|
+
store = Memory()
|
|
181
|
+
v = Versioned(store)
|
|
182
|
+
s = Staged(v)
|
|
183
|
+
s["a"] = 1
|
|
184
|
+
s["b"] = 2
|
|
185
|
+
result = s.commit(keys={"a"})
|
|
186
|
+
assert result.merged
|
|
187
|
+
|
|
188
|
+
# "a" is committed and no longer staged
|
|
189
|
+
assert not s.is_staged("a")
|
|
190
|
+
# "b" is still staged
|
|
191
|
+
assert s.is_staged("b")
|
|
192
|
+
assert s["b"] == 2
|
|
193
|
+
|
|
194
|
+
def test_partial_commit_persists_committed_keys(self):
|
|
195
|
+
store = Memory()
|
|
196
|
+
v = Versioned(store)
|
|
197
|
+
s = Staged(v)
|
|
198
|
+
s["a"] = 1
|
|
199
|
+
s["b"] = 2
|
|
200
|
+
s.commit(keys={"a"})
|
|
201
|
+
|
|
202
|
+
# Verify "a" is persisted (visible from a fresh Staged)
|
|
203
|
+
s2 = Staged(Versioned(store))
|
|
204
|
+
assert s2.get("a") == 1
|
|
205
|
+
# "b" is NOT persisted (still only in s's staging buffer)
|
|
206
|
+
assert s2.get("b") is None
|
|
207
|
+
|
|
208
|
+
def test_partial_commit_with_info(self):
|
|
209
|
+
s = Staged(Versioned())
|
|
210
|
+
s["a"] = 1
|
|
211
|
+
s["b"] = 2
|
|
212
|
+
result = s.commit(keys={"a"}, info={"message": "just a"})
|
|
213
|
+
assert result.merged
|
|
214
|
+
assert s.versioned.commit_info()["message"] == "just a"
|
|
215
|
+
|
|
216
|
+
def test_partial_commit_ignores_unknown_keys(self):
|
|
217
|
+
s = Staged(Versioned())
|
|
218
|
+
s["a"] = 1
|
|
219
|
+
# "ghost" is not staged — should be silently ignored
|
|
220
|
+
result = s.commit(keys={"a", "ghost"})
|
|
221
|
+
assert result.merged
|
|
222
|
+
assert not s.is_staged("a")
|
|
223
|
+
|
|
224
|
+
def test_partial_commit_with_removals(self):
|
|
225
|
+
s = Staged(Versioned())
|
|
226
|
+
s["a"] = 1
|
|
227
|
+
s["b"] = 2
|
|
228
|
+
s.commit()
|
|
229
|
+
|
|
230
|
+
del s["a"]
|
|
231
|
+
s["c"] = 3
|
|
232
|
+
# Partial commit: only the removal of "a"
|
|
233
|
+
s.commit(keys={"a"})
|
|
234
|
+
assert s.get("a") is None # deleted
|
|
235
|
+
assert s.is_staged("c") # still pending
|
|
236
|
+
|
|
237
|
+
def test_partial_commit_clears_full_cache(self):
|
|
238
|
+
"""Regression test: after a partial commit, the entire read cache
|
|
239
|
+
must be cleared because HEAD moved. Cached values from non-committed
|
|
240
|
+
keys could be stale if a concurrent writer modified them.
|
|
241
|
+
"""
|
|
242
|
+
store = Memory()
|
|
243
|
+
v = Versioned(store)
|
|
244
|
+
s = Staged(v)
|
|
245
|
+
|
|
246
|
+
s["x"] = "original_x"
|
|
247
|
+
s["y"] = "original_y"
|
|
248
|
+
s.commit()
|
|
249
|
+
|
|
250
|
+
# Read "y" to populate the cache
|
|
251
|
+
assert s["y"] == "original_y"
|
|
252
|
+
|
|
253
|
+
# Simulate a concurrent writer updating "y" on the same branch
|
|
254
|
+
v2 = Versioned(store)
|
|
255
|
+
v2.commit({"y": s._encoder("updated_by_other")})
|
|
256
|
+
|
|
257
|
+
# Partial commit of "x" — HEAD moves to include the concurrent write
|
|
258
|
+
s["x"] = "new_x"
|
|
259
|
+
s.commit(keys={"x"})
|
|
260
|
+
|
|
261
|
+
# After partial commit, "y" should reflect the concurrent update,
|
|
262
|
+
# NOT the stale cached value. Before the fix, only "x" was evicted
|
|
263
|
+
# from cache, leaving "y" stale as "original_y".
|
|
264
|
+
s.refresh()
|
|
265
|
+
assert s["y"] == "updated_by_other"
|
|
266
|
+
|
|
267
|
+
def test_keys_accepts_non_set_iterable(self):
|
|
268
|
+
"""keys parameter should accept any iterable, not just sets."""
|
|
269
|
+
s = Staged(Versioned())
|
|
270
|
+
s["a"] = 1
|
|
271
|
+
s["b"] = 2
|
|
272
|
+
# Pass a list instead of a set
|
|
273
|
+
result = s.commit(keys=["a"])
|
|
274
|
+
assert result.merged
|
|
275
|
+
assert not s.is_staged("a")
|
|
276
|
+
assert s.is_staged("b")
|
|
277
|
+
|
|
278
|
+
def test_full_commit_unchanged_when_keys_is_none(self):
|
|
279
|
+
"""Without keys, commit flushes everything (backwards compatible)."""
|
|
280
|
+
s = Staged(Versioned())
|
|
281
|
+
s["a"] = 1
|
|
282
|
+
s["b"] = 2
|
|
283
|
+
s.commit()
|
|
284
|
+
assert not s.has_changes
|
|
285
|
+
|
|
286
|
+
|
|
176
287
|
class TestStagedReset:
|
|
177
288
|
def test_reset_clears_staging(self):
|
|
178
289
|
s = Staged(Versioned())
|
|
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
|