beaver-db 2.0rc2__py3-none-any.whl

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.
beaver/dicts.py ADDED
@@ -0,0 +1,314 @@
1
+ import base64
2
+ import json
3
+ import time
4
+ from typing import (
5
+ IO,
6
+ Any,
7
+ Iterator,
8
+ Tuple,
9
+ overload,
10
+ Protocol,
11
+ runtime_checkable,
12
+ TYPE_CHECKING,
13
+ )
14
+
15
+ from pydantic import BaseModel
16
+
17
+ from .manager import AsyncBeaverBase, atomic, emits
18
+ from .security import Cipher
19
+
20
+ if TYPE_CHECKING:
21
+ from .core import AsyncBeaverDB
22
+
23
+
24
+ @runtime_checkable
25
+ class IBeaverDict[T: BaseModel](Protocol):
26
+ """
27
+ The Synchronous Protocol exposed to the user via BeaverBridge.
28
+ """
29
+
30
+ def __getitem__(self, key: str) -> T: ...
31
+ def __setitem__(self, key: str, value: T) -> None: ...
32
+ def __delitem__(self, key: str) -> None: ...
33
+ def __len__(self) -> int: ...
34
+ def __contains__(self, key: str) -> bool: ...
35
+ def __iter__(self) -> Iterator[str]: ...
36
+
37
+ def get(self, key: str) -> T: ...
38
+ def set(self, key: str, value: T, ttl_seconds: float | None = None) -> None: ...
39
+ def delete(self, key: str) -> None: ...
40
+
41
+ def fetch(self, key: str, default: Any = None) -> T | Any: ...
42
+ def pop(self, key: str, default: Any = None) -> T | Any: ...
43
+ def keys(self) -> Iterator[str]: ...
44
+ def values(self) -> Iterator[T]: ...
45
+ def items(self) -> Iterator[Tuple[str, T]]: ...
46
+ def clear(self) -> None: ...
47
+ def count(self) -> int: ...
48
+ def dump(self, fp: IO[str] | None = None) -> dict | None: ...
49
+
50
+
51
+ class AsyncBeaverDict[T: BaseModel](AsyncBeaverBase[T]):
52
+ """
53
+ A wrapper providing a Pythonic interface to a dictionary in the database.
54
+ Refactored for Async-First architecture (v2.0).
55
+ """
56
+
57
+ def __init__(
58
+ self,
59
+ name: str,
60
+ db: "AsyncBeaverDB",
61
+ model: type[T] | None = None,
62
+ secret: str | None = None,
63
+ ):
64
+ super().__init__(name, db, model)
65
+ self._cipher: Cipher | None = None
66
+ self._secret_arg = secret
67
+
68
+ async def _init(self):
69
+ """Async initialization hook."""
70
+ if self._secret_arg or not self.is_system():
71
+ await self._setup_security(self._secret_arg)
72
+
73
+ def is_system(self):
74
+ return self._name in [
75
+ "__metadata__",
76
+ "__security__",
77
+ "__beaver_event_registry__",
78
+ ]
79
+
80
+ async def _setup_security(self, secret: str | None):
81
+ """
82
+ Initializes the encryption cipher.
83
+ Reads directly from the internal __beaver_dicts__ table to avoid recursion.
84
+ """
85
+ if self._name == "__security__":
86
+ if secret:
87
+ raise ValueError(
88
+ "The internal '__security__' dictionary cannot be encrypted."
89
+ )
90
+ return
91
+
92
+ cursor = await self.connection.execute(
93
+ "SELECT value FROM __beaver_dicts__ WHERE dict_name = ? AND key = ?",
94
+ ("__security__", self._name),
95
+ )
96
+ row = await cursor.fetchone()
97
+ metadata = json.loads(row["value"]) if row else None
98
+
99
+ if secret is None:
100
+ if metadata:
101
+ raise ValueError(
102
+ f"Dictionary '{self._name}' is encrypted. You must provide a secret to open it."
103
+ )
104
+ return
105
+
106
+ if metadata:
107
+ try:
108
+ salt = base64.b64decode(metadata["salt"])
109
+ verifier_encrypted = base64.b64decode(metadata["verifier"])
110
+ except (KeyError, TypeError, ValueError):
111
+ raise ValueError(
112
+ f"Corrupted security metadata for dictionary '{self._name}'."
113
+ )
114
+
115
+ cipher = Cipher(secret, salt=salt)
116
+
117
+ try:
118
+ decrypted = cipher.decrypt(verifier_encrypted)
119
+ if decrypted != b"beaver-secure":
120
+ raise ValueError("Invalid secret.")
121
+ except Exception:
122
+ raise ValueError(f"Invalid secret for dictionary '{self._name}'.")
123
+
124
+ self._cipher = cipher
125
+ else:
126
+ cipher = Cipher(secret)
127
+ salt = cipher.salt
128
+ verifier_encrypted = cipher.encrypt(b"beaver-secure")
129
+
130
+ new_metadata = {
131
+ "salt": base64.b64encode(salt).decode("utf-8"),
132
+ "verifier": base64.b64encode(verifier_encrypted).decode("utf-8"),
133
+ "created_at": time.time(),
134
+ }
135
+
136
+ await self.connection.execute(
137
+ "INSERT OR REPLACE INTO __beaver_dicts__ (dict_name, key, value, expires_at) VALUES (?, ?, ?, ?)",
138
+ ("__security__", self._name, json.dumps(new_metadata), None),
139
+ )
140
+ await self.connection.commit()
141
+ self._cipher = cipher
142
+
143
+ def _serialize(self, value: T) -> str:
144
+ json_str = super()._serialize(value)
145
+ if self._cipher:
146
+ encrypted_bytes = self._cipher.encrypt(json_str.encode("utf-8"))
147
+ return base64.urlsafe_b64encode(encrypted_bytes).decode("utf-8")
148
+ return json_str
149
+
150
+ def _deserialize(self, value: str) -> T:
151
+ json_str = value
152
+ if self._cipher:
153
+ try:
154
+ encrypted_bytes = base64.urlsafe_b64decode(value)
155
+ json_str = self._cipher.decrypt(encrypted_bytes).decode("utf-8")
156
+ except Exception as e:
157
+ raise ValueError(
158
+ f"Failed to decrypt value in dictionary '{self._name}'."
159
+ ) from e
160
+ return super()._deserialize(json_str)
161
+
162
+ # --- Core Async API ---
163
+
164
+ @emits("set", payload=lambda key, *args, **kwargs: dict(key=key))
165
+ @atomic
166
+ async def set(self, key: str, value: T, ttl_seconds: float | None = None):
167
+ """Sets a value for a key."""
168
+ if self._secret_arg and not self._cipher:
169
+ await self._setup_security(self._secret_arg)
170
+
171
+ expires_at = None
172
+ if ttl_seconds is not None:
173
+ if not isinstance(ttl_seconds, (int, float)) or ttl_seconds <= 0:
174
+ raise ValueError("ttl_seconds must be a positive number.")
175
+ expires_at = time.time() + ttl_seconds
176
+
177
+ serialized_value = self._serialize(value)
178
+
179
+ await self.connection.execute(
180
+ """
181
+ INSERT OR REPLACE INTO __beaver_dicts__
182
+ (dict_name, key, value, expires_at)
183
+ VALUES (?, ?, ?, ?)
184
+ """,
185
+ (self._name, key, serialized_value, expires_at),
186
+ )
187
+
188
+ @atomic
189
+ async def get(self, key: str) -> T:
190
+ """Retrieves a value for a key. Raises KeyError if missing or expired."""
191
+ if self._secret_arg and not self._cipher:
192
+ await self._setup_security(self._secret_arg)
193
+
194
+ cursor = await self.connection.execute(
195
+ "SELECT value, expires_at FROM __beaver_dicts__ WHERE dict_name = ? AND key = ?",
196
+ (self._name, key),
197
+ )
198
+ result = await cursor.fetchone()
199
+
200
+ if result is None:
201
+ raise KeyError(f"Key '{key}' not found in dictionary '{self._name}'")
202
+
203
+ raw_value, expires_at = result["value"], result["expires_at"]
204
+
205
+ if expires_at is not None and time.time() > expires_at:
206
+ await self.connection.execute(
207
+ "DELETE FROM __beaver_dicts__ WHERE dict_name = ? AND key = ?",
208
+ (self._name, key),
209
+ )
210
+ raise KeyError(
211
+ f"Key '{key}' not found in dictionary '{self._name}' (expired)"
212
+ )
213
+
214
+ return self._deserialize(raw_value)
215
+
216
+ @emits("del", payload=lambda key, *args, **kwargs: dict(key=key))
217
+ @atomic
218
+ async def delete(self, key: str):
219
+ """Deletes a key. Raises KeyError if missing."""
220
+ cursor = await self.connection.execute(
221
+ "DELETE FROM __beaver_dicts__ WHERE dict_name = ? AND key = ?",
222
+ (self._name, key),
223
+ )
224
+
225
+ if cursor.rowcount == 0:
226
+ raise KeyError(f"Key '{key}' not found in dictionary '{self._name}'")
227
+
228
+ async def fetch(self, key: str, default: Any = None) -> T | Any:
229
+ try:
230
+ return await self.get(key)
231
+ except KeyError:
232
+ return default
233
+
234
+ @atomic
235
+ async def pop(self, key: str, default: Any = None) -> T | Any:
236
+ try:
237
+ value = await self.get(key)
238
+ await self.delete(key)
239
+ return value
240
+ except KeyError:
241
+ return default
242
+
243
+ async def count(self) -> int:
244
+ cursor = await self.connection.execute(
245
+ "SELECT COUNT(*) FROM __beaver_dicts__ WHERE dict_name = ?", (self._name,)
246
+ )
247
+ row = await cursor.fetchone()
248
+ return row[0] if row else 0
249
+
250
+ async def contains(self, key: str) -> bool:
251
+ cursor = await self.connection.execute(
252
+ "SELECT 1 FROM __beaver_dicts__ WHERE dict_name = ? AND key = ? LIMIT 1",
253
+ (self._name, key),
254
+ )
255
+ return await cursor.fetchone() is not None
256
+
257
+ @emits("clear", payload=lambda *args, **kwargs: dict())
258
+ @atomic
259
+ async def clear(self):
260
+ await self.connection.execute(
261
+ "DELETE FROM __beaver_dicts__ WHERE dict_name = ?",
262
+ (self._name,),
263
+ )
264
+
265
+ # --- Iterators (Async Generators) ---
266
+
267
+ async def __aiter__(self):
268
+ async for key in self.keys():
269
+ yield key
270
+
271
+ async def keys(self):
272
+ cursor = await self.connection.execute(
273
+ "SELECT key FROM __beaver_dicts__ WHERE dict_name = ?", (self._name,)
274
+ )
275
+ async for row in cursor:
276
+ yield row["key"]
277
+
278
+ async def values(self):
279
+ cursor = await self.connection.execute(
280
+ "SELECT value FROM __beaver_dicts__ WHERE dict_name = ?", (self._name,)
281
+ )
282
+ async for row in cursor:
283
+ yield self._deserialize(row["value"])
284
+
285
+ async def items(self):
286
+ cursor = await self.connection.execute(
287
+ "SELECT key, value FROM __beaver_dicts__ WHERE dict_name = ?", (self._name,)
288
+ )
289
+ async for row in cursor:
290
+ yield (row["key"], self._deserialize(row["value"]))
291
+
292
+ async def dump(self, fp: IO[str] | None = None) -> dict | None:
293
+ items = []
294
+ async for k, v in self.items():
295
+ val = v
296
+ if self._model and isinstance(v, BaseModel):
297
+ val = json.loads(v.model_dump_json())
298
+ items.append({"key": k, "value": val})
299
+
300
+ dump_obj = {
301
+ "metadata": {
302
+ "type": "Dict",
303
+ "name": self._name,
304
+ "count": len(items),
305
+ "encrypted": self._cipher is not None,
306
+ },
307
+ "items": items,
308
+ }
309
+
310
+ if fp:
311
+ json.dump(dump_obj, fp, indent=2)
312
+ return None
313
+
314
+ return dump_obj