tketool.storage 1.3.5__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 (30) hide show
  1. tketool_storage-1.3.5/PKG-INFO +62 -0
  2. tketool_storage-1.3.5/README.md +36 -0
  3. tketool_storage-1.3.5/pyproject.toml +33 -0
  4. tketool_storage-1.3.5/setup.cfg +4 -0
  5. tketool_storage-1.3.5/src/tketool/storage/__init__.py +72 -0
  6. tketool_storage-1.3.5/src/tketool/storage/abstractions/__init__.py +37 -0
  7. tketool_storage-1.3.5/src/tketool/storage/abstractions/backend.py +122 -0
  8. tketool_storage-1.3.5/src/tketool/storage/abstractions/codec.py +214 -0
  9. tketool_storage-1.3.5/src/tketool/storage/abstractions/errors.py +22 -0
  10. tketool_storage-1.3.5/src/tketool/storage/abstractions/types.py +76 -0
  11. tketool_storage-1.3.5/src/tketool/storage/adapters/__init__.py +6 -0
  12. tketool_storage-1.3.5/src/tketool/storage/adapters/memory.py +143 -0
  13. tketool_storage-1.3.5/src/tketool/storage/adapters/redis.py +249 -0
  14. tketool_storage-1.3.5/src/tketool/storage/adapters/sqlalchemy.py +211 -0
  15. tketool_storage-1.3.5/src/tketool/storage/adapters/sqlite.py +234 -0
  16. tketool_storage-1.3.5/src/tketool/storage/bootstrap/__init__.py +3 -0
  17. tketool_storage-1.3.5/src/tketool/storage/bootstrap/factory.py +42 -0
  18. tketool_storage-1.3.5/src/tketool/storage/collections/__init__.py +13 -0
  19. tketool_storage-1.3.5/src/tketool/storage/collections/linked_list.py +251 -0
  20. tketool_storage-1.3.5/src/tketool/storage/collections/mapping.py +165 -0
  21. tketool_storage-1.3.5/src/tketool/storage/collections/multimap.py +89 -0
  22. tketool_storage-1.3.5/src/tketool/storage/collections/sequence.py +111 -0
  23. tketool_storage-1.3.5/src/tketool/storage/structures/__init__.py +4 -0
  24. tketool_storage-1.3.5/src/tketool/storage/structures/graph.py +282 -0
  25. tketool_storage-1.3.5/src/tketool/storage/structures/tree.py +252 -0
  26. tketool_storage-1.3.5/src/tketool.storage.egg-info/PKG-INFO +62 -0
  27. tketool_storage-1.3.5/src/tketool.storage.egg-info/SOURCES.txt +28 -0
  28. tketool_storage-1.3.5/src/tketool.storage.egg-info/dependency_links.txt +1 -0
  29. tketool_storage-1.3.5/src/tketool.storage.egg-info/requires.txt +15 -0
  30. tketool_storage-1.3.5/src/tketool.storage.egg-info/top_level.txt +1 -0
@@ -0,0 +1,62 @@
1
+ Metadata-Version: 2.4
2
+ Name: tketool.storage
3
+ Version: 1.3.5
4
+ Summary: Extensible data structures over pluggable ordered storage backends
5
+ Author-email: Ke <jiangke1207@icloud.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://pypi.org/project/tketool.storage/
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Programming Language :: Python :: 3.10
10
+ Classifier: Programming Language :: Python :: 3.11
11
+ Classifier: Programming Language :: Python :: 3.12
12
+ Classifier: Operating System :: OS Independent
13
+ Requires-Python: >=3.10
14
+ Description-Content-Type: text/markdown
15
+ Provides-Extra: sql
16
+ Requires-Dist: SQLAlchemy<3,>=2.0; extra == "sql"
17
+ Provides-Extra: postgresql
18
+ Requires-Dist: SQLAlchemy<3,>=2.0; extra == "postgresql"
19
+ Requires-Dist: psycopg[binary]<4,>=3.2; extra == "postgresql"
20
+ Provides-Extra: redis
21
+ Requires-Dist: redis<9,>=6; extra == "redis"
22
+ Provides-Extra: test
23
+ Requires-Dist: pytest<9,>=8; extra == "test"
24
+ Requires-Dist: SQLAlchemy<3,>=2.0; extra == "test"
25
+ Requires-Dist: redis<9,>=6; extra == "test"
26
+
27
+ # tketool.storage
28
+
29
+ Extensible graphs, trees, maps, lists, and linked lists over replaceable
30
+ ordered storage backends.
31
+
32
+ ```bash
33
+ pip install tketool.storage
34
+ ```
35
+
36
+ ```python
37
+ from tketool.storage import Graph, MemoryBackend, PersistentMap, Tree
38
+
39
+ backend = MemoryBackend()
40
+ settings = PersistentMap(backend, "settings")
41
+ settings.put("language", "zh-CN")
42
+
43
+ graph = Graph(backend, "knowledge")
44
+ person = graph.add_node("alice", {"name": "Alice"}, type="person")
45
+ ```
46
+
47
+ Optional adapters:
48
+
49
+ ```bash
50
+ pip install "tketool.storage[sql]"
51
+ pip install "tketool.storage[postgresql]"
52
+ pip install "tketool.storage[redis]"
53
+ ```
54
+
55
+ The dependency-free adapters are `MemoryBackend` and `SQLiteBackend`.
56
+ `SQLAlchemyBackend` uses SQLAlchemy Core and database-specific drivers;
57
+ `RedisBackend` uses redis-py and an atomic Lua batch.
58
+
59
+ The old `tketool.ml`/`tketool.ml2` and domain-specific `StorageBase` APIs are
60
+ not retained. New backends only
61
+ implement point read, ordered scan, atomic batch, and transaction primitives;
62
+ new data structures compose those primitives without changing adapters.
@@ -0,0 +1,36 @@
1
+ # tketool.storage
2
+
3
+ Extensible graphs, trees, maps, lists, and linked lists over replaceable
4
+ ordered storage backends.
5
+
6
+ ```bash
7
+ pip install tketool.storage
8
+ ```
9
+
10
+ ```python
11
+ from tketool.storage import Graph, MemoryBackend, PersistentMap, Tree
12
+
13
+ backend = MemoryBackend()
14
+ settings = PersistentMap(backend, "settings")
15
+ settings.put("language", "zh-CN")
16
+
17
+ graph = Graph(backend, "knowledge")
18
+ person = graph.add_node("alice", {"name": "Alice"}, type="person")
19
+ ```
20
+
21
+ Optional adapters:
22
+
23
+ ```bash
24
+ pip install "tketool.storage[sql]"
25
+ pip install "tketool.storage[postgresql]"
26
+ pip install "tketool.storage[redis]"
27
+ ```
28
+
29
+ The dependency-free adapters are `MemoryBackend` and `SQLiteBackend`.
30
+ `SQLAlchemyBackend` uses SQLAlchemy Core and database-specific drivers;
31
+ `RedisBackend` uses redis-py and an atomic Lua batch.
32
+
33
+ The old `tketool.ml`/`tketool.ml2` and domain-specific `StorageBase` APIs are
34
+ not retained. New backends only
35
+ implement point read, ordered scan, atomic batch, and transaction primitives;
36
+ new data structures compose those primitives without changing adapters.
@@ -0,0 +1,33 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "tketool.storage"
7
+ version = "1.3.5"
8
+ description = "Extensible data structures over pluggable ordered storage backends"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "MIT"
12
+ authors = [{ name = "Ke", email = "jiangke1207@icloud.com" }]
13
+ classifiers = [
14
+ "Programming Language :: Python :: 3",
15
+ "Programming Language :: Python :: 3.10",
16
+ "Programming Language :: Python :: 3.11",
17
+ "Programming Language :: Python :: 3.12",
18
+ "Operating System :: OS Independent",
19
+ ]
20
+
21
+ [project.optional-dependencies]
22
+ sql = ["SQLAlchemy>=2.0,<3"]
23
+ postgresql = ["SQLAlchemy>=2.0,<3", "psycopg[binary]>=3.2,<4"]
24
+ redis = ["redis>=6,<9"]
25
+ test = ["pytest>=8,<9", "SQLAlchemy>=2.0,<3", "redis>=6,<9"]
26
+
27
+ [project.urls]
28
+ Homepage = "https://pypi.org/project/tketool.storage/"
29
+
30
+ [tool.setuptools.packages.find]
31
+ where = ["src"]
32
+ include = ["tketool*"]
33
+ namespaces = true
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,72 @@
1
+ """Extensible data structures over a small ordered-storage contract."""
2
+
3
+ from .abstractions import (
4
+ BackendCapabilities,
5
+ BytesCodec,
6
+ Codec,
7
+ CorruptDataError,
8
+ Delete,
9
+ DuplicateBatchKeyError,
10
+ JsonCodec,
11
+ KeyValue,
12
+ Put,
13
+ StorageBackend,
14
+ StorageClosedError,
15
+ StorageConflictError,
16
+ StorageError,
17
+ StorageTransaction,
18
+ StringCodec,
19
+ Subspace,
20
+ SupportLevel,
21
+ TupleKeyCodec,
22
+ WriteBatch,
23
+ )
24
+ from .adapters import MemoryBackend, RedisBackend, SQLAlchemyBackend, SQLiteBackend
25
+ from .bootstrap import create_backend
26
+ from .collections import (
27
+ LinkedEntry,
28
+ MultiMapEntry,
29
+ PersistentLinkedList,
30
+ PersistentList,
31
+ PersistentMap,
32
+ PersistentMultiMap,
33
+ )
34
+ from .structures import Graph, GraphEdge, GraphNode, Tree, TreeNode
35
+
36
+ __all__ = [
37
+ "BackendCapabilities",
38
+ "BytesCodec",
39
+ "Codec",
40
+ "CorruptDataError",
41
+ "Delete",
42
+ "DuplicateBatchKeyError",
43
+ "Graph",
44
+ "GraphEdge",
45
+ "GraphNode",
46
+ "JsonCodec",
47
+ "KeyValue",
48
+ "LinkedEntry",
49
+ "MemoryBackend",
50
+ "MultiMapEntry",
51
+ "PersistentLinkedList",
52
+ "PersistentList",
53
+ "PersistentMap",
54
+ "PersistentMultiMap",
55
+ "Put",
56
+ "RedisBackend",
57
+ "SQLAlchemyBackend",
58
+ "SQLiteBackend",
59
+ "StorageBackend",
60
+ "StorageClosedError",
61
+ "StorageConflictError",
62
+ "StorageError",
63
+ "StorageTransaction",
64
+ "StringCodec",
65
+ "Subspace",
66
+ "SupportLevel",
67
+ "Tree",
68
+ "TreeNode",
69
+ "TupleKeyCodec",
70
+ "WriteBatch",
71
+ "create_backend",
72
+ ]
@@ -0,0 +1,37 @@
1
+ from .backend import StorageBackend, StorageTransaction
2
+ from .codec import BytesCodec, Codec, JsonCodec, KeyPart, StringCodec, Subspace, TupleKeyCodec, prefix_end
3
+ from .errors import (
4
+ CorruptDataError,
5
+ DuplicateBatchKeyError,
6
+ StorageClosedError,
7
+ StorageConflictError,
8
+ StorageError,
9
+ UnsupportedCapabilityError,
10
+ )
11
+ from .types import BackendCapabilities, Delete, KeyValue, Put, SupportLevel, WriteBatch, WriteOperation
12
+
13
+ __all__ = [
14
+ "BackendCapabilities",
15
+ "BytesCodec",
16
+ "Codec",
17
+ "CorruptDataError",
18
+ "Delete",
19
+ "DuplicateBatchKeyError",
20
+ "JsonCodec",
21
+ "KeyPart",
22
+ "KeyValue",
23
+ "Put",
24
+ "StorageBackend",
25
+ "StorageClosedError",
26
+ "StorageConflictError",
27
+ "StorageError",
28
+ "StorageTransaction",
29
+ "StringCodec",
30
+ "Subspace",
31
+ "SupportLevel",
32
+ "TupleKeyCodec",
33
+ "UnsupportedCapabilityError",
34
+ "WriteBatch",
35
+ "WriteOperation",
36
+ "prefix_end",
37
+ ]
@@ -0,0 +1,122 @@
1
+ from __future__ import annotations
2
+
3
+ import abc
4
+ from contextlib import contextmanager
5
+ from typing import Iterable, Iterator
6
+
7
+ from .errors import StorageClosedError
8
+ from .types import BackendCapabilities, KeyValue, WriteBatch, WriteOperation
9
+
10
+
11
+ class StorageTransaction(abc.ABC):
12
+ """One explicit transaction over byte keys and values."""
13
+
14
+ @abc.abstractmethod
15
+ def get(self, key: bytes) -> KeyValue | None:
16
+ raise NotImplementedError
17
+
18
+ def get_many(self, keys: Iterable[bytes]) -> list[KeyValue | None]:
19
+ return [self.get(key) for key in keys]
20
+
21
+ @abc.abstractmethod
22
+ def scan(
23
+ self,
24
+ start: bytes,
25
+ end: bytes | None,
26
+ *,
27
+ reverse: bool = False,
28
+ limit: int | None = None,
29
+ ) -> list[KeyValue]:
30
+ """Return records in the half-open range ``[start, end)``."""
31
+
32
+ @abc.abstractmethod
33
+ def apply(self, batch: WriteBatch) -> None:
34
+ raise NotImplementedError
35
+
36
+ @abc.abstractmethod
37
+ def commit(self) -> None:
38
+ raise NotImplementedError
39
+
40
+ @abc.abstractmethod
41
+ def rollback(self) -> None:
42
+ raise NotImplementedError
43
+
44
+ def __enter__(self) -> "StorageTransaction":
45
+ return self
46
+
47
+ def __exit__(self, exc_type, exc, traceback) -> bool:
48
+ if exc_type is None:
49
+ self.commit()
50
+ else:
51
+ self.rollback()
52
+ return False
53
+
54
+
55
+ class StorageBackend(abc.ABC):
56
+ """Minimal replaceable storage boundary used by all storage collections."""
57
+
58
+ capabilities: BackendCapabilities
59
+
60
+ @abc.abstractmethod
61
+ def begin(self) -> StorageTransaction:
62
+ raise NotImplementedError
63
+
64
+ @abc.abstractmethod
65
+ def close(self) -> None:
66
+ raise NotImplementedError
67
+
68
+ @contextmanager
69
+ def transaction(self) -> Iterator[StorageTransaction]:
70
+ tx = self.begin()
71
+ try:
72
+ yield tx
73
+ except BaseException:
74
+ tx.rollback()
75
+ raise
76
+ else:
77
+ tx.commit()
78
+
79
+ def get(self, key: bytes) -> KeyValue | None:
80
+ with self.transaction() as tx:
81
+ return tx.get(key)
82
+
83
+ def get_many(self, keys: Iterable[bytes]) -> list[KeyValue | None]:
84
+ with self.transaction() as tx:
85
+ return tx.get_many(keys)
86
+
87
+ def scan(
88
+ self,
89
+ start: bytes,
90
+ end: bytes | None,
91
+ *,
92
+ reverse: bool = False,
93
+ limit: int | None = None,
94
+ ) -> list[KeyValue]:
95
+ with self.transaction() as tx:
96
+ return tx.scan(start, end, reverse=reverse, limit=limit)
97
+
98
+ def apply(self, operations: WriteBatch | Iterable[WriteOperation]) -> None:
99
+ batch = operations if isinstance(operations, WriteBatch) else WriteBatch(operations)
100
+ if not batch:
101
+ return
102
+ with self.transaction() as tx:
103
+ tx.apply(batch)
104
+
105
+
106
+ class TransactionState:
107
+ """Small helper shared by concrete transactions."""
108
+
109
+ def __init__(self) -> None:
110
+ self._finished = False
111
+
112
+ @property
113
+ def finished(self) -> bool:
114
+ return self._finished
115
+
116
+ def ensure_active(self) -> None:
117
+ if self._finished:
118
+ raise StorageClosedError("transaction is already finished")
119
+
120
+ def finish(self) -> None:
121
+ self.ensure_active()
122
+ self._finished = True
@@ -0,0 +1,214 @@
1
+ from __future__ import annotations
2
+
3
+ import abc
4
+ import json
5
+ import math
6
+ import struct
7
+ import uuid
8
+ from typing import Generic, TypeVar, cast
9
+
10
+ from .errors import CorruptDataError
11
+
12
+
13
+ T = TypeVar("T")
14
+ KeyPart = None | bool | int | float | str | bytes | uuid.UUID
15
+
16
+
17
+ class Codec(abc.ABC, Generic[T]):
18
+ @abc.abstractmethod
19
+ def encode(self, value: T) -> bytes:
20
+ raise NotImplementedError
21
+
22
+ @abc.abstractmethod
23
+ def decode(self, data: bytes) -> T:
24
+ raise NotImplementedError
25
+
26
+
27
+ class JsonCodec(Codec[T]):
28
+ def encode(self, value: T) -> bytes:
29
+ return json.dumps(
30
+ value,
31
+ ensure_ascii=False,
32
+ separators=(",", ":"),
33
+ sort_keys=True,
34
+ allow_nan=False,
35
+ ).encode("utf-8")
36
+
37
+ def decode(self, data: bytes) -> T:
38
+ try:
39
+ return cast(T, json.loads(data.decode("utf-8")))
40
+ except (UnicodeDecodeError, json.JSONDecodeError) as exc:
41
+ raise CorruptDataError("invalid JSON value") from exc
42
+
43
+
44
+ class BytesCodec(Codec[bytes]):
45
+ def encode(self, value: bytes) -> bytes:
46
+ return bytes(value)
47
+
48
+ def decode(self, data: bytes) -> bytes:
49
+ return bytes(data)
50
+
51
+
52
+ class StringCodec(Codec[str]):
53
+ def encode(self, value: str) -> bytes:
54
+ return value.encode("utf-8")
55
+
56
+ def decode(self, data: bytes) -> str:
57
+ try:
58
+ return data.decode("utf-8")
59
+ except UnicodeDecodeError as exc:
60
+ raise CorruptDataError("invalid UTF-8 value") from exc
61
+
62
+
63
+ _NONE = 0x10
64
+ _FALSE = 0x11
65
+ _TRUE = 0x12
66
+ _INT = 0x20
67
+ _FLOAT = 0x21
68
+ _BYTES = 0x30
69
+ _STRING = 0x31
70
+ _UUID = 0x40
71
+ _TERMINATOR = 0x00
72
+ _ESCAPED_ZERO = 0xFF
73
+
74
+
75
+ def _escape(data: bytes) -> bytes:
76
+ return data.replace(b"\x00", b"\x00\xff") + b"\x00"
77
+
78
+
79
+ def _unescape(data: bytes, offset: int) -> tuple[bytes, int]:
80
+ output = bytearray()
81
+ while offset < len(data):
82
+ value = data[offset]
83
+ offset += 1
84
+ if value != _TERMINATOR:
85
+ output.append(value)
86
+ continue
87
+ if offset < len(data) and data[offset] == _ESCAPED_ZERO:
88
+ output.append(0)
89
+ offset += 1
90
+ continue
91
+ return bytes(output), offset
92
+ raise CorruptDataError("unterminated tuple component")
93
+
94
+
95
+ class TupleKeyCodec:
96
+ """Deterministic order-preserving codec for flat heterogeneous tuples.
97
+
98
+ Ordering across different Python types follows the stable type tags above.
99
+ Integer values are limited to signed 64-bit values.
100
+ """
101
+
102
+ def pack(self, parts: tuple[KeyPart, ...]) -> bytes:
103
+ output = bytearray()
104
+ for part in parts:
105
+ if part is None:
106
+ output.append(_NONE)
107
+ elif part is False:
108
+ output.append(_FALSE)
109
+ elif part is True:
110
+ output.append(_TRUE)
111
+ elif isinstance(part, int):
112
+ if not -(1 << 63) <= part < (1 << 63):
113
+ raise OverflowError("tuple integers must fit signed 64 bits")
114
+ output.append(_INT)
115
+ output.extend(struct.pack(">Q", part + (1 << 63)))
116
+ elif isinstance(part, float):
117
+ if math.isnan(part):
118
+ raise ValueError("NaN cannot be used in an ordered key")
119
+ raw = bytearray(struct.pack(">d", part))
120
+ if raw[0] & 0x80:
121
+ raw = bytearray(value ^ 0xFF for value in raw)
122
+ else:
123
+ raw[0] ^= 0x80
124
+ output.append(_FLOAT)
125
+ output.extend(raw)
126
+ elif isinstance(part, bytes):
127
+ output.append(_BYTES)
128
+ output.extend(_escape(part))
129
+ elif isinstance(part, str):
130
+ output.append(_STRING)
131
+ output.extend(_escape(part.encode("utf-8")))
132
+ elif isinstance(part, uuid.UUID):
133
+ output.append(_UUID)
134
+ output.extend(part.bytes)
135
+ else:
136
+ raise TypeError(f"unsupported tuple key component: {type(part).__name__}")
137
+ return bytes(output)
138
+
139
+ def unpack(self, data: bytes) -> tuple[KeyPart, ...]:
140
+ result: list[KeyPart] = []
141
+ offset = 0
142
+ while offset < len(data):
143
+ tag = data[offset]
144
+ offset += 1
145
+ if tag == _NONE:
146
+ result.append(None)
147
+ elif tag == _FALSE:
148
+ result.append(False)
149
+ elif tag == _TRUE:
150
+ result.append(True)
151
+ elif tag == _INT:
152
+ if offset + 8 > len(data):
153
+ raise CorruptDataError("truncated integer key component")
154
+ result.append(struct.unpack(">Q", data[offset : offset + 8])[0] - (1 << 63))
155
+ offset += 8
156
+ elif tag == _FLOAT:
157
+ if offset + 8 > len(data):
158
+ raise CorruptDataError("truncated float key component")
159
+ raw = bytearray(data[offset : offset + 8])
160
+ if raw[0] & 0x80:
161
+ raw[0] ^= 0x80
162
+ else:
163
+ raw = bytearray(value ^ 0xFF for value in raw)
164
+ result.append(struct.unpack(">d", raw)[0])
165
+ offset += 8
166
+ elif tag in (_BYTES, _STRING):
167
+ value, offset = _unescape(data, offset)
168
+ if tag == _STRING:
169
+ try:
170
+ result.append(value.decode("utf-8"))
171
+ except UnicodeDecodeError as exc:
172
+ raise CorruptDataError("invalid UTF-8 key component") from exc
173
+ else:
174
+ result.append(value)
175
+ elif tag == _UUID:
176
+ if offset + 16 > len(data):
177
+ raise CorruptDataError("truncated UUID key component")
178
+ result.append(uuid.UUID(bytes=data[offset : offset + 16]))
179
+ offset += 16
180
+ else:
181
+ raise CorruptDataError(f"unknown tuple key tag: {tag}")
182
+ return tuple(result)
183
+
184
+
185
+ def prefix_end(prefix: bytes) -> bytes | None:
186
+ """Return the exclusive upper bound of all byte strings with ``prefix``."""
187
+
188
+ if not prefix:
189
+ return None
190
+ value = bytearray(prefix)
191
+ for index in range(len(value) - 1, -1, -1):
192
+ if value[index] != 0xFF:
193
+ value[index] += 1
194
+ return bytes(value[: index + 1])
195
+ return None
196
+
197
+
198
+ class Subspace:
199
+ def __init__(self, prefix: tuple[KeyPart, ...], codec: TupleKeyCodec | None = None):
200
+ self.codec = codec or TupleKeyCodec()
201
+ self.prefix_parts = prefix
202
+ self.raw_prefix = self.codec.pack(prefix)
203
+
204
+ def key(self, *parts: KeyPart) -> bytes:
205
+ return self.raw_prefix + self.codec.pack(tuple(parts))
206
+
207
+ def range(self, *parts: KeyPart) -> tuple[bytes, bytes | None]:
208
+ start = self.key(*parts)
209
+ return start, prefix_end(start)
210
+
211
+ def unpack(self, key: bytes) -> tuple[KeyPart, ...]:
212
+ if not key.startswith(self.raw_prefix):
213
+ raise ValueError("key does not belong to this subspace")
214
+ return self.codec.unpack(key[len(self.raw_prefix) :])
@@ -0,0 +1,22 @@
1
+ class StorageError(Exception):
2
+ """Base error for the portable storage contract."""
3
+
4
+
5
+ class StorageClosedError(StorageError):
6
+ """The backend or transaction has already been closed."""
7
+
8
+
9
+ class StorageConflictError(StorageError):
10
+ """An optimistic write condition did not match."""
11
+
12
+
13
+ class DuplicateBatchKeyError(StorageError, ValueError):
14
+ """A write batch contains more than one operation for the same key."""
15
+
16
+
17
+ class UnsupportedCapabilityError(StorageError):
18
+ """The selected backend cannot provide a required capability."""
19
+
20
+
21
+ class CorruptDataError(StorageError):
22
+ """Stored bytes do not satisfy the declared codec or record format."""
@@ -0,0 +1,76 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from enum import Enum
5
+ from typing import Iterable, TypeAlias
6
+
7
+ from .errors import DuplicateBatchKeyError
8
+
9
+
10
+ class SupportLevel(str, Enum):
11
+ NATIVE = "native"
12
+ EMULATED = "emulated"
13
+ UNSUPPORTED = "unsupported"
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class BackendCapabilities:
18
+ point_read: SupportLevel = SupportLevel.NATIVE
19
+ ordered_scan: SupportLevel = SupportLevel.NATIVE
20
+ atomic_batch: SupportLevel = SupportLevel.NATIVE
21
+ transactions: SupportLevel = SupportLevel.NATIVE
22
+ conditional_write: SupportLevel = SupportLevel.NATIVE
23
+ durability: SupportLevel = SupportLevel.UNSUPPORTED
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class KeyValue:
28
+ key: bytes
29
+ value: bytes
30
+ version: int
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class Put:
35
+ key: bytes
36
+ value: bytes
37
+ expected_version: int | None = None
38
+ if_absent: bool = False
39
+
40
+ def __post_init__(self) -> None:
41
+ if self.expected_version is not None and self.if_absent:
42
+ raise ValueError("expected_version and if_absent are mutually exclusive")
43
+ if self.expected_version is not None and self.expected_version < 1:
44
+ raise ValueError("expected_version must be positive")
45
+
46
+
47
+ @dataclass(frozen=True)
48
+ class Delete:
49
+ key: bytes
50
+ expected_version: int | None = None
51
+
52
+ def __post_init__(self) -> None:
53
+ if self.expected_version is not None and self.expected_version < 1:
54
+ raise ValueError("expected_version must be positive")
55
+
56
+
57
+ WriteOperation: TypeAlias = Put | Delete
58
+
59
+
60
+ @dataclass(frozen=True)
61
+ class WriteBatch:
62
+ operations: tuple[WriteOperation, ...] = field(default_factory=tuple)
63
+
64
+ def __init__(self, operations: Iterable[WriteOperation] = ()) -> None:
65
+ items = tuple(operations)
66
+ keys = [item.key for item in items]
67
+ if len(keys) != len(set(keys)):
68
+ raise DuplicateBatchKeyError("each key may appear only once in a write batch")
69
+ object.__setattr__(self, "operations", items)
70
+
71
+ def __bool__(self) -> bool:
72
+ return bool(self.operations)
73
+
74
+ @classmethod
75
+ def of(cls, *operations: WriteOperation) -> "WriteBatch":
76
+ return cls(operations)
@@ -0,0 +1,6 @@
1
+ from .memory import MemoryBackend
2
+ from .redis import RedisBackend
3
+ from .sqlalchemy import SQLAlchemyBackend
4
+ from .sqlite import SQLiteBackend
5
+
6
+ __all__ = ["MemoryBackend", "RedisBackend", "SQLAlchemyBackend", "SQLiteBackend"]