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/graphs.py ADDED
@@ -0,0 +1,212 @@
1
+ import json
2
+ from typing import (
3
+ Iterator,
4
+ AsyncIterator,
5
+ Protocol,
6
+ runtime_checkable,
7
+ TYPE_CHECKING,
8
+ Tuple,
9
+ Any,
10
+ )
11
+
12
+ from pydantic import BaseModel
13
+
14
+ from .manager import AsyncBeaverBase, atomic, emits
15
+
16
+ if TYPE_CHECKING:
17
+ from .core import AsyncBeaverDB
18
+
19
+
20
+ class Edge[T](BaseModel):
21
+ """
22
+ Represents a directed edge in the graph with typed metadata.
23
+ """
24
+
25
+ source: str
26
+ target: str
27
+ label: str
28
+ metadata: T | None
29
+
30
+
31
+ @runtime_checkable
32
+ class IBeaverGraph[T: BaseModel](Protocol):
33
+ """Protocol exposed to the user via BeaverBridge."""
34
+
35
+ def link(
36
+ self, source: str, target: str, label: str, metadata: T | None = None
37
+ ) -> None: ...
38
+ def unlink(self, source: str, target: str, label: str) -> None: ...
39
+
40
+ def linked(self, source: str, target: str, label: str) -> bool: ...
41
+ def get(self, source: str, target: str, label: str) -> Edge[T]: ...
42
+
43
+ def children(self, source: str, label: str | None = None) -> Iterator[str]: ...
44
+ def parents(self, target: str, label: str | None = None) -> Iterator[str]: ...
45
+ def edges(self, source: str, label: str | None = None) -> Iterator[Edge[T]]: ...
46
+
47
+ def clear(self) -> None: ...
48
+ def count(self) -> int: ...
49
+
50
+
51
+ class AsyncBeaverGraph[T: BaseModel](AsyncBeaverBase[T]):
52
+ """
53
+ Manages directed relationships between entities.
54
+
55
+ The generic type T refers to the type of the Edge Metadata.
56
+
57
+ Table managed:
58
+ - __beaver_edges__ (collection, source_item_id, target_item_id, label, metadata)
59
+ """
60
+
61
+ def __init__(self, name: str, db: "AsyncBeaverDB", model: type[T] | None = None):
62
+ super().__init__(name, db, model)
63
+ # Construct the concrete Edge model for this manager
64
+ self._edge_model = Edge[model] if model else Edge
65
+
66
+ @emits(
67
+ "link",
68
+ payload=lambda s, t, l, *args, **kwargs: dict(source=s, target=t, label=l),
69
+ )
70
+ @atomic
71
+ async def link(
72
+ self, source: str, target: str, label: str, metadata: T | None = None
73
+ ):
74
+ """Creates or updates a directed edge."""
75
+ # Use the base manager's serializer to handle Pydantic models vs dicts
76
+ # Default to empty dict if None (and no model enforced)
77
+ meta_json = self._serialize(metadata) if metadata else None
78
+
79
+ await self.connection.execute(
80
+ """
81
+ INSERT OR REPLACE INTO __beaver_edges__
82
+ (collection, source_item_id, target_item_id, label, metadata)
83
+ VALUES (?, ?, ?, ?, ?)
84
+ """,
85
+ (self._name, source, target, label, meta_json),
86
+ )
87
+
88
+ @emits(
89
+ "unlink",
90
+ payload=lambda s, t, l, *args, **kwargs: dict(source=s, target=t, label=l),
91
+ )
92
+ @atomic
93
+ async def unlink(self, source: str, target: str, label: str):
94
+ """Removes a directed edge."""
95
+ await self.connection.execute(
96
+ """
97
+ DELETE FROM __beaver_edges__
98
+ WHERE collection = ? AND source_item_id = ? AND target_item_id = ? AND label = ?
99
+ """,
100
+ (self._name, source, target, label),
101
+ )
102
+
103
+ async def linked(self, source: str, target: str, label: str) -> bool:
104
+ """Checks if a specific edge exists."""
105
+ cursor = await self.connection.execute(
106
+ """
107
+ SELECT 1 FROM __beaver_edges__
108
+ WHERE collection = ? AND source_item_id = ? AND target_item_id = ? AND label = ?
109
+ LIMIT 1
110
+ """,
111
+ (self._name, source, target, label),
112
+ )
113
+ return await cursor.fetchone() is not None
114
+
115
+ async def get(self, source: str, target: str, label: str) -> Edge[T]:
116
+ """
117
+ Retrieves a specific edge with metadata.
118
+ Raises KeyError if not found.
119
+ """
120
+ cursor = await self.connection.execute(
121
+ """
122
+ SELECT metadata FROM __beaver_edges__
123
+ WHERE collection = ? AND source_item_id = ? AND target_item_id = ? AND label = ?
124
+ """,
125
+ (self._name, source, target, label),
126
+ )
127
+ row = await cursor.fetchone()
128
+
129
+ if not row:
130
+ raise KeyError(f"Edge not found: {source} -[{label}]-> {target}")
131
+
132
+ # Deserialize using the base manager (handles T validation)
133
+ meta_str = row["metadata"]
134
+ meta_val = self._deserialize(meta_str)
135
+
136
+ return self._edge_model(
137
+ source=source, target=target, label=label, metadata=meta_val
138
+ )
139
+
140
+ async def children(
141
+ self, source: str, label: str | None = None
142
+ ) -> AsyncIterator[str]:
143
+ """
144
+ Yields target IDs connected by outgoing edges from 'source'.
145
+ (Forward traversal: source -> target)
146
+ """
147
+ query = "SELECT target_item_id FROM __beaver_edges__ WHERE collection = ? AND source_item_id = ?"
148
+ params = [self._name, source]
149
+
150
+ if label:
151
+ query += " AND label = ?"
152
+ params.append(label)
153
+
154
+ cursor = await self.connection.execute(query, tuple(params))
155
+ async for row in cursor:
156
+ yield row["target_item_id"]
157
+
158
+ async def parents(
159
+ self, target: str, label: str | None = None
160
+ ) -> AsyncIterator[str]:
161
+ """
162
+ Yields source IDs connected by incoming edges to 'target'.
163
+ (Reverse traversal: source -> target)
164
+ """
165
+ # This relies on the index (collection, target_item_id) for performance
166
+ query = "SELECT source_item_id FROM __beaver_edges__ WHERE collection = ? AND target_item_id = ?"
167
+ params = [self._name, target]
168
+
169
+ if label:
170
+ query += " AND label = ?"
171
+ params.append(label)
172
+
173
+ cursor = await self.connection.execute(query, tuple(params))
174
+ async for row in cursor:
175
+ yield row["source_item_id"]
176
+
177
+ async def edges(
178
+ self, source: str, label: str | None = None
179
+ ) -> AsyncIterator[Edge[T]]:
180
+ """
181
+ Yields full Edge objects (including metadata) originating from 'source'.
182
+ """
183
+ query = "SELECT target_item_id, label, metadata FROM __beaver_edges__ WHERE collection = ? AND source_item_id = ?"
184
+ params = [self._name, source]
185
+
186
+ if label:
187
+ query += " AND label = ?"
188
+ params.append(label)
189
+
190
+ cursor = await self.connection.execute(query, tuple(params))
191
+ async for row in cursor:
192
+ meta_str = row["metadata"]
193
+ meta_val = self._deserialize(meta_str) if meta_str else None
194
+
195
+ yield self._edge_model(
196
+ source=source,
197
+ target=row["target_item_id"],
198
+ label=row["label"],
199
+ metadata=meta_val,
200
+ )
201
+
202
+ async def count(self) -> int:
203
+ cursor = await self.connection.execute(
204
+ "SELECT COUNT(*) FROM __beaver_edges__ WHERE collection = ?", (self._name,)
205
+ )
206
+ return (await cursor.fetchone())[0]
207
+
208
+ @atomic
209
+ async def clear(self):
210
+ await self.connection.execute(
211
+ "DELETE FROM __beaver_edges__ WHERE collection = ?", (self._name,)
212
+ )
beaver/lists.py ADDED
@@ -0,0 +1,337 @@
1
+ import json
2
+ from typing import (
3
+ Iterator,
4
+ Union,
5
+ IO,
6
+ overload,
7
+ Protocol,
8
+ runtime_checkable,
9
+ TYPE_CHECKING,
10
+ List,
11
+ )
12
+ from datetime import datetime, timezone
13
+
14
+ from pydantic import BaseModel
15
+
16
+ from .manager import AsyncBeaverBase, atomic, emits
17
+
18
+ if TYPE_CHECKING:
19
+ from .core import AsyncBeaverDB
20
+
21
+
22
+ @runtime_checkable
23
+ class IBeaverList[T: BaseModel](Protocol):
24
+ """
25
+ The Synchronous Protocol exposed to the user via BeaverBridge.
26
+ """
27
+
28
+ def __getitem__(self, index: int | slice) -> T | List[T]: ...
29
+ def __setitem__(self, index: int, value: T) -> None: ...
30
+ def __delitem__(self, index: int) -> None: ...
31
+ def __len__(self) -> int: ...
32
+ def __iter__(self) -> Iterator[T]: ...
33
+ def __contains__(self, value: T) -> bool: ...
34
+
35
+ def get(self, index: int | slice) -> T | List[T]: ...
36
+ def set(self, index: int, value: T) -> None: ...
37
+ def delete(self, index: int) -> None: ...
38
+
39
+ def count(self) -> int: ...
40
+ def push(self, value: T) -> None: ...
41
+ def prepend(self, value: T) -> None: ...
42
+ def insert(self, index: int, value: T) -> None: ...
43
+ def pop(self) -> T | None: ...
44
+ def deque(self) -> T | None: ...
45
+ def clear(self) -> None: ...
46
+ def dump(self, fp: IO[str] | None = None) -> dict | None: ...
47
+
48
+
49
+ class AsyncBeaverList[T: BaseModel](AsyncBeaverBase[T]):
50
+ """
51
+ A wrapper providing a Pythonic, persistent list in the database.
52
+ Refactored for Async-First architecture (v2.0).
53
+ """
54
+
55
+ async def _get_dump_object(self) -> dict:
56
+ items = []
57
+ async for item in self:
58
+ item_value = item
59
+ if self._model and isinstance(item, BaseModel):
60
+ item_value = json.loads(item.model_dump_json())
61
+ items.append(item_value)
62
+
63
+ metadata = {
64
+ "type": "List",
65
+ "name": self._name,
66
+ "count": len(items),
67
+ "dump_date": datetime.now(timezone.utc).isoformat(),
68
+ }
69
+ return {"metadata": metadata, "items": items}
70
+
71
+ async def dump(self, fp: IO[str] | None = None) -> dict | None:
72
+ """
73
+ Dumps the entire contents of the list to a JSON-compatible object.
74
+ """
75
+ # We can acquire the public lock for consistency during dump
76
+ async with self:
77
+ dump_object = await self._get_dump_object()
78
+
79
+ if fp:
80
+ json.dump(dump_object, fp, indent=2)
81
+ return None
82
+ return dump_object
83
+
84
+ async def count(self) -> int:
85
+ """Returns the number of items in the list."""
86
+ cursor = await self.connection.execute(
87
+ "SELECT COUNT(*) FROM __beaver_lists__ WHERE list_name = ?", (self._name,)
88
+ )
89
+ row = await cursor.fetchone()
90
+ return row[0] if row else 0
91
+
92
+ @atomic
93
+ async def get(self, index: Union[int, slice]) -> T | list[T]:
94
+ """
95
+ Retrieves an item or slice from the list.
96
+ Mapped from __getitem__ by the Bridge.
97
+ """
98
+ # Handle Slice
99
+ if isinstance(index, slice):
100
+ # We need the length to calculate indices
101
+ list_len = await self.count()
102
+ start, stop, step = index.indices(list_len)
103
+
104
+ if step != 1:
105
+ raise ValueError("Slicing with a step is not supported.")
106
+
107
+ limit = stop - start
108
+ if limit <= 0:
109
+ return []
110
+
111
+ cursor = await self.connection.execute(
112
+ "SELECT item_value FROM __beaver_lists__ WHERE list_name = ? ORDER BY item_order ASC LIMIT ? OFFSET ?",
113
+ (self._name, limit, start),
114
+ )
115
+ results = [
116
+ self._deserialize(row["item_value"]) for row in await cursor.fetchall()
117
+ ]
118
+ return results
119
+
120
+ # Handle Integer
121
+ elif isinstance(index, int):
122
+ list_len = await self.count()
123
+ if index < -list_len or index >= list_len:
124
+ raise IndexError("List index out of range.")
125
+
126
+ offset = index if index >= 0 else list_len + index
127
+
128
+ cursor = await self.connection.execute(
129
+ "SELECT item_value FROM __beaver_lists__ WHERE list_name = ? ORDER BY item_order ASC LIMIT 1 OFFSET ?",
130
+ (self._name, offset),
131
+ )
132
+ result = await cursor.fetchone()
133
+ if not result:
134
+ raise IndexError("List index out of range.")
135
+
136
+ return self._deserialize(result["item_value"])
137
+
138
+ else:
139
+ raise TypeError("List indices must be integers or slices.")
140
+
141
+ @emits("set", payload=lambda index, *args, **kwargs: dict(index=index))
142
+ @atomic
143
+ async def set(self, index: int, value: T):
144
+ """
145
+ Sets the value of an item at a specific index.
146
+ Mapped from __setitem__ by the Bridge.
147
+ """
148
+ if not isinstance(index, int):
149
+ raise TypeError("List indices must be integers.")
150
+
151
+ list_len = await self.count()
152
+ if index < -list_len or index >= list_len:
153
+ raise IndexError("List index out of range.")
154
+
155
+ offset = index if index >= 0 else list_len + index
156
+
157
+ # Find the rowid of the item to update
158
+ cursor = await self.connection.execute(
159
+ "SELECT rowid FROM __beaver_lists__ WHERE list_name = ? ORDER BY item_order ASC LIMIT 1 OFFSET ?",
160
+ (self._name, offset),
161
+ )
162
+ result = await cursor.fetchone()
163
+ if not result:
164
+ raise IndexError("List index out of range during update.")
165
+
166
+ rowid_to_update = result["rowid"]
167
+
168
+ # Update the value
169
+ await self.connection.execute(
170
+ "UPDATE __beaver_lists__ SET item_value = ? WHERE rowid = ?",
171
+ (self._serialize(value), rowid_to_update),
172
+ )
173
+
174
+ @emits("del", payload=lambda index, *args, **kwargs: dict(index=index))
175
+ @atomic
176
+ async def delete(self, index: int):
177
+ """
178
+ Deletes an item at a specific index.
179
+ Mapped from __delitem__ by the Bridge.
180
+ """
181
+ if not isinstance(index, int):
182
+ raise TypeError("List indices must be integers.")
183
+
184
+ list_len = await self.count()
185
+ if index < -list_len or index >= list_len:
186
+ raise IndexError("List index out of range.")
187
+
188
+ offset = index if index >= 0 else list_len + index
189
+
190
+ # Find rowid
191
+ cursor = await self.connection.execute(
192
+ "SELECT rowid FROM __beaver_lists__ WHERE list_name = ? ORDER BY item_order ASC LIMIT 1 OFFSET ?",
193
+ (self._name, offset),
194
+ )
195
+ result = await cursor.fetchone()
196
+ if not result:
197
+ raise IndexError("List index out of range during delete.")
198
+
199
+ rowid_to_delete = result["rowid"]
200
+
201
+ await self.connection.execute(
202
+ "DELETE FROM __beaver_lists__ WHERE rowid = ?", (rowid_to_delete,)
203
+ )
204
+
205
+ # --- Iterators ---
206
+
207
+ async def __aiter__(self):
208
+ """Async iterator for the list."""
209
+ cursor = await self.connection.execute(
210
+ "SELECT item_value FROM __beaver_lists__ WHERE list_name = ? ORDER BY item_order ASC",
211
+ (self._name,),
212
+ )
213
+ async for row in cursor:
214
+ yield self._deserialize(row["item_value"])
215
+
216
+ async def contains(self, value: T) -> bool:
217
+ """Checks for existence of an item."""
218
+ serialized = self._serialize(value)
219
+ cursor = await self.connection.execute(
220
+ "SELECT 1 FROM __beaver_lists__ WHERE list_name = ? AND item_value = ? LIMIT 1",
221
+ (self._name, serialized),
222
+ )
223
+ return await cursor.fetchone() is not None
224
+
225
+ async def _get_order_at_index(self, index: int) -> float:
226
+ """Helper to get the float item_order at a specific index."""
227
+ cursor = await self.connection.execute(
228
+ "SELECT item_order FROM __beaver_lists__ WHERE list_name = ? ORDER BY item_order ASC LIMIT 1 OFFSET ?",
229
+ (self._name, index),
230
+ )
231
+ result = await cursor.fetchone()
232
+
233
+ if result:
234
+ return result[0]
235
+ raise IndexError(f"{index} out of range.")
236
+
237
+ @emits("push", payload=lambda *args, **kwargs: dict())
238
+ @atomic
239
+ async def push(self, value: T):
240
+ """Pushes an item to the end of the list."""
241
+ cursor = await self.connection.execute(
242
+ "SELECT MAX(item_order) FROM __beaver_lists__ WHERE list_name = ?",
243
+ (self._name,),
244
+ )
245
+ row = await cursor.fetchone()
246
+ max_order = row[0] if row and row[0] is not None else 0.0
247
+ new_order = max_order + 1.0
248
+
249
+ await self.connection.execute(
250
+ "INSERT INTO __beaver_lists__ (list_name, item_order, item_value) VALUES (?, ?, ?)",
251
+ (self._name, new_order, self._serialize(value)),
252
+ )
253
+
254
+ @emits("prepend", payload=lambda *args, **kwargs: dict())
255
+ @atomic
256
+ async def prepend(self, value: T):
257
+ """Prepends an item to the beginning of the list."""
258
+ cursor = await self.connection.execute(
259
+ "SELECT MIN(item_order) FROM __beaver_lists__ WHERE list_name = ?",
260
+ (self._name,),
261
+ )
262
+ row = await cursor.fetchone()
263
+ min_order = row[0] if row and row[0] is not None else 0.0
264
+ new_order = min_order - 1.0
265
+
266
+ await self.connection.execute(
267
+ "INSERT INTO __beaver_lists__ (list_name, item_order, item_value) VALUES (?, ?, ?)",
268
+ (self._name, new_order, self._serialize(value)),
269
+ )
270
+
271
+ @emits("insert", payload=lambda index, *args, **kwargs: dict(index=index))
272
+ @atomic
273
+ async def insert(self, index: int, value: T):
274
+ """Inserts an item at a specific index using float order logic."""
275
+ list_len = await self.count()
276
+
277
+ if index <= 0:
278
+ await self.prepend(value)
279
+ return
280
+ if index >= list_len:
281
+ await self.push(value)
282
+ return
283
+
284
+ # Midpoint insertion
285
+ order_before = await self._get_order_at_index(index - 1)
286
+ order_after = await self._get_order_at_index(index)
287
+ new_order = order_before + (order_after - order_before) / 2.0
288
+
289
+ await self.connection.execute(
290
+ "INSERT INTO __beaver_lists__ (list_name, item_order, item_value) VALUES (?, ?, ?)",
291
+ (self._name, new_order, self._serialize(value)),
292
+ )
293
+
294
+ @emits("pop", payload=lambda *args, **kwargs: dict())
295
+ @atomic
296
+ async def pop(self) -> T | None:
297
+ """Removes and returns the last item."""
298
+ cursor = await self.connection.execute(
299
+ "SELECT rowid, item_value FROM __beaver_lists__ WHERE list_name = ? ORDER BY item_order DESC LIMIT 1",
300
+ (self._name,),
301
+ )
302
+ result = await cursor.fetchone()
303
+ if not result:
304
+ return None
305
+
306
+ rowid_to_delete, value_to_return = result
307
+ await self.connection.execute(
308
+ "DELETE FROM __beaver_lists__ WHERE rowid = ?", (rowid_to_delete,)
309
+ )
310
+ return self._deserialize(value_to_return)
311
+
312
+ @emits("deque", payload=lambda *args, **kwargs: dict())
313
+ @atomic
314
+ async def deque(self) -> T | None:
315
+ """Removes and returns the first item."""
316
+ cursor = await self.connection.execute(
317
+ "SELECT rowid, item_value FROM __beaver_lists__ WHERE list_name = ? ORDER BY item_order ASC LIMIT 1",
318
+ (self._name,),
319
+ )
320
+ result = await cursor.fetchone()
321
+ if not result:
322
+ return None
323
+
324
+ rowid_to_delete, value_to_return = result
325
+ await self.connection.execute(
326
+ "DELETE FROM __beaver_lists__ WHERE rowid = ?", (rowid_to_delete,)
327
+ )
328
+ return self._deserialize(value_to_return)
329
+
330
+ @emits("clear", payload=lambda *args, **kwargs: dict())
331
+ @atomic
332
+ async def clear(self):
333
+ """Atomically removes all items."""
334
+ await self.connection.execute(
335
+ "DELETE FROM __beaver_lists__ WHERE list_name = ?",
336
+ (self._name,),
337
+ )