diskstore 0.2.0__tar.gz → 0.3.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.
- {diskstore-0.2.0 → diskstore-0.3.1}/PKG-INFO +1 -1
- {diskstore-0.2.0 → diskstore-0.3.1}/pyproject.toml +9 -19
- diskstore-0.3.1/src/diskstore/config.py +247 -0
- {diskstore-0.2.0 → diskstore-0.3.1}/src/diskstore/const.py +14 -2
- {diskstore-0.2.0 → diskstore-0.3.1}/src/diskstore/diskread.py +64 -36
- diskstore-0.3.1/src/diskstore/diskstore.py +320 -0
- diskstore-0.2.0/src/diskstore/config.py +0 -190
- diskstore-0.2.0/src/diskstore/diskstore.py +0 -264
- {diskstore-0.2.0 → diskstore-0.3.1}/README.md +0 -0
- {diskstore-0.2.0 → diskstore-0.3.1}/src/diskstore/__init__.py +0 -0
|
@@ -1,42 +1,29 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "diskstore"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.3.1"
|
|
4
4
|
description = "Simple MutableMapping compatible SQLite based disk storage."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.10"
|
|
7
7
|
authors = [{ name = "tds333", email = "tds333@mailbox.org" }]
|
|
8
|
-
dependencies = [
|
|
9
|
-
"apsw>=3.51.3",
|
|
10
|
-
]
|
|
8
|
+
dependencies = ["apsw>=3.51.3"]
|
|
11
9
|
|
|
12
10
|
[dependency-groups]
|
|
13
11
|
dev = [
|
|
12
|
+
"msgspec>=0.20.0",
|
|
13
|
+
"pydantic>=2.12.5,<=2.99.99b0",
|
|
14
14
|
"pytest",
|
|
15
15
|
"pytest-asyncio>=1.3.0",
|
|
16
16
|
"pytest-cov",
|
|
17
17
|
"pytest-examples>=0.0.18",
|
|
18
|
-
"pytest-xdist"
|
|
19
|
-
]
|
|
20
|
-
benchmark = [
|
|
21
|
-
"diskcache",
|
|
22
|
-
"ipython",
|
|
23
|
-
"pickleDB<1",
|
|
24
|
-
"pylibmc",
|
|
25
|
-
"redis",
|
|
26
|
-
"sqlitedict",
|
|
18
|
+
"pytest-xdist"
|
|
27
19
|
]
|
|
28
20
|
docs = [
|
|
29
|
-
"mkdocs>=1.6.1",
|
|
30
21
|
"mkdocstrings[python]>=1.0.3",
|
|
31
22
|
"zensical>=0.0.23",
|
|
32
23
|
]
|
|
33
|
-
ext-dev = [
|
|
34
|
-
"msgspec>=0.20.0",
|
|
35
|
-
"pydantic>=2.12.5",
|
|
36
|
-
]
|
|
37
24
|
|
|
38
25
|
[build-system]
|
|
39
|
-
requires = ["uv_build>=0.9.2,<
|
|
26
|
+
requires = ["uv_build>=0.9.2,<1"]
|
|
40
27
|
build-backend = "uv_build"
|
|
41
28
|
|
|
42
29
|
[tool.coverage.run]
|
|
@@ -78,3 +65,6 @@ ignore = [
|
|
|
78
65
|
"RET504",
|
|
79
66
|
"PLR2004",
|
|
80
67
|
]
|
|
68
|
+
|
|
69
|
+
[tool.uv]
|
|
70
|
+
exclude-newer = "1 days"
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
"""DiskStore configuration classes and helpers for config."""
|
|
2
|
+
|
|
3
|
+
import dataclasses
|
|
4
|
+
import json
|
|
5
|
+
from abc import abstractmethod
|
|
6
|
+
from collections.abc import Sequence
|
|
7
|
+
from typing import Any, Iterable, Protocol
|
|
8
|
+
|
|
9
|
+
from .const import NO_DEFAULT, TIMEOUT, AnyLite, KeyType
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def get_sqlite_type(type_) -> str:
|
|
13
|
+
if type_ in {"BLOB", "TEXT", "INTEGER", "REAL"}:
|
|
14
|
+
return type_
|
|
15
|
+
sqlite_type = "BLOB"
|
|
16
|
+
if type_ is str:
|
|
17
|
+
sqlite_type = "TEXT"
|
|
18
|
+
elif type_ is int:
|
|
19
|
+
sqlite_type = "INTEGER"
|
|
20
|
+
elif type_ is bool:
|
|
21
|
+
sqlite_type = "INTEGER"
|
|
22
|
+
elif type_ is float:
|
|
23
|
+
sqlite_type = "REAL"
|
|
24
|
+
|
|
25
|
+
return sqlite_type
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def escape_name(name: str) -> str:
|
|
29
|
+
tablename = '"' + name.replace('"', '""') + '"'
|
|
30
|
+
return tablename
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def is_bindable_default(value) -> bool:
|
|
34
|
+
"""Whether *value* can be encoded by apsw as a SQL literal."""
|
|
35
|
+
return value is None or isinstance(value, (str, bytes, int, float))
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class ConfigProtocol(Protocol):
|
|
39
|
+
"""Configuration Protocol
|
|
40
|
+
|
|
41
|
+
Attributes:
|
|
42
|
+
tablename: Table name as string
|
|
43
|
+
key_type: key type as string or basic Python type like str, int, float
|
|
44
|
+
timeout: Timeout used to wait if someone blocks connections or with writes
|
|
45
|
+
pragmas: Dictionary with PRAGMAs to set when connections is initialized
|
|
46
|
+
fields: Iterable used for select, update and create statement with
|
|
47
|
+
field name, type, default.
|
|
48
|
+
``[("value", str, NO_DEFAULT), ("value2", int, 0)]``
|
|
49
|
+
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
tablename: str
|
|
53
|
+
key_type: str
|
|
54
|
+
timeout: float
|
|
55
|
+
pragmas: dict
|
|
56
|
+
fields: Iterable
|
|
57
|
+
auto_migrate: bool
|
|
58
|
+
|
|
59
|
+
@abstractmethod
|
|
60
|
+
def dump_value(self, key: KeyType | None, value: Any) -> Sequence:
|
|
61
|
+
"""Called with the key and value, should return a Sequence
|
|
62
|
+
with the key as first element, suitable as parameter tuple for
|
|
63
|
+
the INSERT/SET statement."""
|
|
64
|
+
|
|
65
|
+
@abstractmethod
|
|
66
|
+
def load_data(self, data: tuple) -> Any:
|
|
67
|
+
"""load(db_data): called with the tuple selected from DB,
|
|
68
|
+
including the key at index 0. Should be converted to the
|
|
69
|
+
type normally received as value."""
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class BaseConfig(ConfigProtocol):
|
|
73
|
+
"""Base default configuratin."""
|
|
74
|
+
|
|
75
|
+
def __init__(
|
|
76
|
+
self,
|
|
77
|
+
*,
|
|
78
|
+
tablename=None,
|
|
79
|
+
key_type=None,
|
|
80
|
+
timeout=None,
|
|
81
|
+
pragmas=None,
|
|
82
|
+
auto_migrate=None,
|
|
83
|
+
):
|
|
84
|
+
self.tablename = "DiskStore" if tablename is None else tablename
|
|
85
|
+
self.key_type = "BLOB" if key_type is None else get_sqlite_type(key_type)
|
|
86
|
+
self.timeout = TIMEOUT if timeout is None else float(timeout)
|
|
87
|
+
self.pragmas = {} if pragmas is None else pragmas
|
|
88
|
+
self.auto_migrate = True if auto_migrate is None else auto_migrate
|
|
89
|
+
self.fields = [("value", "BLOB", NO_DEFAULT)]
|
|
90
|
+
|
|
91
|
+
def dump_value(self, key: KeyType | None, value: Any) -> Sequence:
|
|
92
|
+
return (key, value)
|
|
93
|
+
|
|
94
|
+
def load_data(self, data: tuple) -> Any:
|
|
95
|
+
return data[1]
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class NamedTupleConfig(BaseConfig):
|
|
99
|
+
def __init__( # noqa: PLR0913
|
|
100
|
+
self,
|
|
101
|
+
value_class,
|
|
102
|
+
tablename=None,
|
|
103
|
+
key_type=None,
|
|
104
|
+
timeout=None,
|
|
105
|
+
pragmas=None,
|
|
106
|
+
auto_migrate=None,
|
|
107
|
+
):
|
|
108
|
+
tablename = value_class.__name__ if not tablename else tablename
|
|
109
|
+
super().__init__(
|
|
110
|
+
tablename=tablename,
|
|
111
|
+
key_type=key_type,
|
|
112
|
+
timeout=timeout,
|
|
113
|
+
pragmas=pragmas,
|
|
114
|
+
auto_migrate=auto_migrate,
|
|
115
|
+
)
|
|
116
|
+
self.value_class = value_class
|
|
117
|
+
self.fields = self.get_fields(value_class)
|
|
118
|
+
|
|
119
|
+
@staticmethod
|
|
120
|
+
def get_fields(value_class):
|
|
121
|
+
fields = []
|
|
122
|
+
value_columns = tuple(value_class._fields)
|
|
123
|
+
type_annotations = value_class.__annotations__
|
|
124
|
+
value_column_defaults: dict[str, AnyLite] = getattr(
|
|
125
|
+
value_class, "_field_defaults", {}
|
|
126
|
+
)
|
|
127
|
+
for name in value_columns:
|
|
128
|
+
type_cls = type_annotations.get(name, bytes) # types are optional
|
|
129
|
+
sqlite_type = get_sqlite_type(type_cls)
|
|
130
|
+
default = value_column_defaults.get(name, NO_DEFAULT)
|
|
131
|
+
if not is_bindable_default(default):
|
|
132
|
+
default = NO_DEFAULT
|
|
133
|
+
fields.append((name, sqlite_type, default))
|
|
134
|
+
if "_key" in value_columns:
|
|
135
|
+
raise ValueError(
|
|
136
|
+
f"Name _key is not allowed as attribute for {value_class},"
|
|
137
|
+
" listed as field name in _fields."
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
return tuple(fields)
|
|
141
|
+
|
|
142
|
+
def dump_value(self, key: KeyType | None, value: Any) -> Sequence:
|
|
143
|
+
return (key, *value)
|
|
144
|
+
|
|
145
|
+
def load_data(self, data: tuple) -> Any:
|
|
146
|
+
return self.value_class._make(data[1:])
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class JsonConfig(BaseConfig):
|
|
150
|
+
def __init__(
|
|
151
|
+
self,
|
|
152
|
+
tablename=None,
|
|
153
|
+
key_type=None,
|
|
154
|
+
timeout=None,
|
|
155
|
+
pragmas=None,
|
|
156
|
+
auto_migrate=None,
|
|
157
|
+
):
|
|
158
|
+
super().__init__(
|
|
159
|
+
tablename=tablename,
|
|
160
|
+
key_type=key_type,
|
|
161
|
+
timeout=timeout,
|
|
162
|
+
pragmas=pragmas,
|
|
163
|
+
auto_migrate=auto_migrate,
|
|
164
|
+
)
|
|
165
|
+
self.fields = (("value", "TEXT", NO_DEFAULT),)
|
|
166
|
+
|
|
167
|
+
def dump_value(self, key: KeyType | None, value: Any) -> Sequence:
|
|
168
|
+
return (key, json.dumps(value))
|
|
169
|
+
|
|
170
|
+
def load_data(self, data: tuple) -> Any:
|
|
171
|
+
return json.loads(data[1])
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
class DataclassConfig(BaseConfig):
|
|
175
|
+
def __init__( # noqa: PLR0913
|
|
176
|
+
self,
|
|
177
|
+
dataclass,
|
|
178
|
+
tablename=None,
|
|
179
|
+
key_type=None,
|
|
180
|
+
timeout=None,
|
|
181
|
+
pragmas=None,
|
|
182
|
+
auto_migrate=None,
|
|
183
|
+
):
|
|
184
|
+
tablename = dataclass.__name__ if not tablename else tablename
|
|
185
|
+
super().__init__(
|
|
186
|
+
tablename=tablename,
|
|
187
|
+
key_type=key_type,
|
|
188
|
+
timeout=timeout,
|
|
189
|
+
pragmas=pragmas,
|
|
190
|
+
auto_migrate=auto_migrate,
|
|
191
|
+
)
|
|
192
|
+
self.dataclass = dataclass
|
|
193
|
+
self.fields = self.get_fields(dataclass)
|
|
194
|
+
|
|
195
|
+
@staticmethod
|
|
196
|
+
def get_fields(dataclass):
|
|
197
|
+
if not dataclasses.is_dataclass(dataclass):
|
|
198
|
+
raise ValueError("It is not a dataclass.")
|
|
199
|
+
dc_fields = dataclasses.fields(dataclass)
|
|
200
|
+
value_columns = tuple(field.name for field in dc_fields)
|
|
201
|
+
if "_key" in value_columns:
|
|
202
|
+
raise ValueError("Name _key is not allowed as attribute for dataclass.")
|
|
203
|
+
type_annotations = getattr(dataclass, "__annotations__", {}) or {}
|
|
204
|
+
fields = []
|
|
205
|
+
for f in dc_fields:
|
|
206
|
+
type_cls = type_annotations.get(f.name, bytes)
|
|
207
|
+
sqlite_type = get_sqlite_type(type_cls)
|
|
208
|
+
if f.default is not dataclasses.MISSING and is_bindable_default(f.default):
|
|
209
|
+
default = f.default
|
|
210
|
+
else:
|
|
211
|
+
default = NO_DEFAULT
|
|
212
|
+
fields.append((f.name, sqlite_type, default))
|
|
213
|
+
return tuple(fields)
|
|
214
|
+
|
|
215
|
+
def dump_value(self, key: KeyType | None, value: Any) -> Sequence:
|
|
216
|
+
return (key, *dataclasses.astuple(value))
|
|
217
|
+
|
|
218
|
+
def load_data(self, data: tuple) -> Any:
|
|
219
|
+
return self.dataclass(*data[1:])
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
class PydanticConfig(BaseConfig):
|
|
223
|
+
def __init__( # noqa: PLR0913
|
|
224
|
+
self,
|
|
225
|
+
model,
|
|
226
|
+
tablename=None,
|
|
227
|
+
key_type=None,
|
|
228
|
+
timeout=None,
|
|
229
|
+
pragmas=None,
|
|
230
|
+
auto_migrate=None,
|
|
231
|
+
):
|
|
232
|
+
tablename = model.__name__ if not tablename else tablename
|
|
233
|
+
super().__init__(
|
|
234
|
+
tablename=tablename,
|
|
235
|
+
key_type=key_type,
|
|
236
|
+
timeout=timeout,
|
|
237
|
+
pragmas=pragmas,
|
|
238
|
+
auto_migrate=auto_migrate,
|
|
239
|
+
)
|
|
240
|
+
self.fields = (("value", "TEXT", NO_DEFAULT),)
|
|
241
|
+
self.model = model
|
|
242
|
+
|
|
243
|
+
def dump_value(self, key: KeyType | None, value: Any) -> Sequence:
|
|
244
|
+
return (key, value.model_dump_json())
|
|
245
|
+
|
|
246
|
+
def load_data(self, data: tuple) -> Any:
|
|
247
|
+
return self.model.model_validate_json(data[1])
|
|
@@ -5,19 +5,31 @@ from typing import (
|
|
|
5
5
|
Union,
|
|
6
6
|
)
|
|
7
7
|
|
|
8
|
-
TIMEOUT =
|
|
8
|
+
TIMEOUT = 10.0
|
|
9
9
|
"default timout in seconds"
|
|
10
10
|
|
|
11
11
|
MISSING = object()
|
|
12
12
|
|
|
13
13
|
|
|
14
|
+
class NO_DEFAULT: # noqa: N801
|
|
15
|
+
"""Sentinel marker for fields without a default value."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
DEFAULT_RO_PRAGMAS = {
|
|
19
|
+
"cache_size": 2**13, # 8,192 pages
|
|
20
|
+
"mmap_size": 2**28, # 256 MB
|
|
21
|
+
"temp_store": 2, # 0=DEFAULT, 1=FILE, 2=MEMORY
|
|
22
|
+
"synchronous": 1, # 0=OFF, 1=NORMAL, 2=FULL, 3=EXTRA
|
|
23
|
+
}
|
|
24
|
+
"default read only pragma settings"
|
|
25
|
+
|
|
14
26
|
DEFAULT_PRAGMAS = {
|
|
15
27
|
"auto_vacuum": 0, # 1=FULL, 0=None
|
|
16
28
|
"cache_size": 2**13, # 8,192 pages
|
|
17
29
|
"journal_mode": "wal",
|
|
18
30
|
"mmap_size": 2**28, # 256 MB
|
|
19
31
|
"synchronous": 1, # 0=OFF, 1=NORMAL, 2=FULL, 3=EXTRA
|
|
20
|
-
"temp_store":
|
|
32
|
+
"temp_store": 2, # 0=DEFAULT, 1=FILE, 2=MEMORY
|
|
21
33
|
# "pargma_busy_timeout": 5000, # milliseconds
|
|
22
34
|
}
|
|
23
35
|
"default pragma settings"
|
|
@@ -13,16 +13,15 @@ import os
|
|
|
13
13
|
import os.path
|
|
14
14
|
import threading
|
|
15
15
|
from collections.abc import ItemsView, KeysView, Mapping, ValuesView
|
|
16
|
-
from contextlib import closing
|
|
16
|
+
from contextlib import closing
|
|
17
17
|
from typing import Generator, Optional, Sequence, TypeAlias, Union
|
|
18
18
|
|
|
19
19
|
import apsw
|
|
20
20
|
|
|
21
21
|
from .config import BaseConfig, ConfigProtocol, escape_name
|
|
22
|
-
from .const import MISSING, TIMEOUT, KeyType
|
|
22
|
+
from .const import DEFAULT_RO_PRAGMAS, MISSING, TIMEOUT, KeyType
|
|
23
23
|
|
|
24
24
|
Connection = apsw.Connection
|
|
25
|
-
Cursor = apsw.Cursor
|
|
26
25
|
|
|
27
26
|
|
|
28
27
|
BasicType: TypeAlias = Union[bytes, str, int, float]
|
|
@@ -76,7 +75,6 @@ class DiskRead(Mapping):
|
|
|
76
75
|
|
|
77
76
|
"""
|
|
78
77
|
filename = os.path.expanduser(filename)
|
|
79
|
-
filename = os.path.expandvars(filename)
|
|
80
78
|
self._filename = os.fspath(filename)
|
|
81
79
|
self._config: ConfigProtocol = BaseConfig() if config is None else config
|
|
82
80
|
self._timeout: float = (
|
|
@@ -85,13 +83,15 @@ class DiskRead(Mapping):
|
|
|
85
83
|
else float(self._config.timeout)
|
|
86
84
|
)
|
|
87
85
|
self._load_data = self._config.load_data
|
|
86
|
+
self._pragmas = DEFAULT_RO_PRAGMAS.copy()
|
|
87
|
+
self._pragmas.update(self._config.pragmas)
|
|
88
88
|
self._local = threading.local()
|
|
89
89
|
|
|
90
90
|
# precreated statements based on tablename and value_class
|
|
91
91
|
tablename = escape_name(self._config.tablename)
|
|
92
92
|
fields = ", ".join(f"{escape_name(field)}" for field, *_ in self._config.fields)
|
|
93
93
|
self._statements: dict[str, str] = {
|
|
94
|
-
"GET": f"SELECT {fields} FROM {tablename} WHERE _key = ? LIMIT 1",
|
|
94
|
+
"GET": f"SELECT _key, {fields} FROM {tablename} WHERE _key = ? LIMIT 1",
|
|
95
95
|
"CONTAINS": f"SELECT _key FROM {tablename} WHERE _key = ? LIMIT 1",
|
|
96
96
|
"ITER": f"SELECT _key FROM {tablename} ORDER BY rowid ASC",
|
|
97
97
|
"REVERSED": f"SELECT _key FROM {tablename} ORDER BY rowid DESC",
|
|
@@ -124,7 +124,7 @@ class DiskRead(Mapping):
|
|
|
124
124
|
|
|
125
125
|
if local_pid != pid:
|
|
126
126
|
self.close()
|
|
127
|
-
self._local.pid
|
|
127
|
+
self._local.pid = pid
|
|
128
128
|
|
|
129
129
|
con = getattr(self._local, "con", None)
|
|
130
130
|
|
|
@@ -134,20 +134,18 @@ class DiskRead(Mapping):
|
|
|
134
134
|
)
|
|
135
135
|
con.set_busy_timeout(int(self._timeout * 1000))
|
|
136
136
|
|
|
137
|
-
|
|
137
|
+
# Some SQLite pragmas work on a per-connection basis so
|
|
138
|
+
# apply them all on fresh connection
|
|
139
|
+
for key, value in self._pragmas.items():
|
|
140
|
+
con.pragma(key, value)
|
|
138
141
|
|
|
139
|
-
|
|
140
|
-
def _cursor(self):
|
|
141
|
-
cursor: Cursor = self._con.cursor()
|
|
142
|
-
try:
|
|
143
|
-
yield cursor
|
|
144
|
-
finally:
|
|
145
|
-
cursor.close()
|
|
142
|
+
return con
|
|
146
143
|
|
|
147
144
|
def __getitem__(self, key: KeyType):
|
|
145
|
+
"""Get value for *key*, raises KeyError if not found."""
|
|
148
146
|
select = self._statements["GET"]
|
|
149
|
-
with self.
|
|
150
|
-
row = next(
|
|
147
|
+
with closing(self._con.execute(select, (key,))) as cx:
|
|
148
|
+
row = next(cx, MISSING)
|
|
151
149
|
|
|
152
150
|
if row is MISSING:
|
|
153
151
|
raise KeyError(key)
|
|
@@ -155,57 +153,83 @@ class DiskRead(Mapping):
|
|
|
155
153
|
return self._load_data(row) # ty:ignore[invalid-argument-type]
|
|
156
154
|
|
|
157
155
|
def keys(self):
|
|
156
|
+
"""Return a set-like view of keys in the mapping."""
|
|
157
|
+
|
|
158
158
|
return DiskKeysView(self)
|
|
159
159
|
|
|
160
160
|
def values(self):
|
|
161
|
+
"""Return a set-like view of values in the mapping."""
|
|
162
|
+
|
|
161
163
|
return DiskValuesView(self)
|
|
162
164
|
|
|
163
165
|
def items(self):
|
|
166
|
+
"""Return a set-like view of (key, value) pairs in the mapping."""
|
|
167
|
+
|
|
164
168
|
return DiskItemsView(self)
|
|
165
169
|
|
|
166
170
|
def query(
|
|
167
171
|
self,
|
|
168
172
|
where: Optional[str] = None,
|
|
169
|
-
parameters: Optional[Sequence] = None,
|
|
173
|
+
parameters: Optional[Sequence | dict] = None,
|
|
170
174
|
order: Optional[str] = None,
|
|
171
175
|
limit: int | None = None,
|
|
172
176
|
offset: int | None = None,
|
|
173
177
|
) -> Generator[tuple, None, None]:
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
178
|
+
"""Query rows with optional filtering, ordering, limit and offset.
|
|
179
|
+
|
|
180
|
+
Args:
|
|
181
|
+
where: SQL WHERE clause (without the WHERE keyword).
|
|
182
|
+
parameters: Parameters for the WHERE clause.
|
|
183
|
+
order: ORDER BY clause (without the ORDER BY keyword).
|
|
184
|
+
limit: Maximum number of rows to return.
|
|
185
|
+
offset: Number of rows to skip.
|
|
186
|
+
|
|
187
|
+
Yields:
|
|
188
|
+
(key, value) tuples.
|
|
189
|
+
|
|
190
|
+
Examples:
|
|
191
|
+
>>> from diskstore import DiskRead
|
|
192
|
+
>>> ds = DiskRead("/tmp/data.db")
|
|
193
|
+
>>> list(ds.query(where="_key > ?", parameters=(1,), limit=5))
|
|
194
|
+
[(2, 'two'), (3, 'three')]
|
|
195
|
+
|
|
196
|
+
"""
|
|
197
|
+
where_ = " WHERE " + where if where else ""
|
|
198
|
+
parameters_ = () if parameters is None else parameters
|
|
199
|
+
order_ = " ORDER BY " + order if order else ""
|
|
200
|
+
limit_ = " LIMIT " + str(int(limit)) if limit is not None else ""
|
|
201
|
+
offset_ = " OFFSET " + str(int(offset)) if offset is not None else ""
|
|
202
|
+
select = self._statements["QUERY"] + where_ + order_ + limit_ + offset_
|
|
203
|
+
|
|
204
|
+
with closing(self._con.execute(select, parameters_)) as cursor:
|
|
183
205
|
for row in cursor:
|
|
184
|
-
yield row[0], self._load_data(row
|
|
206
|
+
yield row[0], self._load_data(row)
|
|
185
207
|
|
|
186
208
|
def __contains__(self, key: object) -> bool:
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
return
|
|
209
|
+
"""Check if *key* exists in the store."""
|
|
210
|
+
with closing(self._con.execute(self._statements["CONTAINS"], (key,))) as cx:
|
|
211
|
+
row = cx.fetchone()
|
|
212
|
+
return row is not None
|
|
191
213
|
|
|
192
214
|
def __iter__(self):
|
|
193
|
-
|
|
194
|
-
|
|
215
|
+
"""Iterate over keys in insertion order."""
|
|
216
|
+
with closing(self._con.execute(self._statements["ITER"])) as cx:
|
|
195
217
|
for row in cx:
|
|
196
218
|
yield row[0]
|
|
197
219
|
|
|
198
220
|
def __reversed__(self):
|
|
199
|
-
|
|
200
|
-
|
|
221
|
+
"""Iterate over keys in reverse insertion order."""
|
|
222
|
+
with closing(self._con.execute(self._statements["REVERSED"])) as cx:
|
|
201
223
|
for row in cx:
|
|
202
224
|
yield row[0]
|
|
203
225
|
|
|
204
|
-
def open(self):
|
|
226
|
+
def open(self) -> "DiskRead":
|
|
227
|
+
"""Open (or re-open) the database connection and return self."""
|
|
205
228
|
connection = self._con # noqa
|
|
206
229
|
return self
|
|
207
230
|
|
|
208
231
|
def close(self) -> None:
|
|
232
|
+
"""Close the database connection if open."""
|
|
209
233
|
con = getattr(self._local, "con", None)
|
|
210
234
|
if con is None:
|
|
211
235
|
return
|
|
@@ -223,6 +247,10 @@ class DiskRead(Mapping):
|
|
|
223
247
|
self.close()
|
|
224
248
|
|
|
225
249
|
def __len__(self):
|
|
250
|
+
"""Return the number of items in the store.
|
|
251
|
+
|
|
252
|
+
To do this count is used which is not a performant implementation.
|
|
253
|
+
"""
|
|
226
254
|
select = self._statements["COUNT"]
|
|
227
255
|
|
|
228
256
|
with closing(self._con.execute(select)) as cx:
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
"""DiskStore (MutableMapping) API.
|
|
2
|
+
|
|
3
|
+
Examples:
|
|
4
|
+
>>> from diskstore import DiskStore
|
|
5
|
+
>>> ds = DiskStore("/tmp/data.db")
|
|
6
|
+
>>> ds["one"] = 1
|
|
7
|
+
>>> ds["one"]
|
|
8
|
+
1
|
|
9
|
+
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
import os.path
|
|
14
|
+
import threading
|
|
15
|
+
from collections.abc import Mapping, MutableMapping
|
|
16
|
+
from contextlib import closing, contextmanager
|
|
17
|
+
from typing import Any, Iterable
|
|
18
|
+
|
|
19
|
+
import apsw
|
|
20
|
+
|
|
21
|
+
from .config import ConfigProtocol, escape_name, get_sqlite_type
|
|
22
|
+
from .const import DEFAULT_PRAGMAS, MISSING, NO_DEFAULT, KeyType
|
|
23
|
+
from .diskread import DiskRead
|
|
24
|
+
|
|
25
|
+
Connection = apsw.Connection
|
|
26
|
+
Cursor = apsw.Cursor
|
|
27
|
+
SQLError = apsw.SQLError
|
|
28
|
+
BusyError = apsw.BusyError
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class DiskStore(DiskRead, MutableMapping):
|
|
32
|
+
def __init__(
|
|
33
|
+
self, filename: os.PathLike | str, config: ConfigProtocol | None = None
|
|
34
|
+
) -> None:
|
|
35
|
+
"""SQLite based MutableMapping disk storage.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
filename: DiskStore DB filename.
|
|
39
|
+
config: Configuration as specified in ConfigProtocol
|
|
40
|
+
"""
|
|
41
|
+
super().__init__(filename=filename, config=config)
|
|
42
|
+
self._txn_id = None
|
|
43
|
+
tablename = escape_name(self._config.tablename)
|
|
44
|
+
|
|
45
|
+
value_columns = self._config.fields
|
|
46
|
+
fields = ", ".join(f"{escape_name(field)}" for field, *_ in self._config.fields)
|
|
47
|
+
excluded_fields = ", ".join(
|
|
48
|
+
f"{field} = excluded.{escape_name(field)}"
|
|
49
|
+
for field, *_ in self._config.fields
|
|
50
|
+
)
|
|
51
|
+
qms = ", ".join("?" for field in value_columns)
|
|
52
|
+
self._statements.update(
|
|
53
|
+
{
|
|
54
|
+
"SET": (
|
|
55
|
+
f"INSERT INTO {tablename}(_key, {fields}) VALUES (?, {qms})"
|
|
56
|
+
f" ON CONFLICT (_key) DO UPDATE SET {excluded_fields}"
|
|
57
|
+
),
|
|
58
|
+
"ADD": (
|
|
59
|
+
f"INSERT INTO {tablename}(_key, {fields}) "
|
|
60
|
+
f"VALUES (?, {qms}) ON CONFLICT DO NOTHING RETURNING _key"
|
|
61
|
+
),
|
|
62
|
+
"DELETE": f"DELETE FROM {tablename} WHERE _key = ? RETURNING _key",
|
|
63
|
+
"POP": (
|
|
64
|
+
f"DELETE FROM {tablename} WHERE _key = ? RETURNING _key, {fields}"
|
|
65
|
+
),
|
|
66
|
+
"CLEAR": f"DELETE FROM {tablename};VACUUM;",
|
|
67
|
+
"POPITEM": (
|
|
68
|
+
f"DELETE FROM {tablename}"
|
|
69
|
+
f" WHERE rowid = (SELECT MAX(rowid) FROM {tablename})"
|
|
70
|
+
f" RETURNING _key, {fields}"
|
|
71
|
+
),
|
|
72
|
+
}
|
|
73
|
+
)
|
|
74
|
+
self._pragmas = DEFAULT_PRAGMAS.copy()
|
|
75
|
+
self._pragmas.update(self._config.pragmas)
|
|
76
|
+
self._dump_value = self._config.dump_value
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def _con(self) -> Connection:
|
|
80
|
+
# Check process ID to support process forking. If the process
|
|
81
|
+
# ID changes, close the connection and update the process ID.
|
|
82
|
+
|
|
83
|
+
local_pid = getattr(self._local, "pid", None)
|
|
84
|
+
pid: int = os.getpid()
|
|
85
|
+
|
|
86
|
+
if local_pid != pid:
|
|
87
|
+
self.close()
|
|
88
|
+
self._local.pid = pid
|
|
89
|
+
|
|
90
|
+
con = getattr(self._local, "con", None)
|
|
91
|
+
|
|
92
|
+
if con is None:
|
|
93
|
+
con = Connection(self._filename)
|
|
94
|
+
con.set_busy_timeout(int(self._timeout * 1000))
|
|
95
|
+
|
|
96
|
+
# Some SQLite pragmas work on a per-connection basis so
|
|
97
|
+
# apply them all on fresh connection
|
|
98
|
+
for key, value in self._pragmas.items():
|
|
99
|
+
con.pragma(key, value)
|
|
100
|
+
self.migrate_table(con, self._config)
|
|
101
|
+
self._local.con = con
|
|
102
|
+
|
|
103
|
+
return con
|
|
104
|
+
|
|
105
|
+
@staticmethod
|
|
106
|
+
def _get_field_create(field_tuple):
|
|
107
|
+
field_name, field_type, field_default = field_tuple
|
|
108
|
+
default = " NOT NULL"
|
|
109
|
+
if field_default is None:
|
|
110
|
+
default = " DEFAULT NULL"
|
|
111
|
+
elif field_default is not NO_DEFAULT:
|
|
112
|
+
default = " NOT NULL DEFAULT " + apsw.format_sql_value(field_default)
|
|
113
|
+
name = escape_name(field_name)
|
|
114
|
+
type_ = get_sqlite_type(field_type)
|
|
115
|
+
field_create = f"{name} {type_}{default}"
|
|
116
|
+
|
|
117
|
+
return field_create
|
|
118
|
+
|
|
119
|
+
@staticmethod
|
|
120
|
+
def migrate_table(con: Connection, config: ConfigProtocol) -> None:
|
|
121
|
+
"""Ensure the table exists, optionally migrating to match config.fields.
|
|
122
|
+
|
|
123
|
+
If the table does not exist it is created from *config*. If
|
|
124
|
+
*config.auto_migrate* is true and the table already exists,
|
|
125
|
+
columns in *config.fields* missing from the table are added.
|
|
126
|
+
|
|
127
|
+
Migration uses ``BEGIN IMMEDIATE`` to serialise concurrent
|
|
128
|
+
callers.
|
|
129
|
+
"""
|
|
130
|
+
tablename = escape_name(config.tablename)
|
|
131
|
+
existing = {
|
|
132
|
+
row[1] for row in (con.pragma("table_info", config.tablename) or [])
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if not existing:
|
|
136
|
+
primary_key_type = get_sqlite_type(config.key_type)
|
|
137
|
+
fields_create = ", ".join(
|
|
138
|
+
DiskStore._get_field_create(f) for f in config.fields
|
|
139
|
+
)
|
|
140
|
+
create_stmt = (
|
|
141
|
+
f"CREATE TABLE IF NOT EXISTS {tablename} ("
|
|
142
|
+
f" _key {primary_key_type} PRIMARY KEY NOT NULL"
|
|
143
|
+
f", {fields_create})"
|
|
144
|
+
)
|
|
145
|
+
con.execute(create_stmt)
|
|
146
|
+
elif config.auto_migrate:
|
|
147
|
+
config_names = {f[0] for f in config.fields}
|
|
148
|
+
if config_names - existing:
|
|
149
|
+
con.execute("BEGIN IMMEDIATE")
|
|
150
|
+
existing = {
|
|
151
|
+
row[1] for row in con.pragma("table_info", config.tablename)
|
|
152
|
+
}
|
|
153
|
+
for field_name, field_type, default_value in config.fields:
|
|
154
|
+
if field_name not in existing:
|
|
155
|
+
col_def = DiskStore._get_field_create(
|
|
156
|
+
(field_name, field_type, default_value)
|
|
157
|
+
)
|
|
158
|
+
alter_stmt = f"ALTER TABLE {tablename} ADD COLUMN {col_def};"
|
|
159
|
+
con.execute(alter_stmt)
|
|
160
|
+
con.execute("COMMIT")
|
|
161
|
+
|
|
162
|
+
@contextmanager
|
|
163
|
+
def transact(self):
|
|
164
|
+
"""Wrapper for performance sensitive bulk writes.
|
|
165
|
+
|
|
166
|
+
Every write operation (``__setitem__``, ``__delitem__``, ``add``,
|
|
167
|
+
etc.) runs in an implicit transaction when called outside of
|
|
168
|
+
``transact()``. The implicit begin/commit overhead adds up when
|
|
169
|
+
writing many items in a loop:
|
|
170
|
+
|
|
171
|
+
.. code:: python
|
|
172
|
+
|
|
173
|
+
# slow: one implicit transaction per write
|
|
174
|
+
for i in range(1000):
|
|
175
|
+
store[i] = value
|
|
176
|
+
|
|
177
|
+
# fast: single explicit transaction
|
|
178
|
+
with store.transact():
|
|
179
|
+
for i in range(1000):
|
|
180
|
+
store[i] = value
|
|
181
|
+
|
|
182
|
+
Uses ``BEGIN IMMEDIATE`` to avoid deadlocks in concurrent
|
|
183
|
+
workloads. Nested calls are idempotent (reuse the same
|
|
184
|
+
transaction). Yields an ``apsw.Cursor`` for callers that need
|
|
185
|
+
direct SQL execution.
|
|
186
|
+
"""
|
|
187
|
+
cursor: Cursor = self._con.cursor()
|
|
188
|
+
tid = threading.get_ident()
|
|
189
|
+
txn_id = self._txn_id
|
|
190
|
+
|
|
191
|
+
if tid == txn_id: # already inside a thread with a transaction
|
|
192
|
+
begin = False
|
|
193
|
+
else:
|
|
194
|
+
cursor.execute("BEGIN IMMEDIATE")
|
|
195
|
+
begin = True
|
|
196
|
+
self._txn_id = tid
|
|
197
|
+
|
|
198
|
+
try:
|
|
199
|
+
yield cursor
|
|
200
|
+
except BaseException:
|
|
201
|
+
if begin:
|
|
202
|
+
assert self._txn_id == tid
|
|
203
|
+
self._txn_id = None
|
|
204
|
+
cursor.execute("ROLLBACK")
|
|
205
|
+
cursor.close()
|
|
206
|
+
raise
|
|
207
|
+
else:
|
|
208
|
+
if begin:
|
|
209
|
+
assert self._txn_id == tid
|
|
210
|
+
self._txn_id = None
|
|
211
|
+
cursor.execute("COMMIT")
|
|
212
|
+
cursor.close()
|
|
213
|
+
|
|
214
|
+
def __setitem__(self, key: KeyType, value: Any) -> None:
|
|
215
|
+
with closing(
|
|
216
|
+
self._con.execute(self._statements["SET"], self._dump_value(key, value))
|
|
217
|
+
):
|
|
218
|
+
pass
|
|
219
|
+
|
|
220
|
+
def add(self, key: KeyType | None, value: Iterable) -> KeyType | None:
|
|
221
|
+
with closing(
|
|
222
|
+
self._con.execute(self._statements["ADD"], self._dump_value(key, value))
|
|
223
|
+
) as cx:
|
|
224
|
+
rows = cx.fetchall()
|
|
225
|
+
|
|
226
|
+
if not rows:
|
|
227
|
+
return None
|
|
228
|
+
|
|
229
|
+
return rows[0][0]
|
|
230
|
+
|
|
231
|
+
def pop(self, key: KeyType, default=MISSING):
|
|
232
|
+
with closing(self._con.execute(self._statements["POP"], (key,))) as cx:
|
|
233
|
+
rows = cx.fetchall()
|
|
234
|
+
|
|
235
|
+
if not rows:
|
|
236
|
+
if default is MISSING:
|
|
237
|
+
raise KeyError(key)
|
|
238
|
+
return default
|
|
239
|
+
|
|
240
|
+
return self._load_data(rows[0])
|
|
241
|
+
|
|
242
|
+
def popitem(self):
|
|
243
|
+
with self.transact() as cursor:
|
|
244
|
+
cursor.execute(self._statements["POPITEM"])
|
|
245
|
+
row = next(cursor, None)
|
|
246
|
+
if not row:
|
|
247
|
+
raise KeyError()
|
|
248
|
+
key = row[0]
|
|
249
|
+
value = self._load_data(row)
|
|
250
|
+
return key, value
|
|
251
|
+
|
|
252
|
+
def __delitem__(self, key: KeyType) -> None:
|
|
253
|
+
with closing(self._con.execute(self._statements["DELETE"], (key,))) as cx:
|
|
254
|
+
row = cx.fetchone()
|
|
255
|
+
if row is None:
|
|
256
|
+
raise KeyError(key)
|
|
257
|
+
|
|
258
|
+
def setdefault(self, key: KeyType, default: Iterable | None = None):
|
|
259
|
+
with self.transact():
|
|
260
|
+
try:
|
|
261
|
+
return self[key]
|
|
262
|
+
except KeyError:
|
|
263
|
+
if default is not None:
|
|
264
|
+
self.add(key, default)
|
|
265
|
+
return default
|
|
266
|
+
|
|
267
|
+
def check(self, vacuum=False):
|
|
268
|
+
warns = []
|
|
269
|
+
sql = self._con.execute
|
|
270
|
+
|
|
271
|
+
# Check integrity of database.
|
|
272
|
+
with closing(sql("PRAGMA integrity_check")) as cx:
|
|
273
|
+
rows = cx.fetchall()
|
|
274
|
+
|
|
275
|
+
if len(rows) != 1 or rows[0][0] != "ok":
|
|
276
|
+
for (message,) in rows:
|
|
277
|
+
warns.append(message)
|
|
278
|
+
|
|
279
|
+
if vacuum:
|
|
280
|
+
with closing(sql("VACUUM")):
|
|
281
|
+
pass
|
|
282
|
+
|
|
283
|
+
return warns
|
|
284
|
+
|
|
285
|
+
def clear(self) -> None:
|
|
286
|
+
with closing(self._con.execute(self._statements["CLEAR"])):
|
|
287
|
+
pass
|
|
288
|
+
|
|
289
|
+
def update(self, other=(), /, **kwargs):
|
|
290
|
+
"""Bulk upsert from a mapping or iterable.
|
|
291
|
+
|
|
292
|
+
Wrapped in ``transact()`` so all upserts share a single
|
|
293
|
+
transaction — significantly faster than setting keys
|
|
294
|
+
individually in a loop.
|
|
295
|
+
"""
|
|
296
|
+
with self.transact() as cursor:
|
|
297
|
+
if other:
|
|
298
|
+
if isinstance(other, Mapping):
|
|
299
|
+
cursor.executemany(
|
|
300
|
+
self._statements["SET"],
|
|
301
|
+
(self._dump_value(key, value) for key, value in other.items()),
|
|
302
|
+
)
|
|
303
|
+
elif hasattr(other, "keys"):
|
|
304
|
+
cursor.executemany(
|
|
305
|
+
self._statements["SET"],
|
|
306
|
+
(self._dump_value(key, other[key]) for key in other.keys()),
|
|
307
|
+
)
|
|
308
|
+
else:
|
|
309
|
+
cursor.executemany(
|
|
310
|
+
self._statements["SET"],
|
|
311
|
+
(self._dump_value(key, value) for key, value in other),
|
|
312
|
+
)
|
|
313
|
+
if kwargs:
|
|
314
|
+
cursor.executemany(
|
|
315
|
+
self._statements["SET"],
|
|
316
|
+
(self._dump_value(key, value) for key, value in kwargs.items()),
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
def get_readonly_instance(self):
|
|
320
|
+
return DiskRead(self._filename, self._config)
|
|
@@ -1,190 +0,0 @@
|
|
|
1
|
-
"""DiskStore configuration classes and helpers for config."""
|
|
2
|
-
|
|
3
|
-
import dataclasses
|
|
4
|
-
import json
|
|
5
|
-
from abc import abstractmethod
|
|
6
|
-
from typing import Any, Iterable, Protocol
|
|
7
|
-
|
|
8
|
-
from .const import TIMEOUT, AnyLite
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
def get_sqlite_type(type_) -> str:
|
|
12
|
-
if type_ in {"BLOB", "TEXT", "INTEGER", "REAL"}:
|
|
13
|
-
return type_
|
|
14
|
-
sqlite_type = "BLOB"
|
|
15
|
-
if type_ is str:
|
|
16
|
-
sqlite_type = "TEXT"
|
|
17
|
-
elif type_ is int:
|
|
18
|
-
sqlite_type = "INTEGER"
|
|
19
|
-
elif type_ is float:
|
|
20
|
-
sqlite_type = "REAL"
|
|
21
|
-
|
|
22
|
-
return sqlite_type
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
def escape_name(name: str) -> str:
|
|
26
|
-
tablename = '"' + name.replace('"', '""') + '"'
|
|
27
|
-
return tablename
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
class ConfigProtocol(Protocol):
|
|
31
|
-
"""Configuration Protocol
|
|
32
|
-
|
|
33
|
-
Attributes:
|
|
34
|
-
tablename: Table name as string
|
|
35
|
-
key_type: key type as string or basic Python type like str, int, float
|
|
36
|
-
timeout: Timeout used to wait if someone blocks connections or with writes
|
|
37
|
-
pragmas: Dictionary with PRAGMAs to set when connections is initialized
|
|
38
|
-
fields: Iterable used for select, update and create statement with
|
|
39
|
-
field name, type and default. `[("value", str), ("value2", int, 0)]`
|
|
40
|
-
|
|
41
|
-
"""
|
|
42
|
-
|
|
43
|
-
tablename: str
|
|
44
|
-
key_type: str
|
|
45
|
-
timeout: float
|
|
46
|
-
pragmas: dict
|
|
47
|
-
fields: Iterable
|
|
48
|
-
|
|
49
|
-
@abstractmethod
|
|
50
|
-
def dump_value(self, value: Any) -> Iterable:
|
|
51
|
-
"""Called with the value, should return an Iterable
|
|
52
|
-
which is used to write to the DB."""
|
|
53
|
-
|
|
54
|
-
@abstractmethod
|
|
55
|
-
def load_data(self, data: tuple) -> Any:
|
|
56
|
-
"""load(db_data): called with the tuple selected from DB.
|
|
57
|
-
Should be converted to the type normally received as value."""
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
class BaseConfig(ConfigProtocol):
|
|
61
|
-
"""Base default configuratin."""
|
|
62
|
-
|
|
63
|
-
def __init__(self, *, tablename=None, key_type=None, timeout=None, pragmas=None):
|
|
64
|
-
self.tablename = "DiskStore" if tablename is None else tablename
|
|
65
|
-
self.key_type = "BLOB" if key_type is None else get_sqlite_type(key_type)
|
|
66
|
-
self.timeout = TIMEOUT if timeout is None else float(timeout)
|
|
67
|
-
self.pragmas = {} if pragmas is None else pragmas
|
|
68
|
-
self.fields = [("value", "BLOB")]
|
|
69
|
-
|
|
70
|
-
def dump_value(self, value: Any) -> Iterable:
|
|
71
|
-
return (value,)
|
|
72
|
-
|
|
73
|
-
def load_data(self, data: tuple) -> Any:
|
|
74
|
-
return data[0]
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
class NamedTupleConfig(BaseConfig):
|
|
78
|
-
def __init__(
|
|
79
|
-
self, value_class, tablename=None, key_type=None, timeout=None, pragmas=None
|
|
80
|
-
):
|
|
81
|
-
tablename = value_class.__name__ if not tablename else tablename
|
|
82
|
-
super().__init__(
|
|
83
|
-
tablename=tablename, key_type=key_type, timeout=timeout, pragmas=pragmas
|
|
84
|
-
)
|
|
85
|
-
self.value_class = value_class
|
|
86
|
-
self.fields = self.get_fields(value_class)
|
|
87
|
-
|
|
88
|
-
@staticmethod
|
|
89
|
-
def get_fields(value_class):
|
|
90
|
-
fields = []
|
|
91
|
-
value_columns = tuple(value_class._fields)
|
|
92
|
-
type_annotations = value_class.__annotations__
|
|
93
|
-
value_column_defaults: dict[str, AnyLite] = getattr(
|
|
94
|
-
value_class, "_field_defaults", {}
|
|
95
|
-
)
|
|
96
|
-
for name in value_columns:
|
|
97
|
-
type_cls = type_annotations.get(name, bytes) # types are optional
|
|
98
|
-
sqlite_type = get_sqlite_type(type_cls)
|
|
99
|
-
default = value_column_defaults.get(name)
|
|
100
|
-
fields.append((name, sqlite_type, default))
|
|
101
|
-
if "_key" in value_columns:
|
|
102
|
-
raise ValueError(
|
|
103
|
-
f"Name _key is not allowed as attribute for {value_class},"
|
|
104
|
-
" listed as field name in _fields."
|
|
105
|
-
)
|
|
106
|
-
|
|
107
|
-
return tuple(fields)
|
|
108
|
-
|
|
109
|
-
def dump_value(self, value) -> Iterable:
|
|
110
|
-
return value
|
|
111
|
-
|
|
112
|
-
def load_data(self, data: tuple) -> Any:
|
|
113
|
-
return self.value_class._make(data)
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
class JsonConfig(BaseConfig):
|
|
117
|
-
def __init__(self, tablename=None, key_type=None, timeout=None, pragmas=None):
|
|
118
|
-
super().__init__(
|
|
119
|
-
tablename=tablename, key_type=key_type, timeout=timeout, pragmas=pragmas
|
|
120
|
-
)
|
|
121
|
-
self.fields = (("value", "TEXT"),)
|
|
122
|
-
|
|
123
|
-
def dump_value(self, value: Any) -> Iterable:
|
|
124
|
-
return (json.dumps(value),)
|
|
125
|
-
|
|
126
|
-
def load_data(self, data: tuple) -> Any:
|
|
127
|
-
return json.loads(data[0])
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
class DataclassConfig(BaseConfig):
|
|
131
|
-
def __init__(
|
|
132
|
-
self, dataclass, tablename=None, key_type=None, timeout=None, pragmas=None
|
|
133
|
-
):
|
|
134
|
-
tablename = dataclass.__name__ if not tablename else tablename
|
|
135
|
-
super().__init__(
|
|
136
|
-
tablename=tablename, key_type=key_type, timeout=timeout, pragmas=pragmas
|
|
137
|
-
)
|
|
138
|
-
self.dataclass = dataclass
|
|
139
|
-
self.fields = self.get_fields(dataclass)
|
|
140
|
-
self.types = self.get_field_types(dataclass)
|
|
141
|
-
|
|
142
|
-
@staticmethod
|
|
143
|
-
def get_fields(dataclass):
|
|
144
|
-
if dataclasses.is_dataclass(dataclass):
|
|
145
|
-
value_columns = tuple(field.name for field in dataclasses.fields(dataclass))
|
|
146
|
-
else:
|
|
147
|
-
raise ValueError("It is not a dataclass.")
|
|
148
|
-
if "_key" in value_columns:
|
|
149
|
-
raise ValueError("Name _key is not allowed as attribute for dataclass.")
|
|
150
|
-
|
|
151
|
-
return value_columns
|
|
152
|
-
|
|
153
|
-
@classmethod
|
|
154
|
-
def get_field_types(cls, value_class):
|
|
155
|
-
value_columns = cls.get_fields(value_class)
|
|
156
|
-
if hasattr(value_class, "__annotations__") and value_class.__annotations__:
|
|
157
|
-
type_annotations = value_class.__annotations__
|
|
158
|
-
value_types = []
|
|
159
|
-
for c_name in value_columns:
|
|
160
|
-
type_cls = type_annotations.get(c_name, bytes) # types are optional
|
|
161
|
-
sqlite_type = get_sqlite_type(type_cls)
|
|
162
|
-
value_types.append(sqlite_type)
|
|
163
|
-
else:
|
|
164
|
-
value_types = ["BLOB" for _ in value_columns]
|
|
165
|
-
|
|
166
|
-
return tuple(value_types)
|
|
167
|
-
|
|
168
|
-
def dump_value(self, value) -> tuple:
|
|
169
|
-
return dataclasses.astuple(value)
|
|
170
|
-
|
|
171
|
-
def load_data(self, data: tuple) -> Any:
|
|
172
|
-
return self.dataclass(*data)
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
class PydanticConfig(BaseConfig):
|
|
176
|
-
def __init__(
|
|
177
|
-
self, model, tablename=None, key_type=None, timeout=None, pragmas=None
|
|
178
|
-
):
|
|
179
|
-
tablename = model.__name__ if not tablename else tablename
|
|
180
|
-
super().__init__(
|
|
181
|
-
tablename=tablename, key_type=key_type, timeout=timeout, pragmas=pragmas
|
|
182
|
-
)
|
|
183
|
-
self.fields = (("value", "TEXT"),)
|
|
184
|
-
self.model = model
|
|
185
|
-
|
|
186
|
-
def dump_value(self, value):
|
|
187
|
-
return (value.model_dump_json(),)
|
|
188
|
-
|
|
189
|
-
def load_data(self, data):
|
|
190
|
-
return self.model.model_validate_json(data[0])
|
|
@@ -1,264 +0,0 @@
|
|
|
1
|
-
"""DiskStore (MutableMapping) API.
|
|
2
|
-
|
|
3
|
-
Examples:
|
|
4
|
-
>>> from diskstore import DiskStore
|
|
5
|
-
>>> ds = DiskStore("/tmp/data.db")
|
|
6
|
-
>>> ds["one"] = 1
|
|
7
|
-
>>> ds["one"]
|
|
8
|
-
1
|
|
9
|
-
|
|
10
|
-
"""
|
|
11
|
-
|
|
12
|
-
import os
|
|
13
|
-
import os.path
|
|
14
|
-
import threading
|
|
15
|
-
from collections.abc import Mapping, MutableMapping
|
|
16
|
-
from contextlib import closing, contextmanager
|
|
17
|
-
from typing import Any, Iterable
|
|
18
|
-
|
|
19
|
-
import apsw
|
|
20
|
-
|
|
21
|
-
from .config import ConfigProtocol, escape_name, get_sqlite_type
|
|
22
|
-
from .const import DEFAULT_PRAGMAS, MISSING, KeyType
|
|
23
|
-
from .diskread import DiskRead
|
|
24
|
-
|
|
25
|
-
Connection = apsw.Connection
|
|
26
|
-
Cursor = apsw.Cursor
|
|
27
|
-
SQLError = apsw.SQLError
|
|
28
|
-
BusyError = apsw.BusyError
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
class DiskStore(DiskRead, MutableMapping):
|
|
32
|
-
def __init__(
|
|
33
|
-
self, filename: os.PathLike | str, config: ConfigProtocol | None = None
|
|
34
|
-
) -> None:
|
|
35
|
-
"""SQLite based MutableMapping disk storage.
|
|
36
|
-
|
|
37
|
-
Args:
|
|
38
|
-
filename: DiskStore DB filename.
|
|
39
|
-
config: Configuration as specified in ConfigProtocol
|
|
40
|
-
"""
|
|
41
|
-
super().__init__(filename=filename, config=config)
|
|
42
|
-
self._txn_id = None
|
|
43
|
-
tablename = escape_name(self._config.tablename)
|
|
44
|
-
|
|
45
|
-
primary_key_type = get_sqlite_type(self._config.key_type)
|
|
46
|
-
value_columns = self._config.fields
|
|
47
|
-
fields_create = ", ".join(
|
|
48
|
-
self._get_field_create(field) for field in self._config.fields
|
|
49
|
-
)
|
|
50
|
-
fields = ", ".join(f"{escape_name(field)}" for field, *_ in self._config.fields)
|
|
51
|
-
excluded_fields = ", ".join(
|
|
52
|
-
f"{field} = excluded.{escape_name(field)}"
|
|
53
|
-
for field, *_ in self._config.fields
|
|
54
|
-
)
|
|
55
|
-
qms = ", ".join("?" for field in value_columns)
|
|
56
|
-
self._statements.update(
|
|
57
|
-
{
|
|
58
|
-
"CREATE": (
|
|
59
|
-
f"CREATE TABLE IF NOT EXISTS {tablename} ("
|
|
60
|
-
f" _key {primary_key_type} PRIMARY KEY NOT NULL"
|
|
61
|
-
f", {fields_create})"
|
|
62
|
-
),
|
|
63
|
-
"SET": (
|
|
64
|
-
f"INSERT INTO {tablename}(_key, {fields}) VALUES (?, {qms})"
|
|
65
|
-
f" ON CONFLICT (_key) DO UPDATE SET {excluded_fields}"
|
|
66
|
-
),
|
|
67
|
-
"ADD": (
|
|
68
|
-
f"INSERT INTO {tablename}(_key, {fields}) "
|
|
69
|
-
f"VALUES (?, {qms}) ON CONFLICT DO NOTHING RETURNING _key"
|
|
70
|
-
),
|
|
71
|
-
"DELETE": f"DELETE FROM {tablename} WHERE _key = ? RETURNING _key",
|
|
72
|
-
"CLEAR": f"DELETE FROM {tablename};VACUUM;",
|
|
73
|
-
"POPITEM": (
|
|
74
|
-
f"SELECT _key, {fields} FROM {tablename} ORDER BY"
|
|
75
|
-
" rowid DESC LIMIT 1"
|
|
76
|
-
),
|
|
77
|
-
}
|
|
78
|
-
)
|
|
79
|
-
self._pragmas = DEFAULT_PRAGMAS.copy()
|
|
80
|
-
self._pragmas.update(self._config.pragmas)
|
|
81
|
-
self._dump_value = self._config.dump_value
|
|
82
|
-
|
|
83
|
-
@property
|
|
84
|
-
def _con(self) -> Connection:
|
|
85
|
-
# Check process ID to support process forking. If the process
|
|
86
|
-
# ID changes, close the connection and update the process ID.
|
|
87
|
-
|
|
88
|
-
local_pid = getattr(self._local, "pid", None)
|
|
89
|
-
pid: int = os.getpid()
|
|
90
|
-
|
|
91
|
-
if local_pid != pid:
|
|
92
|
-
self.close()
|
|
93
|
-
self._local.pid: int = pid
|
|
94
|
-
|
|
95
|
-
con = getattr(self._local, "con", None)
|
|
96
|
-
|
|
97
|
-
if con is None:
|
|
98
|
-
con = Connection(self._filename)
|
|
99
|
-
con.set_busy_timeout(int(self._timeout * 1000))
|
|
100
|
-
|
|
101
|
-
# Some SQLite pragmas work on a per-connection basis so
|
|
102
|
-
# apply them all on fresh connection
|
|
103
|
-
for key, value in self._pragmas.items():
|
|
104
|
-
con.pragma(key, value)
|
|
105
|
-
con.execute(self._statements["CREATE"])
|
|
106
|
-
self._local.con = con
|
|
107
|
-
|
|
108
|
-
return con
|
|
109
|
-
|
|
110
|
-
@staticmethod
|
|
111
|
-
def _get_field_create(field_tuple):
|
|
112
|
-
field_name, field_type, *field_default = field_tuple
|
|
113
|
-
default = ""
|
|
114
|
-
if field_default and field_default[0] is not None:
|
|
115
|
-
default = " DEFAULT " + apsw.format_sql_value(field_default[0])
|
|
116
|
-
name = escape_name(field_name)
|
|
117
|
-
type_ = get_sqlite_type(field_type)
|
|
118
|
-
field_create = f"{name} {type_} NOT NULL{default}"
|
|
119
|
-
|
|
120
|
-
return field_create
|
|
121
|
-
|
|
122
|
-
def _migrate_table(self, new_fields=()):
|
|
123
|
-
tablename = escape_name(self._config.tablename)
|
|
124
|
-
existing_fileds = set()
|
|
125
|
-
table_info_stmt = f"PRAGMA table_info({tablename});"
|
|
126
|
-
sql = self._con.execute
|
|
127
|
-
with closing(sql(table_info_stmt)) as cursor:
|
|
128
|
-
for row in cursor:
|
|
129
|
-
existing_fileds.add(row[1])
|
|
130
|
-
for columnname, columntype, default_value in new_fields:
|
|
131
|
-
if columnname not in existing_fileds:
|
|
132
|
-
name: str = escape_name(columnname)
|
|
133
|
-
type_ = get_sqlite_type(columntype)
|
|
134
|
-
default = apsw.format_sql_value(default_value)
|
|
135
|
-
alter_stmt = (
|
|
136
|
-
f"ALTER TABLE {tablename} ADD COLUMN {name}"
|
|
137
|
-
f" {type_} NOT NULL DEFAULT {default};"
|
|
138
|
-
)
|
|
139
|
-
with closing(sql(alter_stmt)) as cursor:
|
|
140
|
-
pass
|
|
141
|
-
|
|
142
|
-
@contextmanager
|
|
143
|
-
def transact(self):
|
|
144
|
-
cursor: Cursor = self._con.cursor()
|
|
145
|
-
tid = threading.get_ident()
|
|
146
|
-
txn_id = self._txn_id
|
|
147
|
-
|
|
148
|
-
if tid == txn_id: # already inside a thread with a transaction
|
|
149
|
-
begin = False
|
|
150
|
-
else:
|
|
151
|
-
cursor.execute("BEGIN IMMEDIATE")
|
|
152
|
-
begin = True
|
|
153
|
-
self._txn_id = tid
|
|
154
|
-
|
|
155
|
-
try:
|
|
156
|
-
yield cursor
|
|
157
|
-
except BaseException:
|
|
158
|
-
if begin:
|
|
159
|
-
assert self._txn_id == tid
|
|
160
|
-
self._txn_id = None
|
|
161
|
-
cursor.execute("ROLLBACK")
|
|
162
|
-
cursor.close()
|
|
163
|
-
raise
|
|
164
|
-
else:
|
|
165
|
-
if begin:
|
|
166
|
-
assert self._txn_id == tid
|
|
167
|
-
self._txn_id = None
|
|
168
|
-
cursor.execute("COMMIT")
|
|
169
|
-
cursor.close()
|
|
170
|
-
|
|
171
|
-
def __setitem__(self, key: KeyType, value: Any) -> None:
|
|
172
|
-
with self._cursor() as cursor:
|
|
173
|
-
cursor.execute(self._statements["SET"], (key, *self._dump_value(value)))
|
|
174
|
-
|
|
175
|
-
def add(self, key: KeyType | None, value: Iterable) -> KeyType | None:
|
|
176
|
-
with self._cursor() as cx:
|
|
177
|
-
rows = cx.execute(
|
|
178
|
-
self._statements["ADD"], (key, *self._dump_value(value))
|
|
179
|
-
).fetchall()
|
|
180
|
-
|
|
181
|
-
if not rows:
|
|
182
|
-
return None
|
|
183
|
-
|
|
184
|
-
return rows[0][0]
|
|
185
|
-
|
|
186
|
-
def pop(self, key: KeyType, default=MISSING):
|
|
187
|
-
with self.transact() as cx:
|
|
188
|
-
value = next(cx.execute(self._statements["GET"], (key,)), None)
|
|
189
|
-
if value is None:
|
|
190
|
-
if default is MISSING:
|
|
191
|
-
raise KeyError(key)
|
|
192
|
-
return default
|
|
193
|
-
cx.execute(self._statements["DELETE"], (key,))
|
|
194
|
-
return self._load_data(value)
|
|
195
|
-
|
|
196
|
-
def popitem(self):
|
|
197
|
-
with self.transact() as cx:
|
|
198
|
-
row = next(cx.execute(self._statements["POPITEM"]), None)
|
|
199
|
-
if not row:
|
|
200
|
-
raise KeyError()
|
|
201
|
-
key = row[0]
|
|
202
|
-
value = self._load_data(row[1:])
|
|
203
|
-
cx.execute(self._statements["DELETE"], (key,))
|
|
204
|
-
return key, value
|
|
205
|
-
|
|
206
|
-
def __delitem__(self, key: KeyType) -> None:
|
|
207
|
-
with self._cursor() as cursor:
|
|
208
|
-
rows = cursor.execute(self._statements["DELETE"], (key,)).fetchall()
|
|
209
|
-
if not rows:
|
|
210
|
-
raise KeyError(key)
|
|
211
|
-
|
|
212
|
-
def setdefault(self, key: KeyType, default: Iterable | None = None):
|
|
213
|
-
try:
|
|
214
|
-
return self[key]
|
|
215
|
-
except KeyError:
|
|
216
|
-
if default is not None:
|
|
217
|
-
self.add(key, default)
|
|
218
|
-
|
|
219
|
-
return default
|
|
220
|
-
|
|
221
|
-
def check(self, vacuum=False):
|
|
222
|
-
warns = []
|
|
223
|
-
sql = self._con.execute
|
|
224
|
-
|
|
225
|
-
# Check integrity of database.
|
|
226
|
-
with closing(sql("PRAGMA integrity_check")) as cx:
|
|
227
|
-
rows = cx.fetchall()
|
|
228
|
-
|
|
229
|
-
if len(rows) != 1 or rows[0][0] != "ok":
|
|
230
|
-
for (message,) in rows:
|
|
231
|
-
warns.append(message)
|
|
232
|
-
|
|
233
|
-
if vacuum:
|
|
234
|
-
with closing(sql("VACUUM")):
|
|
235
|
-
pass
|
|
236
|
-
|
|
237
|
-
return warns
|
|
238
|
-
|
|
239
|
-
def clear(self) -> None:
|
|
240
|
-
with closing(self._con.execute(self._statements["CLEAR"])):
|
|
241
|
-
pass
|
|
242
|
-
|
|
243
|
-
def update(self, other=(), /, **kwargs):
|
|
244
|
-
comps = []
|
|
245
|
-
if other:
|
|
246
|
-
if isinstance(other, Mapping):
|
|
247
|
-
comp = ((key, *self._dump_value(value)) for key, value in other.items())
|
|
248
|
-
comps.append(comp)
|
|
249
|
-
elif hasattr(other, "keys"):
|
|
250
|
-
comp = ((key, *self._dump_value(other[key])) for key in other.keys())
|
|
251
|
-
comps.append(comp)
|
|
252
|
-
else:
|
|
253
|
-
comp = ((key, *self._dump_value(value)) for key, value in other)
|
|
254
|
-
comps.append(comp)
|
|
255
|
-
if kwargs:
|
|
256
|
-
comp = ((key, *self._dump_value(value)) for key, value in kwargs.items())
|
|
257
|
-
comps.append(comp)
|
|
258
|
-
|
|
259
|
-
with self.transact() as cursor:
|
|
260
|
-
for comp in comps:
|
|
261
|
-
cursor.executemany(self._statements["SET"], comp)
|
|
262
|
-
|
|
263
|
-
def get_readonly_instance(self):
|
|
264
|
-
return DiskRead(self._filename, self._config)
|
|
File without changes
|
|
File without changes
|