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/queues.py ADDED
@@ -0,0 +1,215 @@
1
+ import asyncio
2
+ import json
3
+ import time
4
+ from datetime import datetime, timezone
5
+ from typing import (
6
+ IO,
7
+ Iterator,
8
+ Literal,
9
+ NamedTuple,
10
+ overload,
11
+ Protocol,
12
+ runtime_checkable,
13
+ TYPE_CHECKING,
14
+ )
15
+
16
+ from pydantic import BaseModel
17
+
18
+ from .manager import AsyncBeaverBase, atomic, emits
19
+
20
+ if TYPE_CHECKING:
21
+ from .core import AsyncBeaverDB
22
+
23
+
24
+ class QueueItem[T](NamedTuple):
25
+ """A data class representing a single item retrieved from the queue."""
26
+
27
+ priority: float
28
+ timestamp: float
29
+ data: T
30
+
31
+
32
+ @runtime_checkable
33
+ class IBeaverQueue[T: BaseModel](Protocol):
34
+ """
35
+ The Synchronous Protocol exposed to the user via BeaverBridge.
36
+ """
37
+
38
+ def put(self, data: T, priority: float) -> None: ...
39
+
40
+ def peek(self) -> QueueItem[T] | None: ...
41
+
42
+ # Overloads for get
43
+ @overload
44
+ def get(
45
+ self, block: Literal[True] = True, timeout: float | None = None
46
+ ) -> QueueItem[T]: ...
47
+ @overload
48
+ def get(self, block: Literal[False]) -> QueueItem[T]: ...
49
+
50
+ def clear(self) -> None: ...
51
+ def count(self) -> int: ...
52
+ def dump(self, fp: IO[str] | None = None) -> dict | None: ...
53
+
54
+ def __len__(self) -> int: ...
55
+ def __iter__(self) -> Iterator[QueueItem[T]]: ...
56
+ def __bool__(self) -> bool: ...
57
+
58
+
59
+ class AsyncBeaverQueue[T: BaseModel](AsyncBeaverBase[T]):
60
+ """
61
+ A wrapper providing a Pythonic interface to a persistent, multi-process
62
+ producer-consumer priority queue.
63
+ Refactored for Async-First architecture (v2.0).
64
+ """
65
+
66
+ @emits("put", payload=lambda *args, **kwargs: dict())
67
+ @atomic
68
+ async def put(self, data: T, priority: float):
69
+ """
70
+ Adds an item to the queue with a specific priority.
71
+ """
72
+ await self.connection.execute(
73
+ "INSERT INTO __beaver_priority_queues__ (queue_name, priority, timestamp, data) VALUES (?, ?, ?, ?)",
74
+ (self._name, priority, time.time(), self._serialize(data)),
75
+ )
76
+
77
+ async def _get_item_atomically(self, pop: bool = True) -> QueueItem[T] | None:
78
+ """
79
+ Performs a single, atomic attempt to retrieve and remove the
80
+ highest-priority item from the queue.
81
+ """
82
+ # We need a transaction to ensure we don't peek/get an item that someone else steals
83
+ # Note: If called from peek/get, they might handle locking via @atomic or logic.
84
+ # Since this helper is used inside @atomic methods, we just need execution.
85
+
86
+ cursor = await self.connection.execute(
87
+ """
88
+ SELECT rowid, priority, timestamp, data
89
+ FROM __beaver_priority_queues__
90
+ WHERE queue_name = ?
91
+ ORDER BY priority ASC, timestamp ASC
92
+ LIMIT 1
93
+ """,
94
+ (self._name,),
95
+ )
96
+ result = await cursor.fetchone()
97
+
98
+ if result is None:
99
+ return None
100
+
101
+ rowid, priority, timestamp, data = result
102
+
103
+ if pop:
104
+ await self.connection.execute(
105
+ "DELETE FROM __beaver_priority_queues__ WHERE rowid = ?", (rowid,)
106
+ )
107
+
108
+ return QueueItem(
109
+ priority=priority, timestamp=timestamp, data=self._deserialize(data)
110
+ )
111
+
112
+ @atomic
113
+ async def peek(self) -> QueueItem[T] | None:
114
+ """
115
+ Retrieves the first item of the queue without removing it.
116
+ """
117
+ return await self._get_item_atomically(pop=False)
118
+
119
+ @atomic
120
+ async def _try_pop_atomic(self) -> QueueItem[T] | None:
121
+ """Helper to check and pop one item under lock."""
122
+ return await self._get_item_atomically(pop=True)
123
+
124
+ async def _get_loop_impl(self, block: bool, timeout: float | None) -> QueueItem[T]:
125
+ """
126
+ The polling loop. It acquires the lock only briefly during the check,
127
+ allowing producers to interleave 'put' operations while we wait.
128
+ """
129
+ # 1. Non-blocking fast path
130
+ if not block:
131
+ item = await self._try_pop_atomic()
132
+ if item is None:
133
+ raise IndexError("get from an empty queue.")
134
+ return item
135
+
136
+ # 2. Blocking loop
137
+ start_time = time.time()
138
+ while True:
139
+ item = await self._try_pop_atomic()
140
+
141
+ if item is not None:
142
+ return item
143
+
144
+ if timeout is not None and (time.time() - start_time) > timeout:
145
+ raise TimeoutError("Timeout expired while waiting for an item.")
146
+
147
+ # Yield control to allow producers to acquire the lock and put items
148
+ await asyncio.sleep(0.1)
149
+
150
+ # We override the public get to use the loop implementation
151
+ # NOTE: We do NOT decorate this with @atomic, as it manages its own locking scope.
152
+ @emits("get", payload=lambda *args, **kwargs: dict())
153
+ async def get(
154
+ self, block: bool = True, timeout: float | None = None
155
+ ) -> QueueItem[T]:
156
+ return await self._get_loop_impl(block, timeout)
157
+
158
+ async def count(self) -> int:
159
+ cursor = await self.connection.execute(
160
+ "SELECT COUNT(*) FROM __beaver_priority_queues__ WHERE queue_name = ?",
161
+ (self._name,),
162
+ )
163
+ count = await cursor.fetchone()
164
+ return count[0] if count else 0
165
+
166
+ async def clear(self):
167
+ await self.connection.execute(
168
+ "DELETE FROM __beaver_priority_queues__ WHERE queue_name = ?",
169
+ (self._name,),
170
+ )
171
+
172
+ # --- Iterators ---
173
+
174
+ async def __aiter__(self):
175
+ cursor = await self.connection.execute(
176
+ """
177
+ SELECT priority, timestamp, data
178
+ FROM __beaver_priority_queues__
179
+ WHERE queue_name = ?
180
+ ORDER BY priority ASC, timestamp ASC
181
+ """,
182
+ (self._name,),
183
+ )
184
+ async for row in cursor:
185
+ yield QueueItem(
186
+ priority=row["priority"],
187
+ timestamp=row["timestamp"],
188
+ data=self._deserialize(row["data"]),
189
+ )
190
+
191
+ async def dump(self, fp: IO[str] | None = None) -> dict | None:
192
+ items_list = []
193
+ async for item in self:
194
+ data = item.data
195
+ if self._model and isinstance(data, BaseModel):
196
+ data = json.loads(data.model_dump_json())
197
+
198
+ items_list.append(
199
+ {"priority": item.priority, "timestamp": item.timestamp, "data": data}
200
+ )
201
+
202
+ metadata = {
203
+ "type": "Queue",
204
+ "name": self._name,
205
+ "count": len(items_list),
206
+ "dump_date": datetime.now(timezone.utc).isoformat(),
207
+ }
208
+
209
+ dump_obj = {"metadata": metadata, "items": items_list}
210
+
211
+ if fp:
212
+ json.dump(dump_obj, fp, indent=2)
213
+ return None
214
+
215
+ return dump_obj
beaver/security.py ADDED
@@ -0,0 +1,144 @@
1
+ import base64
2
+ import os
3
+ from typing import Any, Optional
4
+
5
+ from pydantic import BaseModel
6
+
7
+ try:
8
+ from cryptography.fernet import Fernet, InvalidToken
9
+ from cryptography.hazmat.primitives import hashes
10
+ from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
11
+ from cryptography.hazmat.backends import default_backend
12
+ except ImportError:
13
+ # We define dummy classes or raise errors if accessed without dependencies
14
+ Fernet = None # type: ignore
15
+
16
+ def require_security():
17
+ raise ImportError(
18
+ 'The "security" extra is required to use this feature. '
19
+ 'Please install it with: pip install "beaver-db[security]"'
20
+ )
21
+
22
+ else:
23
+
24
+ def require_security():
25
+ pass
26
+
27
+
28
+ class Cipher:
29
+ """
30
+ Handles symmetric encryption using Fernet (AES-128 CBC + HMAC-SHA256).
31
+ Used by Encrypted Dictionaries to secure values at rest.
32
+ """
33
+
34
+ def __init__(self, secret: str, salt: bytes | None = None):
35
+ require_security()
36
+ if not salt:
37
+ salt = os.urandom(16)
38
+ self.salt = salt
39
+ self.key = self._derive_key(secret, salt)
40
+ self.fernet = Fernet(self.key)
41
+
42
+ @staticmethod
43
+ def _derive_key(secret: str, salt: bytes) -> bytes:
44
+ """Derives a 32-byte, url-safe base64 key from the secret using PBKDF2."""
45
+ kdf = PBKDF2HMAC(
46
+ algorithm=hashes.SHA256(),
47
+ length=32,
48
+ salt=salt,
49
+ iterations=100_000,
50
+ backend=default_backend(),
51
+ )
52
+ return base64.urlsafe_b64encode(kdf.derive(secret.encode()))
53
+
54
+ def encrypt(self, data: bytes) -> bytes:
55
+ """Encrypts bytes."""
56
+ return self.fernet.encrypt(data)
57
+
58
+ def decrypt(self, token: bytes) -> bytes:
59
+ """Decrypts bytes. Raises ValueError if the key is incorrect."""
60
+ try:
61
+ return self.fernet.decrypt(token)
62
+ except InvalidToken:
63
+ raise ValueError("Invalid secret key or corrupted data.")
64
+
65
+
66
+ class Secret(BaseModel):
67
+ """
68
+ A value type for secure, one-way password hashing.
69
+ Stores only the hash and salt as base64 strings, ensuring JSON compatibility.
70
+ """
71
+
72
+ hash: str
73
+ salt: str
74
+
75
+ def __init__(self, value: str | None = None, **kwargs):
76
+ """
77
+ Can be initialized in two ways:
78
+ 1. New Secret: Secret("my-password") -> Hashes and stores it as b64 strings.
79
+ 2. Loading: Secret(hash="...", salt="...") -> Reconstructs it.
80
+ """
81
+ # Ensure security deps are present
82
+ if value is not None or (not kwargs.get("hash")):
83
+ require_security()
84
+
85
+ if value is not None:
86
+ # 1. User provided a plain-text password to hash
87
+ salt_bytes = os.urandom(16)
88
+ kdf = PBKDF2HMAC(
89
+ algorithm=hashes.SHA256(),
90
+ length=32,
91
+ salt=salt_bytes,
92
+ iterations=100_000,
93
+ backend=default_backend(),
94
+ )
95
+ hashed_bytes = kdf.derive(value.encode())
96
+
97
+ # Store as base64 strings
98
+ super().__init__(
99
+ hash=base64.b64encode(hashed_bytes).decode("utf-8"),
100
+ salt=base64.b64encode(salt_bytes).decode("utf-8"),
101
+ )
102
+ else:
103
+ # 2. Pydantic is loading from arguments (hash/salt)
104
+ super().__init__(**kwargs)
105
+
106
+ def __eq__(self, other: Any) -> bool:
107
+ """
108
+ Checks equality.
109
+ - If comparing to another Secret, checks hash equality.
110
+ - If comparing to a string, checks if the string hashes to the stored value.
111
+ """
112
+ if isinstance(other, Secret):
113
+ return self.hash == other.hash and self.salt == other.salt
114
+
115
+ if isinstance(other, str):
116
+ require_security()
117
+
118
+ # Decode stored strings back to bytes
119
+ try:
120
+ salt_bytes = base64.b64decode(self.salt)
121
+ hash_bytes = base64.b64decode(self.hash)
122
+ except (ValueError, TypeError):
123
+ return False
124
+
125
+ kdf = PBKDF2HMAC(
126
+ algorithm=hashes.SHA256(),
127
+ length=32,
128
+ salt=salt_bytes,
129
+ iterations=100_000,
130
+ backend=default_backend(),
131
+ )
132
+ try:
133
+ kdf.verify(other.encode(), hash_bytes)
134
+ return True
135
+ except Exception:
136
+ return False
137
+
138
+ return False
139
+
140
+ def __repr__(self) -> str:
141
+ return f"Secret(hash={self.hash[:8]}...)"
142
+
143
+ def __str__(self) -> str:
144
+ return "********"