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.
- {diskstore-0.1.0 → diskstore-0.2.0}/PKG-INFO +7 -7
- {diskstore-0.1.0 → diskstore-0.2.0}/README.md +5 -5
- diskstore-0.2.0/pyproject.toml +80 -0
- diskstore-0.2.0/src/diskstore/__init__.py +2 -0
- diskstore-0.2.0/src/diskstore/config.py +190 -0
- {diskstore-0.1.0 → diskstore-0.2.0}/src/diskstore/const.py +4 -2
- {diskstore-0.1.0 → diskstore-0.2.0}/src/diskstore/diskread.py +45 -78
- {diskstore-0.1.0 → diskstore-0.2.0}/src/diskstore/diskstore.py +76 -127
- diskstore-0.1.0/pyproject.toml +0 -67
- diskstore-0.1.0/src/diskstore/__init__.py +0 -4
- diskstore-0.1.0/src/diskstore/diskcache.py +0 -136
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: diskstore
|
|
3
|
-
Version: 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`.
|
|
19
|
-
|
|
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
|
|
54
|
+
from diskstore import DiskStore
|
|
55
55
|
|
|
56
|
-
ds = DiskStore("/tmp/diskstore.db"
|
|
57
|
-
ds["key"] =
|
|
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`.
|
|
9
|
-
|
|
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
|
|
44
|
+
from diskstore import DiskStore
|
|
45
45
|
|
|
46
|
-
ds = DiskStore("/tmp/diskstore.db"
|
|
47
|
-
ds["key"] =
|
|
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,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
|
-
"""
|
|
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
|
|
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,
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
def __init__( # noqa: PLR0915
|
|
64
|
+
def __init__(
|
|
82
65
|
self,
|
|
83
66
|
filename: os.PathLike | str,
|
|
84
|
-
|
|
85
|
-
tablename: str | None = None,
|
|
86
|
-
timeout=None,
|
|
67
|
+
config: ConfigProtocol | None = None,
|
|
87
68
|
) -> None:
|
|
88
|
-
"""
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
:
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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.
|
|
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 =
|
|
115
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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
|
-
"
|
|
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
|
|
9
|
-
from typing import Iterable
|
|
17
|
+
from typing import Any, Iterable
|
|
10
18
|
|
|
11
19
|
import apsw
|
|
12
20
|
|
|
13
|
-
from .
|
|
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
|
-
|
|
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
|
-
"""
|
|
36
|
-
|
|
37
|
-
:
|
|
38
|
-
|
|
39
|
-
|
|
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 =
|
|
55
|
-
|
|
56
|
-
primary_key_type =
|
|
57
|
-
|
|
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
|
-
|
|
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
|
|
50
|
+
fields = ", ".join(f"{escape_name(field)}" for field, *_ in self._config.fields)
|
|
70
51
|
excluded_fields = ", ".join(
|
|
71
|
-
f"{field} = excluded.{field}"
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
|
130
|
+
for columnname, columntype, default_value in new_fields:
|
|
172
131
|
if columnname not in existing_fileds:
|
|
173
|
-
|
|
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 {
|
|
176
|
-
f" {
|
|
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
|
|
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
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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:
|
|
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(
|
|
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):
|
|
235
|
-
with self.transact(
|
|
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.
|
|
194
|
+
return self._load_data(value)
|
|
243
195
|
|
|
244
196
|
def popitem(self):
|
|
245
|
-
with self.transact(
|
|
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.
|
|
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):
|
|
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(
|
|
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)
|
diskstore-0.1.0/pyproject.toml
DELETED
|
@@ -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,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)
|