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.
- tketool_storage-1.3.5/PKG-INFO +62 -0
- tketool_storage-1.3.5/README.md +36 -0
- tketool_storage-1.3.5/pyproject.toml +33 -0
- tketool_storage-1.3.5/setup.cfg +4 -0
- tketool_storage-1.3.5/src/tketool/storage/__init__.py +72 -0
- tketool_storage-1.3.5/src/tketool/storage/abstractions/__init__.py +37 -0
- tketool_storage-1.3.5/src/tketool/storage/abstractions/backend.py +122 -0
- tketool_storage-1.3.5/src/tketool/storage/abstractions/codec.py +214 -0
- tketool_storage-1.3.5/src/tketool/storage/abstractions/errors.py +22 -0
- tketool_storage-1.3.5/src/tketool/storage/abstractions/types.py +76 -0
- tketool_storage-1.3.5/src/tketool/storage/adapters/__init__.py +6 -0
- tketool_storage-1.3.5/src/tketool/storage/adapters/memory.py +143 -0
- tketool_storage-1.3.5/src/tketool/storage/adapters/redis.py +249 -0
- tketool_storage-1.3.5/src/tketool/storage/adapters/sqlalchemy.py +211 -0
- tketool_storage-1.3.5/src/tketool/storage/adapters/sqlite.py +234 -0
- tketool_storage-1.3.5/src/tketool/storage/bootstrap/__init__.py +3 -0
- tketool_storage-1.3.5/src/tketool/storage/bootstrap/factory.py +42 -0
- tketool_storage-1.3.5/src/tketool/storage/collections/__init__.py +13 -0
- tketool_storage-1.3.5/src/tketool/storage/collections/linked_list.py +251 -0
- tketool_storage-1.3.5/src/tketool/storage/collections/mapping.py +165 -0
- tketool_storage-1.3.5/src/tketool/storage/collections/multimap.py +89 -0
- tketool_storage-1.3.5/src/tketool/storage/collections/sequence.py +111 -0
- tketool_storage-1.3.5/src/tketool/storage/structures/__init__.py +4 -0
- tketool_storage-1.3.5/src/tketool/storage/structures/graph.py +282 -0
- tketool_storage-1.3.5/src/tketool/storage/structures/tree.py +252 -0
- tketool_storage-1.3.5/src/tketool.storage.egg-info/PKG-INFO +62 -0
- tketool_storage-1.3.5/src/tketool.storage.egg-info/SOURCES.txt +28 -0
- tketool_storage-1.3.5/src/tketool.storage.egg-info/dependency_links.txt +1 -0
- tketool_storage-1.3.5/src/tketool.storage.egg-info/requires.txt +15 -0
- 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,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)
|