diskstore 0.1.0__tar.gz → 0.2.0__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,10 +1,10 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: diskstore
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: Simple MutableMapping compatible SQLite based disk storage.
5
5
  Author: tds333
6
6
  Author-email: tds333 <tds333@mailbox.org>
7
- Requires-Dist: apsw
7
+ Requires-Dist: apsw>=3.51.3
8
8
  Requires-Python: >=3.10
9
9
  Description-Content-Type: text/markdown
10
10
 
@@ -15,8 +15,8 @@ in pure Python.
15
15
 
16
16
  Inspired by DiskCache library it implements a MutableMapping compatible sqlite
17
17
  based disk storage. Easy interface and very fast. Keys can be of basic sqlite storage
18
- classes defined by Python types `int`, `float`, `str`, `bytes`. Value is a `Namedtuple` class
19
- with same basic Python types.
18
+ classes defined by Python types `int`, `float`, `str`, `bytes`. Values can be of basic sqlite
19
+ type or with custom configuration.
20
20
 
21
21
 
22
22
  ## Features
@@ -51,10 +51,10 @@ Example:
51
51
 
52
52
  ```python
53
53
 
54
- from diskstore import DiskStore, Value
54
+ from diskstore import DiskStore
55
55
 
56
- ds = DiskStore("/tmp/diskstore.db", value_class=Value)
57
- ds["key"] = Value("my value")
56
+ ds = DiskStore("/tmp/diskstore.db")
57
+ ds["key"] = "my value"
58
58
  print(ds["key"])
59
59
 
60
60
  ```
@@ -5,8 +5,8 @@ in pure Python.
5
5
 
6
6
  Inspired by DiskCache library it implements a MutableMapping compatible sqlite
7
7
  based disk storage. Easy interface and very fast. Keys can be of basic sqlite storage
8
- classes defined by Python types `int`, `float`, `str`, `bytes`. Value is a `Namedtuple` class
9
- with same basic Python types.
8
+ classes defined by Python types `int`, `float`, `str`, `bytes`. Values can be of basic sqlite
9
+ type or with custom configuration.
10
10
 
11
11
 
12
12
  ## Features
@@ -41,10 +41,10 @@ Example:
41
41
 
42
42
  ```python
43
43
 
44
- from diskstore import DiskStore, Value
44
+ from diskstore import DiskStore
45
45
 
46
- ds = DiskStore("/tmp/diskstore.db", value_class=Value)
47
- ds["key"] = Value("my value")
46
+ ds = DiskStore("/tmp/diskstore.db")
47
+ ds["key"] = "my value"
48
48
  print(ds["key"])
49
49
 
50
50
  ```
@@ -0,0 +1,80 @@
1
+ [project]
2
+ name = "diskstore"
3
+ version = "0.2.0"
4
+ description = "Simple MutableMapping compatible SQLite based disk storage."
5
+ readme = "README.md"
6
+ requires-python = ">=3.10"
7
+ authors = [{ name = "tds333", email = "tds333@mailbox.org" }]
8
+ dependencies = [
9
+ "apsw>=3.51.3",
10
+ ]
11
+
12
+ [dependency-groups]
13
+ dev = [
14
+ "pytest",
15
+ "pytest-asyncio>=1.3.0",
16
+ "pytest-cov",
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",
27
+ ]
28
+ docs = [
29
+ "mkdocs>=1.6.1",
30
+ "mkdocstrings[python]>=1.0.3",
31
+ "zensical>=0.0.23",
32
+ ]
33
+ ext-dev = [
34
+ "msgspec>=0.20.0",
35
+ "pydantic>=2.12.5",
36
+ ]
37
+
38
+ [build-system]
39
+ requires = ["uv_build>=0.9.2,<0.12.0"]
40
+ build-backend = "uv_build"
41
+
42
+ [tool.coverage.run]
43
+ parallel = true
44
+ branch = true
45
+ source = ["diskstore"]
46
+
47
+ [tool.coverage.report]
48
+ show_missing = true
49
+ skip_covered = true
50
+ exclude_lines = ["no cov", "if __name__ == .__main__.:", "if TYPE_CHECKING:"]
51
+
52
+ [tool.pytest]
53
+ # pythonpath = ["src"]
54
+ testpaths = ["tests"]
55
+
56
+ [tool.ruff]
57
+ exclude = [".env", ".venv", "venv"]
58
+ line-length = 88
59
+
60
+ [tool.ruff.lint]
61
+ select = [
62
+ "E", # pycodestyle errors
63
+ "W", # pycodestyle warnings
64
+ "F", # pyflakes
65
+ "I", # isort
66
+ "C", # flake8-comprehensions
67
+ "B", # flake8-bugbear
68
+ "PT", # flake8-pytest
69
+ "N", # naming
70
+ # "D", # docu
71
+ "PL", # pylint
72
+ "RUF", # ruff
73
+ ]
74
+ ignore = [
75
+ # "E501", # line too long, handled by black
76
+ "B008", # do not perform function calls in argument defaults
77
+ "C901", # too complex
78
+ "RET504",
79
+ "PLR2004",
80
+ ]
@@ -0,0 +1,2 @@
1
+ from .diskread import DiskRead # noqa
2
+ from .diskstore import DiskStore # noqa
@@ -0,0 +1,190 @@
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,11 +1,12 @@
1
- """Core disktore API."""
1
+ """Default constants used in diskstore."""
2
2
 
3
3
  from typing import (
4
4
  TypeAlias,
5
5
  Union,
6
6
  )
7
7
 
8
- TIMEOUT = 60.0 # in seconds
8
+ TIMEOUT = 60.0
9
+ "default timout in seconds"
9
10
 
10
11
  MISSING = object()
11
12
 
@@ -19,6 +20,7 @@ DEFAULT_PRAGMAS = {
19
20
  "temp_store": "memory",
20
21
  # "pargma_busy_timeout": 5000, # milliseconds
21
22
  }
23
+ "default pragma settings"
22
24
 
23
25
 
24
26
  AnyLite: TypeAlias = Union[bytes, str, int, float]
@@ -1,14 +1,24 @@
1
- """DiskRead (Mapping) API."""
1
+ """DiskRead (Mapping) API.
2
+
3
+ Examples:
4
+ >>> from diskstore import DiskRead
5
+ # DB must exist!
6
+ >>> ds = DiskRead("/tmp/data.db")
7
+ >>> ds["one"]
8
+ 1
9
+
10
+ """
2
11
 
3
12
  import os
4
13
  import os.path
5
14
  import threading
6
15
  from collections.abc import ItemsView, KeysView, Mapping, ValuesView
7
16
  from contextlib import closing, contextmanager
8
- from typing import Generator, NamedTuple, Optional, Sequence, TypeAlias, Union
17
+ from typing import Generator, Optional, Sequence, TypeAlias, Union
9
18
 
10
19
  import apsw
11
20
 
21
+ from .config import BaseConfig, ConfigProtocol, escape_name
12
22
  from .const import MISSING, TIMEOUT, KeyType
13
23
 
14
24
  Connection = apsw.Connection
@@ -18,31 +28,6 @@ Cursor = apsw.Cursor
18
28
  BasicType: TypeAlias = Union[bytes, str, int, float]
19
29
 
20
30
 
21
- class Value(NamedTuple):
22
- value: BasicType
23
-
24
- def __float__(self):
25
- data = self[0]
26
- if isinstance(data, float):
27
- return data
28
- raise ValueError("Internal value is not an instance of float.")
29
-
30
- def __int__(self):
31
- data = self[0]
32
- if isinstance(data, int):
33
- return data
34
- raise ValueError("Internal value is not an instance of int.")
35
-
36
- def __bytes__(self):
37
- data = self[0]
38
- if isinstance(data, bytes):
39
- return data
40
- raise ValueError("Internal value is not an instance of bytes.")
41
-
42
- def __str__(self):
43
- return str(self[0])
44
-
45
-
46
31
  class DiskKeysView(KeysView):
47
32
  __slots__ = ()
48
33
 
@@ -76,44 +61,35 @@ class DiskItemsView(ItemsView):
76
61
 
77
62
 
78
63
  class DiskRead(Mapping):
79
- """SQLite read only disk storage. Does not connect on init!"""
80
-
81
- def __init__( # noqa: PLR0915
64
+ def __init__(
82
65
  self,
83
66
  filename: os.PathLike | str,
84
- value_class=None,
85
- tablename: str | None = None,
86
- timeout=None,
67
+ config: ConfigProtocol | None = None,
87
68
  ) -> None:
88
- """Initialize cache instance.
89
-
90
- :param str filename: filename for DB to use.
91
- :param str tablename: Optional table name to use.
92
- :param type value_class: Class type (inherited from NamedTuple)
93
- used to get back data.
94
- :param float timeout: SQLite connection timeout
95
- (also used if DB is busy to retry)
69
+ """SQLite read only disk storage.
70
+
71
+ Database is opened read only on demand.
72
+
73
+ Args:
74
+ filename: filename for DB to use.
75
+ config: Configuration
76
+
96
77
  """
97
78
  filename = os.path.expanduser(filename)
98
79
  filename = os.path.expandvars(filename)
99
- if value_class is None:
100
- self._value_class = Value
101
- else:
102
- self._value_class = value_class
103
- self._tablename = (
104
- self._value_class.__name__
105
- if tablename is None or tablename == "_Settings"
106
- else tablename
107
- )
108
-
109
80
  self._filename = os.fspath(filename)
110
- self._timeout: float = TIMEOUT if timeout is None else float(timeout)
81
+ self._config: ConfigProtocol = BaseConfig() if config is None else config
82
+ self._timeout: float = (
83
+ TIMEOUT
84
+ if (self._config.timeout is None or self._config.timeout < 0.0)
85
+ else float(self._config.timeout)
86
+ )
87
+ self._load_data = self._config.load_data
111
88
  self._local = threading.local()
112
89
 
113
90
  # precreated statements based on tablename and value_class
114
- tablename = self._escape_table_name(self._tablename)
115
- value_columns = self.get_fields(self._value_class)
116
- fields = ", ".join(f"{field}" for field in value_columns)
91
+ tablename = escape_name(self._config.tablename)
92
+ fields = ", ".join(f"{escape_name(field)}" for field, *_ in self._config.fields)
117
93
  self._statements: dict[str, str] = {
118
94
  "GET": f"SELECT {fields} FROM {tablename} WHERE _key = ? LIMIT 1",
119
95
  "CONTAINS": f"SELECT _key FROM {tablename} WHERE _key = ? LIMIT 1",
@@ -123,22 +99,6 @@ class DiskRead(Mapping):
123
99
  "QUERY": f"SELECT _key, {fields} FROM {tablename}",
124
100
  }
125
101
 
126
- @staticmethod
127
- def get_fields(value_class):
128
- value_columns = tuple(value_class._fields)
129
- if "_key" in value_columns:
130
- raise ValueError(
131
- f"Name _key is not allowed as attribute for {value_class},"
132
- " listed as field name in _fields."
133
- )
134
-
135
- return value_columns
136
-
137
- @staticmethod
138
- def _escape_table_name(name: str) -> str:
139
- tablename = '"' + name.replace('"', '""') + '"'
140
- return tablename
141
-
142
102
  @property
143
103
  def filename(self) -> str:
144
104
  """DiskStore filename for DB."""
@@ -151,7 +111,8 @@ class DiskRead(Mapping):
151
111
 
152
112
  @property
153
113
  def tablename(self) -> str:
154
- return self._tablename
114
+ """Tablename used to get data from."""
115
+ return self._config.tablename
155
116
 
156
117
  @property
157
118
  def _con(self) -> Connection:
@@ -191,7 +152,7 @@ class DiskRead(Mapping):
191
152
  if row is MISSING:
192
153
  raise KeyError(key)
193
154
 
194
- return self._value_class(*row) # ty:ignore[not-iterable]
155
+ return self._load_data(row) # ty:ignore[invalid-argument-type]
195
156
 
196
157
  def keys(self):
197
158
  return DiskKeysView(self)
@@ -207,16 +168,20 @@ class DiskRead(Mapping):
207
168
  where: Optional[str] = None,
208
169
  parameters: Optional[Sequence] = None,
209
170
  order: Optional[str] = None,
171
+ limit: int | None = None,
172
+ offset: int | None = None,
210
173
  ) -> Generator[tuple, None, None]:
211
174
  where = " WHERE " + where if where else ""
212
175
  parameters = () if parameters is None else parameters
213
176
  order = " ORDER BY " + order if order else ""
214
- select = self._statements["QUERY"] + where + order
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
215
180
 
216
181
  with self._cursor() as cursor:
217
182
  cursor.execute(select, parameters)
218
183
  for row in cursor:
219
- yield row[0], self._value_class(*row[1:])
184
+ yield row[0], self._load_data(row[1:])
220
185
 
221
186
  def __contains__(self, key: object) -> bool:
222
187
  with self._cursor() as cx:
@@ -236,6 +201,10 @@ class DiskRead(Mapping):
236
201
  for row in cx:
237
202
  yield row[0]
238
203
 
204
+ def open(self):
205
+ connection = self._con # noqa
206
+ return self
207
+
239
208
  def close(self) -> None:
240
209
  con = getattr(self._local, "con", None)
241
210
  if con is None:
@@ -263,9 +232,7 @@ class DiskRead(Mapping):
263
232
  def __getstate__(self):
264
233
  return {
265
234
  "filename": self.filename,
266
- "timeout": self.timeout,
267
- "tablename": self._tablename,
268
- "value_class": self._value_class,
235
+ "config": self._config,
269
236
  }
270
237
 
271
238
  def __setstate__(self, state) -> None:
@@ -1,16 +1,25 @@
1
- """DiskStore (MutableMapping) API."""
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
+ """
2
11
 
3
12
  import os
4
13
  import os.path
5
14
  import threading
6
15
  from collections.abc import Mapping, MutableMapping
7
16
  from contextlib import closing, contextmanager
8
- from time import sleep, time
9
- from typing import Iterable
17
+ from typing import Any, Iterable
10
18
 
11
19
  import apsw
12
20
 
13
- from .const import DEFAULT_PRAGMAS, MISSING, TIMEOUT, KeyType
21
+ from .config import ConfigProtocol, escape_name, get_sqlite_type
22
+ from .const import DEFAULT_PRAGMAS, MISSING, KeyType
14
23
  from .diskread import DiskRead
15
24
 
16
25
  Connection = apsw.Connection
@@ -20,55 +29,28 @@ BusyError = apsw.BusyError
20
29
 
21
30
 
22
31
  class DiskStore(DiskRead, MutableMapping):
23
- """SQLite disk storage."""
24
-
25
- def __init__( # noqa: PLR0915
26
- self,
27
- filename: os.PathLike | str,
28
- value_class=None,
29
- tablename: str | None = None,
30
- key_type=None,
31
- timeout: float | None = None,
32
- alter_table: bool = True,
33
- **pragmas,
32
+ def __init__(
33
+ self, filename: os.PathLike | str, config: ConfigProtocol | None = None
34
34
  ) -> None:
35
- """Initialize Diskstore instance.
36
-
37
- :param str filename: DiskStore DB filename.
38
- If not set a tmp directory and default DB name is created.
39
- :param str tablename: Optional table name to use.
40
- If not set the name from value_class is used.
41
- :param type value_class: Class type (inherited from NamedTuple)
42
- used to get back data. Default is Value class.
43
- :param float timeout: SQLite connection timeout
44
- (also used if DB is busy to retry)
45
- :param pragmas: Any of DEFAULT_PRAGMAS or additional PRAGMA's to apply
35
+ """SQLite based MutableMapping disk storage.
36
+
37
+ Args:
38
+ filename: DiskStore DB filename.
39
+ config: Configuration as specified in ConfigProtocol
46
40
  """
47
- super().__init__(
48
- filename=filename,
49
- value_class=value_class,
50
- tablename=tablename,
51
- timeout=timeout,
52
- )
41
+ super().__init__(filename=filename, config=config)
53
42
  self._txn_id = None
54
- tablename = self._escape_table_name(self._tablename)
55
-
56
- primary_key_type = "BLOB"
57
- if key_type is str:
58
- primary_key_type = "TEXT"
59
- elif key_type is int:
60
- primary_key_type = "INTEGER"
61
- elif key_type is float:
62
- primary_key_type = "REAL"
63
- value_columns = self.get_fields(self._value_class)
64
- column_types = self.get_field_types(self._value_class)
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
65
47
  fields_create = ", ".join(
66
- f"{field} {ctype} NOT NULL"
67
- for field, ctype in zip(value_columns, column_types, strict=True)
48
+ self._get_field_create(field) for field in self._config.fields
68
49
  )
69
- fields = ", ".join(f"{field}" for field in value_columns)
50
+ fields = ", ".join(f"{escape_name(field)}" for field, *_ in self._config.fields)
70
51
  excluded_fields = ", ".join(
71
- f"{field} = excluded.{field}" for field in value_columns
52
+ f"{field} = excluded.{escape_name(field)}"
53
+ for field, *_ in self._config.fields
72
54
  )
73
55
  qms = ", ".join("?" for field in value_columns)
74
56
  self._statements.update(
@@ -95,43 +77,8 @@ class DiskStore(DiskRead, MutableMapping):
95
77
  }
96
78
  )
97
79
  self._pragmas = DEFAULT_PRAGMAS.copy()
98
- self._pragmas.update(pragmas)
99
- sql = self._con.execute
100
- # Setup table.
101
- with closing(sql(self._statements["CREATE"])):
102
- pass
103
- # try alter table if needed
104
- if alter_table:
105
- self._alter_table(sql)
106
-
107
- @staticmethod
108
- def get_field_types(value_class):
109
- value_columns = tuple(value_class._fields)
110
- if hasattr(value_class, "__annotations__") and value_class.__annotations__:
111
- type_annotations = value_class.__annotations__
112
- value_types = []
113
- for c_name in value_columns:
114
- type_cls = type_annotations.get(c_name, bytes) # types are optional
115
- if type_cls is str:
116
- value_types.append("TEXT")
117
- elif type_cls is int:
118
- value_types.append("INTEGER")
119
- elif type_cls is float:
120
- value_types.append("REAL")
121
- else:
122
- value_types.append("BLOB")
123
- else:
124
- value_types = ["BLOB" for _ in value_columns]
125
-
126
- return value_types
127
-
128
- def _validate_value(self, value):
129
- if isinstance(value, self._value_class):
130
- return value
131
- value_type = type(value)
132
- raise ValueError(
133
- f"Value must be instance of {self._value_class} but is {value_type}."
134
- )
80
+ self._pragmas.update(self._config.pragmas)
81
+ self._dump_value = self._config.dump_value
135
82
 
136
83
  @property
137
84
  def _con(self) -> Connection:
@@ -148,57 +95,62 @@ class DiskStore(DiskRead, MutableMapping):
148
95
  con = getattr(self._local, "con", None)
149
96
 
150
97
  if con is None:
151
- con = self._local.con = Connection(self._filename)
98
+ con = Connection(self._filename)
152
99
  con.set_busy_timeout(int(self._timeout * 1000))
153
100
 
154
101
  # Some SQLite pragmas work on a per-connection basis so
155
102
  # apply them all on fresh connection
156
103
  for key, value in self._pragmas.items():
157
104
  con.pragma(key, value)
105
+ con.execute(self._statements["CREATE"])
106
+ self._local.con = con
158
107
 
159
108
  return con
160
109
 
161
- def _alter_table(self, sql):
162
- value_columns = self.get_fields(self._value_class)
163
- column_types = self.get_field_types(self._value_class)
164
- tablename = self._escape_table_name(self._tablename)
165
- value_column_defaults = getattr(self._value_class, "_field_defaults", {})
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)
166
124
  existing_fileds = set()
167
125
  table_info_stmt = f"PRAGMA table_info({tablename});"
126
+ sql = self._con.execute
168
127
  with closing(sql(table_info_stmt)) as cursor:
169
128
  for row in cursor:
170
129
  existing_fileds.add(row[1])
171
- for columnname, columntype in zip(value_columns, column_types, strict=True):
130
+ for columnname, columntype, default_value in new_fields:
172
131
  if columnname not in existing_fileds:
173
- default = apsw.format_sql_value(value_column_defaults[columnname])
132
+ name: str = escape_name(columnname)
133
+ type_ = get_sqlite_type(columntype)
134
+ default = apsw.format_sql_value(default_value)
174
135
  alter_stmt = (
175
- f"ALTER TABLE {tablename} ADD COLUMN {columnname}"
176
- f" {columntype} NOT NULL DEFAULT {default};"
136
+ f"ALTER TABLE {tablename} ADD COLUMN {name}"
137
+ f" {type_} NOT NULL DEFAULT {default};"
177
138
  )
178
139
  with closing(sql(alter_stmt)) as cursor:
179
140
  pass
180
141
 
181
142
  @contextmanager
182
- def transact(self, retry=False):
143
+ def transact(self):
183
144
  cursor: Cursor = self._con.cursor()
184
145
  tid = threading.get_ident()
185
146
  txn_id = self._txn_id
186
- retry_until = time() + TIMEOUT
187
147
 
188
148
  if tid == txn_id: # already inside a thread with a transaction
189
149
  begin = False
190
150
  else:
191
- while True:
192
- try:
193
- cursor.execute("BEGIN IMMEDIATE")
194
- begin = True
195
- self._txn_id = tid
196
- break
197
- except BusyError:
198
- if retry and time() < retry_until:
199
- sleep(0.001)
200
- continue
201
- raise
151
+ cursor.execute("BEGIN IMMEDIATE")
152
+ begin = True
153
+ self._txn_id = tid
202
154
 
203
155
  try:
204
156
  yield cursor
@@ -216,38 +168,38 @@ class DiskStore(DiskRead, MutableMapping):
216
168
  cursor.execute("COMMIT")
217
169
  cursor.close()
218
170
 
219
- def __setitem__(self, key: KeyType, value: Iterable) -> None:
220
- value = self._validate_value(value)
171
+ def __setitem__(self, key: KeyType, value: Any) -> None:
221
172
  with self._cursor() as cursor:
222
- cursor.execute(self._statements["SET"], (key, *value))
173
+ cursor.execute(self._statements["SET"], (key, *self._dump_value(value)))
223
174
 
224
175
  def add(self, key: KeyType | None, value: Iterable) -> KeyType | None:
225
- value = self._validate_value(value)
226
176
  with self._cursor() as cx:
227
- rows = cx.execute(self._statements["ADD"], (key, *value)).fetchall()
177
+ rows = cx.execute(
178
+ self._statements["ADD"], (key, *self._dump_value(value))
179
+ ).fetchall()
228
180
 
229
181
  if not rows:
230
182
  return None
231
183
 
232
184
  return rows[0][0]
233
185
 
234
- def pop(self, key: KeyType, default=MISSING): # noqa: E501
235
- with self.transact(retry=True) as cx:
186
+ def pop(self, key: KeyType, default=MISSING):
187
+ with self.transact() as cx:
236
188
  value = next(cx.execute(self._statements["GET"], (key,)), None)
237
189
  if value is None:
238
190
  if default is MISSING:
239
191
  raise KeyError(key)
240
192
  return default
241
193
  cx.execute(self._statements["DELETE"], (key,))
242
- return self._value_class(*value)
194
+ return self._load_data(value)
243
195
 
244
196
  def popitem(self):
245
- with self.transact(retry=True) as cx:
197
+ with self.transact() as cx:
246
198
  row = next(cx.execute(self._statements["POPITEM"]), None)
247
199
  if not row:
248
200
  raise KeyError()
249
201
  key = row[0]
250
- value = self._value_class(*row[1:])
202
+ value = self._load_data(row[1:])
251
203
  cx.execute(self._statements["DELETE"], (key,))
252
204
  return key, value
253
205
 
@@ -262,7 +214,6 @@ class DiskStore(DiskRead, MutableMapping):
262
214
  return self[key]
263
215
  except KeyError:
264
216
  if default is not None:
265
- default = self._validate_value(default)
266
217
  self.add(key, default)
267
218
 
268
219
  return default
@@ -289,27 +240,25 @@ class DiskStore(DiskRead, MutableMapping):
289
240
  with closing(self._con.execute(self._statements["CLEAR"])):
290
241
  pass
291
242
 
292
- def update(self, other=(), /, **kwargs): # type: ignore
243
+ def update(self, other=(), /, **kwargs):
293
244
  comps = []
294
245
  if other:
295
246
  if isinstance(other, Mapping):
296
- comp = ((key, *value) for key, value in other.items())
247
+ comp = ((key, *self._dump_value(value)) for key, value in other.items())
297
248
  comps.append(comp)
298
249
  elif hasattr(other, "keys"):
299
- comp = ((key, *other[key]) for key in other.keys())
250
+ comp = ((key, *self._dump_value(other[key])) for key in other.keys())
300
251
  comps.append(comp)
301
252
  else:
302
- comp = ((key, *value) for key, value in other)
253
+ comp = ((key, *self._dump_value(value)) for key, value in other)
303
254
  comps.append(comp)
304
255
  if kwargs:
305
- comp = ((key, *value) for key, value in kwargs.items())
256
+ comp = ((key, *self._dump_value(value)) for key, value in kwargs.items())
306
257
  comps.append(comp)
307
258
 
308
- with self.transact(retry=True) as cursor:
259
+ with self.transact() as cursor:
309
260
  for comp in comps:
310
261
  cursor.executemany(self._statements["SET"], comp)
311
262
 
312
263
  def get_readonly_instance(self):
313
- return DiskRead(
314
- self._filename, self._value_class, self._tablename, self._timeout
315
- )
264
+ return DiskRead(self._filename, self._config)
@@ -1,67 +0,0 @@
1
- [project]
2
- name = "diskstore"
3
- version = "0.1.0"
4
- description = "Simple MutableMapping compatible SQLite based disk storage."
5
- readme = "README.md"
6
- authors = [{ name = "tds333", email = "tds333@mailbox.org" }]
7
- requires-python = ">=3.10"
8
- dependencies = ["apsw"]
9
-
10
-
11
- [build-system]
12
- requires = ["uv_build>=0.9.2,<0.11.0"]
13
- build-backend = "uv_build"
14
-
15
-
16
- [tool.coverage.run]
17
- parallel = true
18
- branch = true
19
- source = ["diskstore"]
20
-
21
-
22
- [tool.coverage.report]
23
- show_missing = true
24
- skip_covered = true
25
- exclude_lines = ["no cov", "if __name__ == .__main__.:", "if TYPE_CHECKING:"]
26
-
27
-
28
- [tool.pytest.ini_options]
29
- #pythonpath = ["src"]
30
- testpaths = ["tests"]
31
-
32
- [tool.ruff]
33
- exclude = [".env", ".venv", "venv"]
34
- line-length = 88
35
- [tool.ruff.lint]
36
- select = [
37
- "E", # pycodestyle errors
38
- "W", # pycodestyle warnings
39
- "F", # pyflakes
40
- "I", # isort
41
- "C", # flake8-comprehensions
42
- "B", # flake8-bugbear
43
- "PT", # flake8-pytest
44
- "N", # naming
45
- #"D", # docu
46
- "PL", # pylint
47
- "R", # ruff
48
-
49
- ]
50
- ignore = [
51
- #"E501", # line too long, handled by black
52
- "B008", # do not perform function calls in argument defaults
53
- "C901", # too complex
54
- "RET504",
55
- ]
56
-
57
- [dependency-groups]
58
- dev = ["pytest", "pytest-cov", "pytest-env", "pytest-xdist"]
59
-
60
- benchmark = [
61
- "ipython",
62
- "pickleDB<1",
63
- "pylibmc",
64
- "sqlitedict",
65
- "redis",
66
- "diskcache",
67
- ]
@@ -1,4 +0,0 @@
1
- from .const import DEFAULT_PRAGMAS, TIMEOUT, AnyLite, KeyType # noqa
2
- from .diskread import DiskRead, Value # noqa
3
- from .diskstore import DiskStore # noqa
4
- from .diskcache import DiskCache # noqa
@@ -1,136 +0,0 @@
1
- """Core disktore API."""
2
-
3
- import os
4
- import os.path
5
- import pickle
6
- from time import time
7
- from typing import (
8
- NamedTuple,
9
- )
10
-
11
- from .const import MISSING, TIMEOUT, KeyType
12
- from .diskstore import DiskStore
13
-
14
-
15
- class CacheValue(NamedTuple):
16
- value: bytes
17
- expires_at: float
18
-
19
- def load(self, expire_timestamp, default=MISSING):
20
- expires_at = self[1]
21
- if expires_at == 0.0 or expires_at > time():
22
- return pickle.loads(self[0])
23
- return default
24
-
25
- @classmethod
26
- def create(cls, value, expires=None):
27
- expires_at = 0.0
28
- if expires:
29
- expires_at = time() + expires
30
- return cls(value=pickle.dumps(value), expires_at=expires_at)
31
-
32
-
33
- class DiskCache:
34
- def __init__(
35
- self,
36
- filename: os.PathLike | str,
37
- default_expiration=60.0,
38
- ) -> None:
39
- """Initialize DiskCache instance.
40
-
41
- :param str filename: DiskStore DB filename.
42
- If not set a tmp directory and default DB name is created.
43
- :param float default_expiration: Default expiration value in seconds (float)
44
- """
45
- self._diskstore = DiskStore(
46
- filename=filename,
47
- value_class=CacheValue,
48
- tablename="Cache",
49
- timeout=TIMEOUT,
50
- pragma_auto_vacuum=1,
51
- pragma_synchronous=0,
52
- alter_table=False,
53
- )
54
- self._default_expiration = default_expiration
55
-
56
- def delete(self, key: KeyType) -> bool:
57
- bin_key = pickle.dumps(key)
58
- try:
59
- del self._diskstore[bin_key]
60
- return True
61
- except KeyError:
62
- return False
63
-
64
- def set(self, key, value, expire=None):
65
- bin_key = pickle.dumps(key)
66
- if expire is None:
67
- expire = self._default_expiration
68
- v = CacheValue.create(value, expire)
69
- self._diskstore[bin_key] = v
70
-
71
- def get(
72
- self,
73
- key: KeyType,
74
- default=None,
75
- ):
76
- bin_key = pickle.dumps(key)
77
- try:
78
- result = self._diskstore[bin_key]
79
- except KeyError:
80
- return default
81
-
82
- return result.load(expire_timestamp=time(), default=default)
83
-
84
- def add(self, key, value, expire=None):
85
- bin_key = pickle.dumps(key)
86
- v = CacheValue.create(value, expire)
87
- ret_key = self._diskstore.add(bin_key, v)
88
- if ret_key is None:
89
- return False
90
-
91
- return True
92
-
93
- def delete_expired(self):
94
- delete_expired = "DELETE FROM Cache WHERE expires_at != 0.0 AND expires_at < ?"
95
- with self._diskstore.transact() as cx:
96
- cx.execute(delete_expired, (time(),))
97
-
98
- def touch(self, key, expire=None):
99
- bin_key = pickle.dumps(key)
100
- if expire is None:
101
- expire = self._default_expiration
102
- expires_at = time() + expire if expire else 0.0
103
- stmt = "UPDATE Cache SET expires_at = ? WHERE _key = ?"
104
- with self._diskstore.transact() as cx:
105
- cx.execute(stmt, (expires_at, bin_key))
106
-
107
- @property
108
- def filename(self):
109
- return self._diskstore.filename
110
-
111
- def check(self, *args, **kwargs):
112
- return self._diskstore.check(*args, **kwargs)
113
-
114
- def clear(self):
115
- self._diskstore.clear()
116
-
117
- def close(self):
118
- self._diskstore.close()
119
-
120
- def __enter__(self):
121
- self._diskstore.__enter__()
122
- return self
123
-
124
- def __exit__(self, *args, **kwargs) -> None:
125
- self._diskstore.__exit__(*args, **kwargs)
126
-
127
- def __len__(self):
128
- return len(self._diskstore)
129
-
130
- def __iter__(self):
131
- for key in self._diskstore:
132
- yield pickle.loads(key)
133
-
134
- def __reversed__(self):
135
- for key in reversed(self._diskstore):
136
- yield pickle.loads(key)