slowstore 0.1.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.
@@ -0,0 +1,9 @@
1
+ MIT License
2
+
3
+ Copyright (c) <year> <copyright holders>
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
6
+
7
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
8
+
9
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,114 @@
1
+ Metadata-Version: 2.1
2
+ Name: slowstore
3
+ Version: 0.1.0
4
+ Summary: Simple filesystem backed key value storage for easy testing
5
+ License: MIT
6
+ Author: Costa Halicea
7
+ Author-email: costa@codechem.com
8
+ Requires-Python: >=3.12,<4.0
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.12
12
+ Requires-Dist: pydantic (>=2.8.2,<3.0.0)
13
+ Description-Content-Type: text/markdown
14
+
15
+ # Slowstore
16
+
17
+ This is a very simple single file single class key-value store written in Python. It is designed to be slow and inefficient, and is intended to be used for testing purposes only.
18
+ The rules are simple:
19
+ Every store has it's own directory, and every item is stored in a separate JSON file.
20
+
21
+ That's it.
22
+
23
+ It is easy to use and inspect the data being manipulated.
24
+
25
+ ## Installation
26
+
27
+ You can install Slowstore using pip:
28
+
29
+ ```bash
30
+ pip install slowstore
31
+ ```
32
+
33
+ ## Usage
34
+ You can create a new Slowstore instance by calling the constructor:
35
+
36
+ ```python
37
+ from slowstore import Slowstore
38
+
39
+ class SampleModel(BaseModel):
40
+ name: str
41
+ age: int = 0
42
+
43
+ # Create a new store and load the data
44
+ store = Slowstore[SampleModel](SampleModel, "mydata")).load()
45
+ ```
46
+
47
+ **One feature that I use a lot is to commit on property change, so I can inspect exactly what is going on**
48
+
49
+ ```python
50
+ store.save_on_change = True
51
+ ```
52
+ you can also set this from the constructor
53
+
54
+ ```python
55
+ store = Slowstore[SampleModel](SampleModel, "mydata", save_on_change=True).load()
56
+ ```
57
+ After this code is executed, every time a property is changed,
58
+ the changes will be saved to disk immediately.
59
+
60
+ This is slow, but it is useful for debugging and testing.
61
+
62
+
63
+ ### Adding items to the store
64
+
65
+ ```python
66
+ s1 = store.upsert(SampleModel(name="Alice", age=30))
67
+ s2 = store.upsert(SampleModel(name="Bob", age=25))
68
+ s3 = store.upsert(SampleModel(name="Charlie", age=35))
69
+ store.commit_all()
70
+
71
+ s1.age = 32
72
+ ```
73
+
74
+ ### Commit the changes to the store
75
+
76
+ ```python
77
+ store.commit(s1)
78
+ store.commit(s2)
79
+ # or just commit all changes
80
+ store.commit_all()
81
+ ```
82
+
83
+ ### Undo/Redo
84
+ every slowstore item is a proxy object around what you've created. This allows you to access the object's attributes and adds undo/redo/dirty functionality
85
+
86
+ ```python
87
+ s1.undo()
88
+ s1.redo()
89
+ ```
90
+
91
+ ### Querying
92
+ ```python
93
+ # get all items, returns an iterator
94
+ all_items = store.all()
95
+
96
+ # yield all items that match a condition
97
+ filtered_items = store.filter(lambda x: x.age > 30)
98
+
99
+ # get the first item that matches a condition
100
+ first_item = store.first(lambda x: x.age > 30)
101
+
102
+ # Check if some object or it's key is in the store
103
+ if s2 in store:
104
+ print("s2 is in store")
105
+ else:
106
+ print("s2 is not in store")
107
+
108
+ ```
109
+
110
+ ## License
111
+ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
112
+
113
+
114
+
@@ -0,0 +1,99 @@
1
+ # Slowstore
2
+
3
+ This is a very simple single file single class key-value store written in Python. It is designed to be slow and inefficient, and is intended to be used for testing purposes only.
4
+ The rules are simple:
5
+ Every store has it's own directory, and every item is stored in a separate JSON file.
6
+
7
+ That's it.
8
+
9
+ It is easy to use and inspect the data being manipulated.
10
+
11
+ ## Installation
12
+
13
+ You can install Slowstore using pip:
14
+
15
+ ```bash
16
+ pip install slowstore
17
+ ```
18
+
19
+ ## Usage
20
+ You can create a new Slowstore instance by calling the constructor:
21
+
22
+ ```python
23
+ from slowstore import Slowstore
24
+
25
+ class SampleModel(BaseModel):
26
+ name: str
27
+ age: int = 0
28
+
29
+ # Create a new store and load the data
30
+ store = Slowstore[SampleModel](SampleModel, "mydata")).load()
31
+ ```
32
+
33
+ **One feature that I use a lot is to commit on property change, so I can inspect exactly what is going on**
34
+
35
+ ```python
36
+ store.save_on_change = True
37
+ ```
38
+ you can also set this from the constructor
39
+
40
+ ```python
41
+ store = Slowstore[SampleModel](SampleModel, "mydata", save_on_change=True).load()
42
+ ```
43
+ After this code is executed, every time a property is changed,
44
+ the changes will be saved to disk immediately.
45
+
46
+ This is slow, but it is useful for debugging and testing.
47
+
48
+
49
+ ### Adding items to the store
50
+
51
+ ```python
52
+ s1 = store.upsert(SampleModel(name="Alice", age=30))
53
+ s2 = store.upsert(SampleModel(name="Bob", age=25))
54
+ s3 = store.upsert(SampleModel(name="Charlie", age=35))
55
+ store.commit_all()
56
+
57
+ s1.age = 32
58
+ ```
59
+
60
+ ### Commit the changes to the store
61
+
62
+ ```python
63
+ store.commit(s1)
64
+ store.commit(s2)
65
+ # or just commit all changes
66
+ store.commit_all()
67
+ ```
68
+
69
+ ### Undo/Redo
70
+ every slowstore item is a proxy object around what you've created. This allows you to access the object's attributes and adds undo/redo/dirty functionality
71
+
72
+ ```python
73
+ s1.undo()
74
+ s1.redo()
75
+ ```
76
+
77
+ ### Querying
78
+ ```python
79
+ # get all items, returns an iterator
80
+ all_items = store.all()
81
+
82
+ # yield all items that match a condition
83
+ filtered_items = store.filter(lambda x: x.age > 30)
84
+
85
+ # get the first item that matches a condition
86
+ first_item = store.first(lambda x: x.age > 30)
87
+
88
+ # Check if some object or it's key is in the store
89
+ if s2 in store:
90
+ print("s2 is in store")
91
+ else:
92
+ print("s2 is not in store")
93
+
94
+ ```
95
+
96
+ ## License
97
+ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
98
+
99
+
@@ -0,0 +1,16 @@
1
+ [tool.poetry]
2
+ name = "slowstore"
3
+ version = "0.1.0"
4
+ description = "Simple filesystem backed key value storage for easy testing"
5
+ authors = ["Costa Halicea <costa@codechem.com>"]
6
+ license = "MIT"
7
+ readme = "README.md"
8
+
9
+ [tool.poetry.dependencies]
10
+ python = "^3.12"
11
+ pydantic = "^2.8.2"
12
+
13
+
14
+ [build-system]
15
+ requires = ["poetry-core"]
16
+ build-backend = "poetry.core.masonry.api"
@@ -0,0 +1,312 @@
1
+ import json
2
+ from logging import getLogger as get_logger
3
+ import os
4
+ import shutil
5
+ import sys
6
+ import datetime
7
+ from pydantic import BaseModel
8
+
9
+ from typing import Any, Callable, Generic, List, Literal, TypeVar, cast
10
+
11
+ T = TypeVar("T", bound=BaseModel)
12
+
13
+ from logging import getLogger as get_logger
14
+
15
+ logger = get_logger(__name__)
16
+
17
+
18
+ def json_default(o):
19
+ if isinstance(o, (datetime.date, datetime.datetime)):
20
+ return o.isoformat()
21
+
22
+
23
+ class Change(BaseModel):
24
+ """A property change that can be undone or redone on a model"""
25
+
26
+ key: str
27
+ prop_name: str
28
+ prev_val: Any
29
+ new_val: Any
30
+ date: datetime.datetime = datetime.datetime.now()
31
+
32
+ # Not supporting this yet, since i dont need it
33
+ transaction_id: str = ""
34
+
35
+ def __init__(self, key, prop_name, prev_val, new_val):
36
+ super().__init__(
37
+ key=key, prop_name=prop_name, prev_val=prev_val, new_val=new_val
38
+ )
39
+
40
+ def undo(self, model):
41
+ setattr(model, self.prop_name, self.prev_val)
42
+
43
+ def redo(self, model):
44
+ setattr(model, self.prop_name, self.new_val)
45
+
46
+
47
+ __special_fields__ = [
48
+ "store",
49
+ "model",
50
+ "is_dirty",
51
+ "commit",
52
+ "__key__",
53
+ "__changes__",
54
+ "__add_change__",
55
+ "__reset__",
56
+ ]
57
+
58
+
59
+ class ModelProxy(BaseModel, Generic[T]):
60
+ store: "Slowstore[T]"
61
+ model: T
62
+ is_dirty: bool = False
63
+ __changes__: List[Change] = []
64
+ __key__: str
65
+
66
+ def __init__(self, store: "Slowstore[T]", key: str, model: T):
67
+ self.model = model
68
+ self.store = store
69
+ self.is_dirty = False
70
+ self.__key__ = key
71
+ self.__changes__ = []
72
+
73
+ def __getattr__(self, name):
74
+ if name in __special_fields__:
75
+ return super().__getattribute__(name)
76
+ else:
77
+ return getattr(self.model, name)
78
+
79
+ def __setattr__(self, name, value):
80
+ if name == "model":
81
+ super().__setattr__(name, value)
82
+ self.is_dirty = False
83
+ self.__changes__ = []
84
+ elif name in __special_fields__:
85
+ super().__setattr__(name, value)
86
+ else:
87
+ prev = self.model.__getattribute__(name)
88
+ if prev == value:
89
+ return
90
+ setattr(self.model, name, value)
91
+ self.__add_change__(name, prev, value)
92
+ self.__changes__.insert(0, Change(self.__key__, name, prev, value))
93
+ self.is_dirty = True
94
+ if self.store.save_on_change:
95
+ self.commit()
96
+
97
+ def commit(self):
98
+ self.store.commit(self)
99
+
100
+ def __add_change__(self, prop_name: str, prev_val: Any, new_val: Any):
101
+ change = Change(self.__key__, prop_name, prev_val, new_val)
102
+ self.store.__changes__.append(change)
103
+ self.__changes__.append(change)
104
+
105
+ def __reset__(self, count: int = sys.maxsize):
106
+ size = len(self.__changes__)
107
+ while size > 0 and count > 0:
108
+ change = self.__changes__.pop(0)
109
+ count -= 1
110
+ size -= 1
111
+ change.undo(self.model)
112
+
113
+
114
+ class Slowstore(Generic[T]):
115
+ """A simple key-value store that persists data to disk,
116
+ it uses a pydantic model as the value type
117
+ :param cls: The class
118
+ :type cls: type
119
+
120
+ :param directory: The directory where the store will be stored
121
+ :type directory: str
122
+
123
+ :param load_on_start: If the store should load all data on start
124
+ :type load_on_start: bool, Defaults to True
125
+
126
+ :param save_on_change: If the store should save data on change
127
+ :type save_on_change: bool, Defaults to False
128
+
129
+ :param save_on_exit: If the store should save data on exit
130
+ :type save_on_exit: bool, Defaults to True
131
+ """
132
+
133
+ directory: str
134
+ cls: type
135
+ save_on_change: bool = False
136
+ save_on_exit: bool = False
137
+ loaded: bool = False
138
+ __data__: dict[str, ModelProxy[T]]
139
+ __changes__: List[Change]
140
+
141
+ def __init__(self, cls: type, directory: str, **kwargs):
142
+ """Creates a new Slowstore instance"""
143
+ super().__init__()
144
+ self.__data__ = {}
145
+ self.directory = directory
146
+ self.cls = cls
147
+ # get the model type from the generic
148
+ if kwargs.get("load_on_start", True):
149
+ self.load()
150
+ self.save_on_change = kwargs.get("save_on_change", False)
151
+ self.save_on_exit = kwargs.get("save_on_exit", True)
152
+
153
+ def get(self, key) -> T:
154
+ """gets an object from the story"""
155
+ self.__ensure_loaded__()
156
+ return cast(T, self.__data__[key])
157
+
158
+ def upsert(self, key: str, value: T) -> T:
159
+ """sets a new object in the store and returns it's proxy
160
+ if we overwrite the key it will completely change the underlying model and this can cause data inconsistencies
161
+ """
162
+ self.__ensure_loaded__()
163
+ if key in self.__data__:
164
+ proxy = self.__data__[key]
165
+ if proxy.model != value:
166
+ self.__data__[key].model = value
167
+ proxy.is_dirty = True
168
+
169
+ if self.save_on_change:
170
+ self.commit(proxy)
171
+
172
+ return cast(T, proxy)
173
+
174
+ proxy = ModelProxy[T](self, key, value)
175
+ proxy.is_dirty = True
176
+
177
+ if self.save_on_change:
178
+ self.commit(proxy)
179
+
180
+ self.__data__[key] = proxy
181
+ return cast(T, proxy)
182
+
183
+ def delete(self, key: str) -> bool:
184
+ self.__ensure_loaded__()
185
+ if key in self.__data__:
186
+ del self.__data__[key]
187
+ os.remove(f"{self.directory}/{self.__sanitize_file_name__(key)}.json")
188
+ return True
189
+ return False
190
+
191
+ def __contains__(self, key: str | ModelProxy[T]) -> bool:
192
+ self.__ensure_loaded__()
193
+
194
+ if isinstance(key, ModelProxy):
195
+ return key.__key__ in self.__data__
196
+
197
+ return key in self.__data__
198
+
199
+ def __getitem__(self, key: str):
200
+ return cast(T, self.get(key))
201
+
202
+ def __setitem__(self, key: str, value: T):
203
+ return cast(T, self.upsert(key, value))
204
+
205
+ def __delitem__(self, key: str):
206
+ return self.delete(key)
207
+
208
+ def filter(self, filter: Callable[[str, ModelProxy], bool]):
209
+ """yield all models that satisfy the filter function"""
210
+ for key, model in self.__data__.items():
211
+ if filter(key, model):
212
+ yield cast(T, model)
213
+
214
+ def first(
215
+ self,
216
+ filter: Callable[[str, ModelProxy[T]], bool | Literal[True] | Literal[False]],
217
+ ) -> T | None:
218
+ """return the model that satisfy the filter function otherwise return None"""
219
+ self.__ensure_loaded__()
220
+ for key, model in self.__data__.items():
221
+ if filter(key, model):
222
+ return cast(T, model)
223
+ return None
224
+
225
+ def update(
226
+ self,
227
+ filter: Callable[[str, ModelProxy], bool],
228
+ updater: Callable[[str, ModelProxy], None],
229
+ ):
230
+ for key, model in self.__data__.items():
231
+ if filter(key, model):
232
+ updater(key, model)
233
+
234
+ def all(self):
235
+ self.__ensure_loaded__()
236
+ for x in self.__data__:
237
+ yield self.get(x)
238
+
239
+ def load(self):
240
+ if not os.path.exists(self.directory):
241
+ os.makedirs(self.directory, exist_ok=True)
242
+
243
+ for filename in os.listdir(self.directory):
244
+ with open(f"{self.directory}/{filename}", "r") as f:
245
+ try:
246
+ d = json.load(f)
247
+ key: str = d["__key__"]
248
+ del d["__key__"]
249
+ proxy = ModelProxy[T](store=self, key=key, model=self.cls(**d))
250
+ self.__data__[key] = proxy
251
+ except Exception as e:
252
+ logger.error(f"Error loading {filename}: {e}")
253
+ self.loaded = True
254
+ return self
255
+
256
+ def clear(self):
257
+ shutil.rmtree(self.directory)
258
+ self.__data__ = {}
259
+ self.loaded = False
260
+ self.__ensure_loaded__()
261
+
262
+ def commit_all(self):
263
+ self.__ensure_loaded__()
264
+ for proxy in self.__data__.values():
265
+ self.commit(proxy)
266
+
267
+ def commit(self, item: ModelProxy[T]):
268
+ if item.store != self:
269
+ raise ValueError("Item does not belong to this store")
270
+ if item.is_dirty:
271
+ with open(
272
+ f"{self.directory}/{self.__sanitize_file_name__(item.__key__)}.json",
273
+ "w",
274
+ ) as f:
275
+ d = item.model.model_dump()
276
+ d["__key__"] = item.__key__
277
+ f.write(json.dumps(d, indent=2, default=json_default))
278
+ item.is_dirty = False
279
+ item.__changes__ = []
280
+
281
+ def __ensure_loaded__(self):
282
+ if not self.loaded:
283
+ self.load()
284
+
285
+ def __sanitize_file_name__(self, name: str):
286
+ return (
287
+ name.replace("/", "_")
288
+ .replace("\\", "_")
289
+ .replace(":", "_")
290
+ .replace(" ", "_")
291
+ .replace(".", "_")
292
+ .replace("!", "_")
293
+ .replace("?", "_")
294
+ .lower()
295
+ )
296
+
297
+ def __iter__(self):
298
+ self.__ensure_loaded__()
299
+ return iter(self.__data__.keys())
300
+
301
+ def __enter__(self):
302
+ return self
303
+
304
+ def __exit__(self, exc_type, exc_val, exc_tb): # pyright:ignore
305
+ if exc_type is None and self.save_on_exit:
306
+ self.commit_all()
307
+ self.__data__ = {}
308
+ self.loaded = False
309
+ else:
310
+ self.__data__ = {}
311
+ self.loaded = False
312
+ return False