kvgit 0.3.0__tar.gz → 0.3.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {kvgit-0.3.0 → kvgit-0.3.2}/PKG-INFO +3 -7
- {kvgit-0.3.0 → kvgit-0.3.2}/README.md +2 -2
- {kvgit-0.3.0 → kvgit-0.3.2}/kvgit/__init__.py +2 -8
- kvgit-0.3.2/kvgit/kv/composite.py +191 -0
- {kvgit-0.3.0 → kvgit-0.3.2}/kvgit/kv/disk.py +7 -0
- {kvgit-0.3.0 → kvgit-0.3.2}/kvgit/staged.py +1 -2
- kvgit-0.3.2/kvgit/store.py +152 -0
- {kvgit-0.3.0 → kvgit-0.3.2}/kvgit/versioned/__init__.py +0 -7
- {kvgit-0.3.0 → kvgit-0.3.2}/kvgit/versioned/kv.py +171 -138
- {kvgit-0.3.0 → kvgit-0.3.2}/kvgit/versioned/merge.py +2 -3
- {kvgit-0.3.0 → kvgit-0.3.2}/kvgit/versioned/protocol.py +1 -2
- {kvgit-0.3.0 → kvgit-0.3.2}/kvgit.egg-info/PKG-INFO +3 -7
- {kvgit-0.3.0 → kvgit-0.3.2}/kvgit.egg-info/SOURCES.txt +1 -1
- {kvgit-0.3.0 → kvgit-0.3.2}/kvgit.egg-info/requires.txt +0 -5
- {kvgit-0.3.0 → kvgit-0.3.2}/pyproject.toml +3 -5
- kvgit-0.3.2/tests/test_delete_branches.py +209 -0
- kvgit-0.3.0/kvgit/kv/composite.py +0 -137
- kvgit-0.3.0/kvgit/store.py +0 -87
- kvgit-0.3.0/kvgit/versioned/gp.py +0 -360
- {kvgit-0.3.0 → kvgit-0.3.2}/LICENSE +0 -0
- {kvgit-0.3.0 → kvgit-0.3.2}/kvgit/codecs/__init__.py +0 -0
- {kvgit-0.3.0 → kvgit-0.3.2}/kvgit/codecs/_hash.py +0 -0
- {kvgit-0.3.0 → kvgit-0.3.2}/kvgit/codecs/base.py +0 -0
- {kvgit-0.3.0 → kvgit-0.3.2}/kvgit/codecs/numpy.py +0 -0
- {kvgit-0.3.0 → kvgit-0.3.2}/kvgit/codecs/pandas.py +0 -0
- {kvgit-0.3.0 → kvgit-0.3.2}/kvgit/codecs/pickler.py +0 -0
- {kvgit-0.3.0 → kvgit-0.3.2}/kvgit/content_types.py +0 -0
- {kvgit-0.3.0 → kvgit-0.3.2}/kvgit/encoding.py +0 -0
- {kvgit-0.3.0 → kvgit-0.3.2}/kvgit/errors.py +0 -0
- {kvgit-0.3.0 → kvgit-0.3.2}/kvgit/hamt.py +0 -0
- {kvgit-0.3.0 → kvgit-0.3.2}/kvgit/kv/__init__.py +0 -0
- {kvgit-0.3.0 → kvgit-0.3.2}/kvgit/kv/base.py +0 -0
- {kvgit-0.3.0 → kvgit-0.3.2}/kvgit/kv/indexeddb.py +0 -0
- {kvgit-0.3.0 → kvgit-0.3.2}/kvgit/kv/memory.py +0 -0
- {kvgit-0.3.0 → kvgit-0.3.2}/kvgit/namespaced.py +0 -0
- {kvgit-0.3.0 → kvgit-0.3.2}/kvgit/py.typed +0 -0
- {kvgit-0.3.0 → kvgit-0.3.2}/kvgit/versioned/base.py +0 -0
- {kvgit-0.3.0 → kvgit-0.3.2}/kvgit/versioned/helpers.py +0 -0
- {kvgit-0.3.0 → kvgit-0.3.2}/kvgit/versioned/keyset.py +0 -0
- {kvgit-0.3.0 → kvgit-0.3.2}/kvgit.egg-info/dependency_links.txt +0 -0
- {kvgit-0.3.0 → kvgit-0.3.2}/kvgit.egg-info/top_level.txt +0 -0
- {kvgit-0.3.0 → kvgit-0.3.2}/setup.cfg +0 -0
- {kvgit-0.3.0 → kvgit-0.3.2}/tests/test_content_types.py +0 -0
- {kvgit-0.3.0 → kvgit-0.3.2}/tests/test_hamt.py +0 -0
- {kvgit-0.3.0 → kvgit-0.3.2}/tests/test_namespaced.py +0 -0
- {kvgit-0.3.0 → kvgit-0.3.2}/tests/test_staged.py +0 -0
- {kvgit-0.3.0 → kvgit-0.3.2}/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.2
|
|
4
4
|
Summary: Versioned key-value store with git-like commit, branch, and merge semantics.
|
|
5
5
|
Author: ashenfad
|
|
6
6
|
License: MIT
|
|
@@ -27,8 +27,6 @@ Description-Content-Type: text/markdown
|
|
|
27
27
|
License-File: LICENSE
|
|
28
28
|
Provides-Extra: disk
|
|
29
29
|
Requires-Dist: diskcache; extra == "disk"
|
|
30
|
-
Provides-Extra: git
|
|
31
|
-
Requires-Dist: gitpython>=3.1; extra == "git"
|
|
32
30
|
Provides-Extra: numpy
|
|
33
31
|
Requires-Dist: numpy>=1.24; extra == "numpy"
|
|
34
32
|
Provides-Extra: pandas
|
|
@@ -39,13 +37,11 @@ Requires-Dist: numpy>=1.24; extra == "scientific"
|
|
|
39
37
|
Requires-Dist: pandas>=2.0; extra == "scientific"
|
|
40
38
|
Provides-Extra: all
|
|
41
39
|
Requires-Dist: diskcache; extra == "all"
|
|
42
|
-
Requires-Dist: gitpython>=3.1; extra == "all"
|
|
43
40
|
Requires-Dist: numpy>=1.24; extra == "all"
|
|
44
41
|
Requires-Dist: pandas>=2.0; extra == "all"
|
|
45
42
|
Provides-Extra: dev
|
|
46
43
|
Requires-Dist: pytest; extra == "dev"
|
|
47
44
|
Requires-Dist: diskcache; extra == "dev"
|
|
48
|
-
Requires-Dist: gitpython>=3.1; extra == "dev"
|
|
49
45
|
Requires-Dist: ruff; extra == "dev"
|
|
50
46
|
Requires-Dist: numpy>=1.24; extra == "dev"
|
|
51
47
|
Requires-Dist: pandas>=2.0; extra == "dev"
|
|
@@ -61,7 +57,7 @@ Git-style versioning for your data. Commits, branches, and merges -- backed by a
|
|
|
61
57
|
| **Commits** | Immutable, content-addressable snapshots with rollback |
|
|
62
58
|
| **Branches** | Cheap forks with CAS-based optimistic concurrency |
|
|
63
59
|
| **Three-way merge** | Auto-merges non-overlapping changes; pluggable merge fns for conflicts |
|
|
64
|
-
| **Pluggable backends** | In-memory, disk (diskcache),
|
|
60
|
+
| **Pluggable backends** | In-memory, disk (diskcache), IndexedDB (Pyodide/browser), or bring your own `KVStore` |
|
|
65
61
|
| **Chunked codecs** | Optional content-addressed dedup for large numpy arrays and pandas DataFrames -- equal buffers stored once across keys, commits, and branches |
|
|
66
62
|
|
|
67
63
|
## Install
|
|
@@ -69,7 +65,6 @@ Git-style versioning for your data. Commits, branches, and merges -- backed by a
|
|
|
69
65
|
```bash
|
|
70
66
|
pip install kvgit # in-memory only
|
|
71
67
|
pip install kvgit[disk] # adds disk backend via diskcache
|
|
72
|
-
pip install kvgit[git] # adds git backend via GitPython (requires git on PATH)
|
|
73
68
|
pip install kvgit[scientific] # adds chunked codecs for numpy / pandas
|
|
74
69
|
# IndexedDB backend is available automatically in Pyodide (browser) environments
|
|
75
70
|
```
|
|
@@ -131,3 +126,4 @@ See [`docs/`](docs/) for detailed documentation:
|
|
|
131
126
|
|
|
132
127
|
- [Quick Start](docs/quick-start.md) -- common patterns with runnable examples
|
|
133
128
|
- [API Reference](docs/api.md) -- full reference for all classes, methods, and types
|
|
129
|
+
- [Browser persistence (Pyodide)](docs/pyodide.md) -- choosing between the IndexedDB and OPFS-mounted-disk backends, plus the syncfs flush requirement and recommended host-side patterns
|
|
@@ -8,7 +8,7 @@ Git-style versioning for your data. Commits, branches, and merges -- backed by a
|
|
|
8
8
|
| **Commits** | Immutable, content-addressable snapshots with rollback |
|
|
9
9
|
| **Branches** | Cheap forks with CAS-based optimistic concurrency |
|
|
10
10
|
| **Three-way merge** | Auto-merges non-overlapping changes; pluggable merge fns for conflicts |
|
|
11
|
-
| **Pluggable backends** | In-memory, disk (diskcache),
|
|
11
|
+
| **Pluggable backends** | In-memory, disk (diskcache), IndexedDB (Pyodide/browser), or bring your own `KVStore` |
|
|
12
12
|
| **Chunked codecs** | Optional content-addressed dedup for large numpy arrays and pandas DataFrames -- equal buffers stored once across keys, commits, and branches |
|
|
13
13
|
|
|
14
14
|
## Install
|
|
@@ -16,7 +16,6 @@ Git-style versioning for your data. Commits, branches, and merges -- backed by a
|
|
|
16
16
|
```bash
|
|
17
17
|
pip install kvgit # in-memory only
|
|
18
18
|
pip install kvgit[disk] # adds disk backend via diskcache
|
|
19
|
-
pip install kvgit[git] # adds git backend via GitPython (requires git on PATH)
|
|
20
19
|
pip install kvgit[scientific] # adds chunked codecs for numpy / pandas
|
|
21
20
|
# IndexedDB backend is available automatically in Pyodide (browser) environments
|
|
22
21
|
```
|
|
@@ -78,3 +77,4 @@ See [`docs/`](docs/) for detailed documentation:
|
|
|
78
77
|
|
|
79
78
|
- [Quick Start](docs/quick-start.md) -- common patterns with runnable examples
|
|
80
79
|
- [API Reference](docs/api.md) -- full reference for all classes, methods, and types
|
|
80
|
+
- [Browser persistence (Pyodide)](docs/pyodide.md) -- choosing between the IndexedDB and OPFS-mounted-disk backends, plus the syncfs flush requirement and recommended host-side patterns
|
|
@@ -5,7 +5,7 @@ from .errors import ConcurrencyError, MergeConflict
|
|
|
5
5
|
from .namespaced import Namespaced
|
|
6
6
|
from .versioned.protocol import MergeResult, Versioned
|
|
7
7
|
from .staged import Staged
|
|
8
|
-
from .store import store
|
|
8
|
+
from .store import delete_branches, store
|
|
9
9
|
from .versioned.kv import VersionedKV
|
|
10
10
|
|
|
11
11
|
__all__ = [
|
|
@@ -18,13 +18,7 @@ __all__ = [
|
|
|
18
18
|
"Versioned",
|
|
19
19
|
"VersionedKV",
|
|
20
20
|
"counter",
|
|
21
|
+
"delete_branches",
|
|
21
22
|
"last_writer_wins",
|
|
22
23
|
"store",
|
|
23
24
|
]
|
|
24
|
-
|
|
25
|
-
try:
|
|
26
|
-
from .versioned.gp import VersionedGP
|
|
27
|
-
|
|
28
|
-
__all__ += ["VersionedGP"]
|
|
29
|
-
except ImportError:
|
|
30
|
-
pass
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""N-tier composite cache over multiple KV stores."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from collections.abc import Iterable, Mapping
|
|
5
|
+
|
|
6
|
+
from .base import KVStore
|
|
7
|
+
|
|
8
|
+
logger = logging.getLogger("kvgit.kv.composite")
|
|
9
|
+
|
|
10
|
+
# Exceptions we treat as programming bugs (a misconfigured tier, a
|
|
11
|
+
# protocol mismatch) rather than operational tier unavailability.
|
|
12
|
+
# These are re-raised so they surface instead of being silently masked
|
|
13
|
+
# by the cache-fallback machinery. Everything else under Exception
|
|
14
|
+
# (OSError, ConnectionError, Pyodide JsException, ...) is logged and
|
|
15
|
+
# treated as "tier unavailable, try next".
|
|
16
|
+
_BUG_EXCEPTIONS = (TypeError, AttributeError, AssertionError)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _is_bug(exc: BaseException) -> bool:
|
|
20
|
+
return isinstance(exc, _BUG_EXCEPTIONS)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Composite(KVStore):
|
|
24
|
+
"""N-tier cache composing any number of KV stores.
|
|
25
|
+
|
|
26
|
+
On get: check L1, L2, ..., Ln in order. On hit at tier i,
|
|
27
|
+
populate L1..L(i-1) and return.
|
|
28
|
+
|
|
29
|
+
On set: write to all tiers (most durable first).
|
|
30
|
+
|
|
31
|
+
On cas: delegate to Ln (authoritative), update caches on success.
|
|
32
|
+
|
|
33
|
+
Tier failures (``OSError``, network errors, etc.) are logged at
|
|
34
|
+
WARNING and the next tier is tried; programming-error exceptions
|
|
35
|
+
(``TypeError``, ``AttributeError``, ``AssertionError``) propagate
|
|
36
|
+
so they aren't silently swallowed.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
stores: List of KV stores ordered fastest -> most durable.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
def __init__(self, stores: list[KVStore]) -> None:
|
|
43
|
+
if not stores:
|
|
44
|
+
raise ValueError("Composite requires at least one store")
|
|
45
|
+
self._stores = stores
|
|
46
|
+
|
|
47
|
+
def _populate_caches(self, upto: int, items: Mapping[str, bytes]) -> None:
|
|
48
|
+
"""Best-effort write to faster tiers after a slow-tier hit."""
|
|
49
|
+
for j in range(upto):
|
|
50
|
+
try:
|
|
51
|
+
self._stores[j].set_many(items)
|
|
52
|
+
except Exception as e:
|
|
53
|
+
if _is_bug(e):
|
|
54
|
+
raise
|
|
55
|
+
logger.warning("Composite cache populate failed at tier %d: %s", j, e)
|
|
56
|
+
|
|
57
|
+
def get(self, key: str) -> bytes | None:
|
|
58
|
+
for i, store in enumerate(self._stores):
|
|
59
|
+
try:
|
|
60
|
+
value = store.get(key)
|
|
61
|
+
except Exception as e:
|
|
62
|
+
if _is_bug(e):
|
|
63
|
+
raise
|
|
64
|
+
logger.warning("Composite get failed at tier %d for %r: %s", i, key, e)
|
|
65
|
+
continue
|
|
66
|
+
if value is not None:
|
|
67
|
+
if i > 0:
|
|
68
|
+
self._populate_caches(i, {key: value})
|
|
69
|
+
return value
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
def get_many(self, *args) -> Mapping[str, bytes]:
|
|
73
|
+
result: dict[str, bytes] = {}
|
|
74
|
+
remaining = set(self._normalize_keys(args))
|
|
75
|
+
for i, store in enumerate(self._stores):
|
|
76
|
+
if not remaining:
|
|
77
|
+
break
|
|
78
|
+
try:
|
|
79
|
+
# Delegate to the tier's bulk get — backends with high
|
|
80
|
+
# per-call latency (Disk, IndexedDB) collapse N round-trips
|
|
81
|
+
# into one. The protocol guarantees only existing keys
|
|
82
|
+
# appear in the result.
|
|
83
|
+
tier_values = store.get_many(remaining)
|
|
84
|
+
except Exception as e:
|
|
85
|
+
if _is_bug(e):
|
|
86
|
+
raise
|
|
87
|
+
logger.warning("Composite get_many failed at tier %d: %s", i, e)
|
|
88
|
+
continue
|
|
89
|
+
if tier_values and i > 0:
|
|
90
|
+
self._populate_caches(i, tier_values)
|
|
91
|
+
result.update(tier_values)
|
|
92
|
+
remaining -= tier_values.keys()
|
|
93
|
+
return result
|
|
94
|
+
|
|
95
|
+
def __contains__(self, key: str) -> bool:
|
|
96
|
+
for i, store in enumerate(self._stores):
|
|
97
|
+
try:
|
|
98
|
+
if key in store:
|
|
99
|
+
return True
|
|
100
|
+
except Exception as e:
|
|
101
|
+
if _is_bug(e):
|
|
102
|
+
raise
|
|
103
|
+
logger.warning(
|
|
104
|
+
"Composite contains failed at tier %d for %r: %s", i, key, e
|
|
105
|
+
)
|
|
106
|
+
continue
|
|
107
|
+
return False
|
|
108
|
+
|
|
109
|
+
def keys(self) -> Iterable[str]:
|
|
110
|
+
return self._stores[-1].keys()
|
|
111
|
+
|
|
112
|
+
def items(self) -> Iterable[tuple[str, bytes]]:
|
|
113
|
+
return self._stores[-1].items()
|
|
114
|
+
|
|
115
|
+
def set(self, key: str, value: bytes) -> None:
|
|
116
|
+
# Authoritative tier first; failures here propagate (durability
|
|
117
|
+
# is the contract of set()). Cache-tier failures are logged.
|
|
118
|
+
self._stores[-1].set(key, value)
|
|
119
|
+
for i, store in enumerate(self._stores[:-1]):
|
|
120
|
+
try:
|
|
121
|
+
store.set(key, value)
|
|
122
|
+
except Exception as e:
|
|
123
|
+
if _is_bug(e):
|
|
124
|
+
raise
|
|
125
|
+
logger.warning("Composite set failed at tier %d for %r: %s", i, key, e)
|
|
126
|
+
|
|
127
|
+
def set_many(
|
|
128
|
+
self,
|
|
129
|
+
items: Mapping[str, bytes] | None = None,
|
|
130
|
+
/,
|
|
131
|
+
**kwargs: bytes,
|
|
132
|
+
) -> None:
|
|
133
|
+
items = self._normalize_items(items, kwargs)
|
|
134
|
+
self._stores[-1].set_many(items)
|
|
135
|
+
for i, store in enumerate(self._stores[:-1]):
|
|
136
|
+
try:
|
|
137
|
+
store.set_many(items)
|
|
138
|
+
except Exception as e:
|
|
139
|
+
if _is_bug(e):
|
|
140
|
+
raise
|
|
141
|
+
logger.warning("Composite set_many failed at tier %d: %s", i, e)
|
|
142
|
+
|
|
143
|
+
def remove(self, key: str) -> None:
|
|
144
|
+
self._stores[-1].remove(key)
|
|
145
|
+
for i, store in enumerate(self._stores[:-1]):
|
|
146
|
+
try:
|
|
147
|
+
store.remove(key)
|
|
148
|
+
except Exception as e:
|
|
149
|
+
if _is_bug(e):
|
|
150
|
+
raise
|
|
151
|
+
logger.warning(
|
|
152
|
+
"Composite remove failed at tier %d for %r: %s", i, key, e
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
def remove_many(self, *args) -> None:
|
|
156
|
+
keys = list(self._normalize_keys(args))
|
|
157
|
+
self._stores[-1].remove_many(keys)
|
|
158
|
+
for i, store in enumerate(self._stores[:-1]):
|
|
159
|
+
try:
|
|
160
|
+
store.remove_many(keys)
|
|
161
|
+
except Exception as e:
|
|
162
|
+
if _is_bug(e):
|
|
163
|
+
raise
|
|
164
|
+
logger.warning("Composite remove_many failed at tier %d: %s", i, e)
|
|
165
|
+
|
|
166
|
+
def clear(self) -> None:
|
|
167
|
+
self._stores[-1].clear()
|
|
168
|
+
for i, store in enumerate(self._stores[:-1]):
|
|
169
|
+
try:
|
|
170
|
+
store.clear()
|
|
171
|
+
except Exception as e:
|
|
172
|
+
if _is_bug(e):
|
|
173
|
+
raise
|
|
174
|
+
logger.warning("Composite clear failed at tier %d: %s", i, e)
|
|
175
|
+
|
|
176
|
+
def cas(self, key: str, value: bytes, expected: bytes | None) -> bool:
|
|
177
|
+
success = self._stores[-1].cas(key, value, expected)
|
|
178
|
+
if success:
|
|
179
|
+
for i, store in enumerate(self._stores[:-1]):
|
|
180
|
+
try:
|
|
181
|
+
store.set(key, value)
|
|
182
|
+
except Exception as e:
|
|
183
|
+
if _is_bug(e):
|
|
184
|
+
raise
|
|
185
|
+
logger.warning(
|
|
186
|
+
"Composite cas cache-update failed at tier %d for %r: %s",
|
|
187
|
+
i,
|
|
188
|
+
key,
|
|
189
|
+
e,
|
|
190
|
+
)
|
|
191
|
+
return success
|
|
@@ -87,3 +87,10 @@ class Disk(KVStore):
|
|
|
87
87
|
|
|
88
88
|
def clear(self) -> None:
|
|
89
89
|
self.store.clear()
|
|
90
|
+
|
|
91
|
+
def close(self) -> None:
|
|
92
|
+
# diskcache holds SQLite connections (one per thread) open until
|
|
93
|
+
# closed. Admin teardown (kvgit.delete_branches) constructs a
|
|
94
|
+
# backend just to sweep and must release the handle so the next
|
|
95
|
+
# opener on the same directory isn't blocked.
|
|
96
|
+
self.store.close()
|
|
@@ -126,8 +126,7 @@ class Staged(MutableMapping[str, Any]):
|
|
|
126
126
|
"chunked encoder/decoder requires a VersionedKV-backed Staged "
|
|
127
127
|
f"(got {type(versioned).__name__}). "
|
|
128
128
|
"Use kvgit.store(kind='memory'/'disk'/'indexeddb') or build a "
|
|
129
|
-
"VersionedKV directly.
|
|
130
|
-
"support chunked codecs."
|
|
129
|
+
"VersionedKV directly."
|
|
131
130
|
)
|
|
132
131
|
self._chunk_reader = (
|
|
133
132
|
_ChunkReader(versioned.store) if self._decoder_chunked else None
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""Store factory function."""
|
|
2
|
+
|
|
3
|
+
import pickle
|
|
4
|
+
from collections.abc import Iterable
|
|
5
|
+
from typing import Any, Callable, Literal
|
|
6
|
+
|
|
7
|
+
from .kv.base import KVStore
|
|
8
|
+
from .kv.memory import Memory
|
|
9
|
+
from .staged import Staged
|
|
10
|
+
from .versioned.kv import BRANCH_HEAD, BRANCH_HEAD_PREV, VersionedKV, clean_orphans
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _make_backend(
|
|
14
|
+
kind: Literal["memory", "disk", "indexeddb"],
|
|
15
|
+
*,
|
|
16
|
+
path: str | None,
|
|
17
|
+
db_name: str,
|
|
18
|
+
) -> KVStore:
|
|
19
|
+
"""Construct a raw ``KVStore`` backend by kind.
|
|
20
|
+
|
|
21
|
+
Shared by :func:`store` and :func:`delete_branches` so both open the
|
|
22
|
+
exact same backends from the same parameters.
|
|
23
|
+
"""
|
|
24
|
+
if kind == "memory":
|
|
25
|
+
return Memory()
|
|
26
|
+
elif kind == "disk":
|
|
27
|
+
if path is None:
|
|
28
|
+
raise ValueError("path is required when kind='disk'")
|
|
29
|
+
from .kv.disk import Disk
|
|
30
|
+
|
|
31
|
+
return Disk(path)
|
|
32
|
+
elif kind == "indexeddb":
|
|
33
|
+
from .kv.indexeddb import IndexedDB
|
|
34
|
+
|
|
35
|
+
return IndexedDB(db_name=db_name)
|
|
36
|
+
else:
|
|
37
|
+
raise ValueError(f"Unknown kind: {kind!r}")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def store(
|
|
41
|
+
kind: Literal["memory", "disk", "indexeddb"] = "memory",
|
|
42
|
+
*,
|
|
43
|
+
path: str | None = None,
|
|
44
|
+
db_name: str = "kvgit",
|
|
45
|
+
branch: str = "main",
|
|
46
|
+
encoder: Callable[..., bytes] = pickle.dumps,
|
|
47
|
+
decoder: Callable[..., Any] = pickle.loads,
|
|
48
|
+
codecs: str | None = None,
|
|
49
|
+
) -> Staged:
|
|
50
|
+
"""Create a Staged store with sensible defaults.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
kind: ``"memory"`` (default), ``"disk"``, or ``"indexeddb"``.
|
|
54
|
+
path: Required when ``kind="disk"``. Directory path for the
|
|
55
|
+
disk backend.
|
|
56
|
+
db_name: IndexedDB database name (default ``"kvgit"``).
|
|
57
|
+
Only used when ``kind="indexeddb"``.
|
|
58
|
+
branch: Branch name (default ``"main"``).
|
|
59
|
+
encoder: Value encoder (default ``pickle.dumps``).
|
|
60
|
+
decoder: Value decoder (default ``pickle.loads``).
|
|
61
|
+
codecs: Optional named codec preset. Currently supported:
|
|
62
|
+
``"scientific"`` — numpy/pandas chunked codecs (requires
|
|
63
|
+
numpy; install with ``pip install kvgit[scientific]``).
|
|
64
|
+
Mutually exclusive with explicit ``encoder`` / ``decoder``.
|
|
65
|
+
|
|
66
|
+
Returns:
|
|
67
|
+
A ``Staged`` store instance.
|
|
68
|
+
|
|
69
|
+
Raises:
|
|
70
|
+
ValueError: if ``codecs`` is given alongside non-default
|
|
71
|
+
``encoder`` / ``decoder``, or if ``codecs`` names an
|
|
72
|
+
unknown preset.
|
|
73
|
+
ImportError: if a codec preset's optional dependency is not
|
|
74
|
+
installed.
|
|
75
|
+
"""
|
|
76
|
+
if codecs is not None:
|
|
77
|
+
if encoder is not pickle.dumps or decoder is not pickle.loads:
|
|
78
|
+
raise ValueError(
|
|
79
|
+
"codecs= is mutually exclusive with explicit encoder/decoder; "
|
|
80
|
+
"pass one or the other"
|
|
81
|
+
)
|
|
82
|
+
from .codecs import _resolve_named
|
|
83
|
+
|
|
84
|
+
encoder, decoder = _resolve_named(codecs)
|
|
85
|
+
|
|
86
|
+
backend = _make_backend(kind, path=path, db_name=db_name)
|
|
87
|
+
|
|
88
|
+
return Staged(VersionedKV(backend, branch=branch), encoder=encoder, decoder=decoder)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def delete_branches(
|
|
92
|
+
names: str | Iterable[str],
|
|
93
|
+
*,
|
|
94
|
+
kind: Literal["memory", "disk", "indexeddb"] = "disk",
|
|
95
|
+
path: str | None = None,
|
|
96
|
+
db_name: str = "kvgit",
|
|
97
|
+
min_age: float = 3600,
|
|
98
|
+
) -> None:
|
|
99
|
+
"""Delete branches with no branch anchor, then sweep orphans.
|
|
100
|
+
|
|
101
|
+
:meth:`VersionedKV.delete_branch` refuses to delete the branch its
|
|
102
|
+
handle is anchored on — and there is no way to open a handle
|
|
103
|
+
*without* a branch. When the branch you want gone is the store's
|
|
104
|
+
only branch, there is nothing safe to anchor on. This admin entry
|
|
105
|
+
point sidesteps the whole problem: it opens the backend directly
|
|
106
|
+
(no ``VersionedKV``, so no current branch) and edits the branch
|
|
107
|
+
keys itself.
|
|
108
|
+
|
|
109
|
+
For each name, ``__branch_head__<name>`` and its
|
|
110
|
+
``__branch_head_prev__<name>`` recovery backup are removed — the
|
|
111
|
+
backup too, so a later same-named branch can't "recover" the
|
|
112
|
+
deleted state through ``_resolve_head``'s fallback. A name with no
|
|
113
|
+
head is simply skipped: this is teardown, and idempotency beats a
|
|
114
|
+
``ValueError`` here. Deleting every branch is legal — the store
|
|
115
|
+
just mints a fresh empty ``main`` on next open.
|
|
116
|
+
|
|
117
|
+
One :func:`clean_orphans` sweep runs after all removals, so commits
|
|
118
|
+
that only the deleted branches referenced are reclaimed. All heads
|
|
119
|
+
(and prev-heads) are gone before the sweep, so it can't mistake a
|
|
120
|
+
doomed branch for a live one. ``min_age`` defaults to
|
|
121
|
+
``clean_orphans``'s one-hour guard (matching ``delete_branch``), so
|
|
122
|
+
an in-flight commit from a concurrent writer on another branch is
|
|
123
|
+
not swept out from under it; an admin who knows the store is quiet
|
|
124
|
+
can pass ``0`` to reclaim young commits immediately.
|
|
125
|
+
|
|
126
|
+
Args:
|
|
127
|
+
names: Branch names to delete — one name or an iterable of
|
|
128
|
+
them. Missing names are no-ops. (A bare string is treated
|
|
129
|
+
as ONE name, not iterated per character.)
|
|
130
|
+
kind: ``"disk"`` (default), ``"memory"``, or ``"indexeddb"``.
|
|
131
|
+
path: Required when ``kind="disk"``. Store directory.
|
|
132
|
+
db_name: IndexedDB database name (``kind="indexeddb"`` only).
|
|
133
|
+
min_age: Passed to :func:`clean_orphans` — commits younger than
|
|
134
|
+
this many seconds survive the sweep.
|
|
135
|
+
"""
|
|
136
|
+
# A bare string is a plausible one-branch call, and iterating it
|
|
137
|
+
# would "delete" each character as a branch name — a silent no-op
|
|
138
|
+
# at best. Treat it as a single name, like nontainer's
|
|
139
|
+
# delete_workspace does one layer up.
|
|
140
|
+
doomed = {names} if isinstance(names, str) else set(names)
|
|
141
|
+
if not doomed:
|
|
142
|
+
return # nothing asked: skip the backend open and the sweep
|
|
143
|
+
backend = _make_backend(kind, path=path, db_name=db_name)
|
|
144
|
+
try:
|
|
145
|
+
for name in doomed:
|
|
146
|
+
backend.remove(BRANCH_HEAD % name)
|
|
147
|
+
backend.remove(BRANCH_HEAD_PREV % name)
|
|
148
|
+
clean_orphans(backend, min_age=min_age)
|
|
149
|
+
finally:
|
|
150
|
+
close = getattr(backend, "close", None)
|
|
151
|
+
if callable(close):
|
|
152
|
+
close()
|