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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: diskstore
3
- Version: 0.2.0
3
+ Version: 0.3.1
4
4
  Summary: Simple MutableMapping compatible SQLite based disk storage.
5
5
  Author: tds333
6
6
  Author-email: tds333 <tds333@mailbox.org>
@@ -1,42 +1,29 @@
1
1
  [project]
2
2
  name = "diskstore"
3
- version = "0.2.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,<0.12.0"]
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 = 60.0
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": "memory",
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, contextmanager
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: int = 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
- return con
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
- @contextmanager
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._cursor() as cursor:
150
- row = next(cursor.execute(select, (key,)), MISSING)
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
- where = " WHERE " + where if where else ""
175
- parameters = () if parameters is None else parameters
176
- order = " ORDER BY " + order if order else ""
177
- limit = " LIMIT " + str(limit) if limit is not None else ""
178
- offset = " OFFSET " + str(offset) if offset is not None else ""
179
- select = self._statements["QUERY"] + where + order + limit + offset
180
-
181
- with self._cursor() as cursor:
182
- cursor.execute(select, parameters)
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[1:])
206
+ yield row[0], self._load_data(row)
185
207
 
186
208
  def __contains__(self, key: object) -> bool:
187
- with self._cursor() as cx:
188
- rows = cx.execute(self._statements["CONTAINS"], (key,)).fetchall()
189
-
190
- return bool(rows)
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
- with self._cursor() as cx:
194
- cx.execute(self._statements["ITER"])
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
- with self._cursor() as cx:
200
- cx.execute(self._statements["REVERSED"])
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