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/channels.py ADDED
@@ -0,0 +1,249 @@
1
+ import asyncio
2
+ import json
3
+ import time
4
+ import weakref
5
+ from typing import (
6
+ IO,
7
+ Any,
8
+ Iterator,
9
+ AsyncIterator,
10
+ Protocol,
11
+ runtime_checkable,
12
+ TYPE_CHECKING,
13
+ NamedTuple,
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 ChannelMessage[T](BaseModel):
25
+ """A message received from a channel."""
26
+
27
+ channel: str
28
+ payload: T
29
+ timestamp: float
30
+
31
+
32
+ @runtime_checkable
33
+ class IBeaverChannel[T: BaseModel](Protocol):
34
+ """
35
+ The Synchronous Protocol exposed to the user via BeaverBridge.
36
+ """
37
+
38
+ def publish(self, payload: T) -> None: ...
39
+ def subscribe(self) -> Iterator[ChannelMessage]: ...
40
+ def history(self, limit: int = 100) -> list[ChannelMessage]: ...
41
+ def clear(self) -> None: ...
42
+ def count(self) -> int: ...
43
+
44
+
45
+ class PubSubEngine:
46
+ """
47
+ A central engine that manages the background polling loop for ALL channels.
48
+ Attached to the AsyncBeaverDB instance.
49
+ """
50
+
51
+ def __init__(self, db: "AsyncBeaverDB"):
52
+ self.db = db
53
+ self._listeners: dict[str, list[asyncio.Queue]] = {}
54
+ self._running = False
55
+ self._task: asyncio.Task | None = None
56
+ self._last_poll_ts = time.time()
57
+
58
+ async def start(self):
59
+ """Starts the background polling loop."""
60
+ if self._running:
61
+ return
62
+ self._running = True
63
+ self._last_poll_ts = time.time()
64
+ self._task = asyncio.create_task(self._poll_loop())
65
+
66
+ async def stop(self):
67
+ """Stops the background polling loop."""
68
+ self._running = False
69
+ if self._task:
70
+ self._task.cancel()
71
+ try:
72
+ await self._task
73
+ except asyncio.CancelledError:
74
+ pass
75
+ self._task = None
76
+
77
+ def subscribe(self, channel: str) -> asyncio.Queue[ChannelMessage]:
78
+ """Registers a new listener queue for a channel."""
79
+ queue = asyncio.Queue()
80
+ if channel not in self._listeners:
81
+ self._listeners[channel] = []
82
+ self._listeners[channel].append(queue)
83
+ return queue
84
+
85
+ def unsubscribe(self, channel: str, queue: asyncio.Queue):
86
+ """Unregisters a listener."""
87
+ if channel in self._listeners:
88
+ if queue in self._listeners[channel]:
89
+ self._listeners[channel].remove(queue)
90
+ if not self._listeners[channel]:
91
+ del self._listeners[channel]
92
+
93
+ async def _poll_loop(self):
94
+ """
95
+ Periodically checks the DB for new messages and dispatches them
96
+ to registered local queues.
97
+ """
98
+ while self._running:
99
+ try:
100
+ # 1. Fetch new messages globally
101
+ # We use a raw execute here to avoid locking the transaction logic
102
+ # for simple reads.
103
+ cursor = await self.db.connection.execute(
104
+ """
105
+ SELECT timestamp, channel_name, message_payload
106
+ FROM __beaver_pubsub_log__
107
+ WHERE timestamp > ?
108
+ ORDER BY timestamp ASC
109
+ """,
110
+ (self._last_poll_ts,),
111
+ )
112
+
113
+ rows = await cursor.fetchall()
114
+ rows = list(rows)
115
+
116
+ if rows:
117
+ # Update high-water mark
118
+ self._last_poll_ts = rows[-1]["timestamp"]
119
+
120
+ # 2. Dispatch to listeners
121
+ for row in rows:
122
+ channel = row["channel_name"]
123
+ payload = row["message_payload"]
124
+ msg = ChannelMessage(
125
+ channel=channel, payload=payload, timestamp=row["timestamp"]
126
+ )
127
+
128
+ # Fan-out to all queues listening on this channel
129
+ if channel in self._listeners:
130
+ for q in self._listeners[channel]:
131
+ q.put_nowait(msg)
132
+
133
+ # 3. Wait before next poll
134
+ # Adaptive sleep could be added here (e.g. sleep less if busy)
135
+ await asyncio.sleep(0.1)
136
+
137
+ except asyncio.CancelledError:
138
+ break
139
+ except Exception:
140
+ # Log error or retry, don't crash the loop
141
+ await asyncio.sleep(1.0)
142
+
143
+
144
+ class AsyncBeaverChannel[T: BaseModel](AsyncBeaverBase[T]):
145
+ """
146
+ A wrapper for a Pub/Sub channel.
147
+ Refactored for Async-First architecture (v2.0).
148
+ """
149
+
150
+ async def _get_engine(self) -> PubSubEngine:
151
+ """
152
+ Retrieves (or creates) the shared PubSubEngine on the DB instance.
153
+ """
154
+ # We perform a lazy attachment to the DB instance to avoid modifying core.py
155
+ # heavily. We store the engine on the DB instance dynamically.
156
+ if not hasattr(self._db, "_pubsub_engine"):
157
+ self._db._pubsub_engine = PubSubEngine(self._db)
158
+ await self._db._pubsub_engine.start()
159
+
160
+ return self._db._pubsub_engine
161
+
162
+ @emits("publish", payload=lambda payload, *args, **kwargs: dict(payload=payload))
163
+ @atomic
164
+ async def publish(self, payload: T):
165
+ """
166
+ Publishes a message to the channel.
167
+ """
168
+ # Ensure engine is running (in case we are the first publisher)
169
+ await self._get_engine()
170
+
171
+ # Serialize
172
+ data_str = self._serialize(payload)
173
+ ts = time.time()
174
+
175
+ # Monotonicity check (simple collision avoidance)
176
+ # In high-throughput, we rely on the DB to serialize inserts via lock
177
+ await self.connection.execute(
178
+ "INSERT INTO __beaver_pubsub_log__ (timestamp, channel_name, message_payload) VALUES (?, ?, ?)",
179
+ (ts, self._name, data_str),
180
+ )
181
+
182
+ async def subscribe(self) -> AsyncIterator[ChannelMessage[T]]:
183
+ """
184
+ Returns an async iterator that yields new messages as they arrive.
185
+ """
186
+ engine = await self._get_engine()
187
+ queue = engine.subscribe(self._name)
188
+
189
+ try:
190
+ while True:
191
+ # Wait for next message from the engine
192
+ msg = await queue.get()
193
+
194
+ # Deserialize message
195
+ yield ChannelMessage(
196
+ channel=msg.channel,
197
+ payload=self._deserialize(msg.payload),
198
+ timestamp=msg.timestamp,
199
+ )
200
+ finally:
201
+ # Cleanup on break/cancel
202
+ engine.unsubscribe(self._name, queue)
203
+
204
+ async def history(self, limit: int = 100) -> list[ChannelMessage[T]]:
205
+ """
206
+ Retrieves the last N messages from the channel history.
207
+ """
208
+ cursor = await self.connection.execute(
209
+ """
210
+ SELECT timestamp, channel_name, message_payload
211
+ FROM __beaver_pubsub_log__
212
+ WHERE channel_name = ?
213
+ ORDER BY timestamp DESC
214
+ LIMIT ?
215
+ """,
216
+ (self._name, limit),
217
+ )
218
+
219
+ rows = await cursor.fetchall()
220
+ rows = list(rows)
221
+ results = []
222
+
223
+ # Rows are DESC, we usually want them returned chronologically
224
+ for row in reversed(rows):
225
+ payload = self._deserialize(row["message_payload"])
226
+ results.append(
227
+ ChannelMessage(
228
+ channel=self._name, payload=payload, timestamp=row["timestamp"]
229
+ )
230
+ )
231
+
232
+ return results
233
+
234
+ @emits("clear", payload=lambda *args, **kwargs: dict())
235
+ @atomic
236
+ async def clear(self):
237
+ """Clears the history for this channel."""
238
+ await self.connection.execute(
239
+ "DELETE FROM __beaver_pubsub_log__ WHERE channel_name = ?", (self._name,)
240
+ )
241
+
242
+ async def count(self) -> int:
243
+ """Returns the total number of messages in history."""
244
+ cursor = await self.connection.execute(
245
+ "SELECT COUNT(*) FROM __beaver_pubsub_log__ WHERE channel_name = ?",
246
+ (self._name,),
247
+ )
248
+ row = await cursor.fetchone()
249
+ return row[0] if row else 0
beaver/cli/__init__.py ADDED
@@ -0,0 +1,133 @@
1
+ import typer
2
+ import rich
3
+ import rich.table
4
+ from typing_extensions import Annotated
5
+
6
+ import beaver
7
+ from beaver import BeaverDB
8
+
9
+ # Import the command group from the new file
10
+ from beaver.cli import dicts as dicts_cli
11
+ from beaver.cli import lists as lists_cli
12
+ from beaver.cli import queues as queues_cli
13
+ from beaver.cli import blobs as blobs_cli
14
+ from beaver.cli import locks as locks_cli
15
+ from beaver.cli import logs as logs_cli
16
+ from beaver.cli import channels as channels_cli
17
+ from beaver.cli import collections as collections_cli
18
+
19
+ # --- Main App ---
20
+ app = typer.Typer()
21
+
22
+ # Register the command group
23
+ app.add_typer(dicts_cli.app)
24
+ app.add_typer(lists_cli.app)
25
+ app.add_typer(queues_cli.app)
26
+ app.add_typer(blobs_cli.app)
27
+ app.add_typer(locks_cli.app)
28
+ app.add_typer(logs_cli.app)
29
+ app.add_typer(channels_cli.app)
30
+ app.add_typer(collections_cli.app)
31
+
32
+
33
+ def version_callback(value: bool):
34
+ if value:
35
+ print(beaver.__version__)
36
+ raise typer.Exit()
37
+
38
+
39
+ @app.callback()
40
+ def main(
41
+ ctx: typer.Context,
42
+ database: Annotated[
43
+ str, typer.Option(help="The path to the BeaverDB database file.")
44
+ ] = "beaver.db",
45
+ version: Annotated[
46
+ bool,
47
+ typer.Option(
48
+ "--version",
49
+ callback=version_callback,
50
+ is_eager=True,
51
+ help="Show the version and exit.",
52
+ ),
53
+ ] = False,
54
+ ):
55
+ """
56
+ BeaverDB command-line interface.
57
+ """
58
+ try:
59
+ # Store the db instance in the context for all subcommands
60
+ ctx.obj = {"db": BeaverDB(database)}
61
+ except Exception as e:
62
+ rich.print(f"[bold red]Error opening database:[/] {e}")
63
+ raise typer.Exit(code=1)
64
+
65
+
66
+ # --- Serve Command ---
67
+ @app.command()
68
+ def serve(
69
+ database: Annotated[
70
+ str, typer.Option(help="The path to the BeaverDB database file.")
71
+ ] = "beaver.db",
72
+ host: Annotated[
73
+ str, typer.Option(help="The host to bind the server to.")
74
+ ] = "127.0.0.1",
75
+ port: Annotated[int, typer.Option(help="The port to run the server on.")] = 8000,
76
+ ):
77
+ """Starts a REST API server for the BeaverDB database."""
78
+ try:
79
+ from beaver import server
80
+ except ImportError:
81
+ rich.print(
82
+ "[red]Error:[/] To use the serve command, please install the server dependencies:\n"
83
+ 'pip install "beaver-db[server]"'
84
+ )
85
+ raise typer.Exit(code=1)
86
+ server.serve(database, host=host, port=port)
87
+
88
+
89
+ @app.command()
90
+ def info(ctx: typer.Context):
91
+ """
92
+ Displays a summary of all data structures in the database.
93
+ """
94
+ db: BeaverDB = ctx.obj["db"]
95
+ rich.print(f"[bold]Database Summary: {db._db_path}[/bold]")
96
+
97
+ table = rich.table.Table(title="BeaverDB Contents")
98
+ table.add_column("Type", style="cyan")
99
+ table.add_column("Name", style="bold")
100
+ table.add_column("Count", style="magenta", justify="right")
101
+
102
+ try:
103
+ # Dictionaries
104
+ for name in db.dicts:
105
+ table.add_row("Dict", name, str(len(db.dict(name))))
106
+ # Lists
107
+ for name in db.lists:
108
+ table.add_row("List", name, str(len(db.list(name))))
109
+ # Queues
110
+ for name in db.queues:
111
+ table.add_row("Queue", name, str(len(db.queue(name))))
112
+ # Collections
113
+ for name in db.collections:
114
+ table.add_row("Collection", name, str(len(db.collection(name))))
115
+ # Blob Stores
116
+ for name in db.blobs:
117
+ table.add_row("Blob Store", name, str(len(db.blob(name))))
118
+ # Logs
119
+ for name in db.logs:
120
+ table.add_row("Log", name, "N/A (len not supported)")
121
+ # Active Locks
122
+ for name in db.locks:
123
+ table.add_row("Active Lock", name, "1")
124
+
125
+ rich.print(table)
126
+
127
+ except Exception as e:
128
+ rich.print(f"[bold red]Error:[/] {e}")
129
+ raise typer.Exit(code=1)
130
+
131
+
132
+ if __name__ == "__main__":
133
+ app()
beaver/cli/blobs.py ADDED
@@ -0,0 +1,225 @@
1
+ import json
2
+ from pathlib import Path
3
+ import typer
4
+ import rich
5
+ import rich.table
6
+ from typing_extensions import Annotated
7
+ from typing import Optional
8
+
9
+ from beaver import BeaverDB
10
+
11
+ app = typer.Typer(
12
+ name="blob",
13
+ help="Interact with blob stores. (e.g., beaver blob assets put my-file.png /path/to/file.png)",
14
+ )
15
+
16
+
17
+ def _get_db(ctx: typer.Context) -> BeaverDB:
18
+ """Helper to get the DB instance from the main context."""
19
+ return ctx.find_object(dict)["db"]
20
+
21
+
22
+ def _parse_metadata(metadata_str: Optional[str]) -> Optional[dict]:
23
+ """Parses the metadata string as JSON."""
24
+ if metadata_str is None:
25
+ return None
26
+ if not (metadata_str.startswith("{") or metadata_str.startswith("[")):
27
+ raise typer.BadParameter(
28
+ 'Metadata must be valid JSON (e.g., \'{"key":"value"}\')'
29
+ )
30
+ try:
31
+ return json.loads(metadata_str)
32
+ except json.JSONDecodeError:
33
+ raise typer.BadParameter("Invalid JSON format for metadata.")
34
+
35
+
36
+ @app.callback(invoke_without_command=True)
37
+ def blob_main(
38
+ ctx: typer.Context,
39
+ name: Annotated[
40
+ Optional[str],
41
+ typer.Argument(help="The name of the blob store to interact with."),
42
+ ] = None,
43
+ ):
44
+ """
45
+ Manage binary blob stores.
46
+
47
+ If no name is provided, lists all available blob stores.
48
+ """
49
+ db = _get_db(ctx)
50
+
51
+ if name is None:
52
+ # No name given, so list all blob stores
53
+ rich.print("[bold]Available Blob Stores:[/bold]")
54
+ try:
55
+ blob_names = db.blobs
56
+ if not blob_names:
57
+ rich.print(" (No blob stores found)")
58
+ else:
59
+ for blob_name in blob_names:
60
+ rich.print(f" • {blob_name}")
61
+ rich.print(
62
+ "\n[bold]Usage:[/bold] beaver blob [bold]<NAME>[/bold] [COMMAND]"
63
+ )
64
+ return
65
+ except Exception as e:
66
+ rich.print(f"[bold red]Error querying blob stores:[/] {e}")
67
+ raise typer.Exit(code=1)
68
+
69
+ # A name was provided, store it in the context for subcommands
70
+ ctx.obj = {"name": name, "db": db}
71
+
72
+ if ctx.invoked_subcommand is None:
73
+ # A name was given, but no command
74
+ try:
75
+ count = len(db.blob(name))
76
+ rich.print(f"Blob Store '[bold]{name}[/bold]' contains {count} items.")
77
+ rich.print("\n[bold]Commands:[/bold]")
78
+ rich.print(" put, get, del, list, dump")
79
+ rich.print(
80
+ f"\nRun [bold]beaver blob {name} --help[/bold] for command-specific options."
81
+ )
82
+ except Exception as e:
83
+ rich.print(f"[bold red]Error:[/] {e}")
84
+ raise typer.Exit(code=1)
85
+ raise typer.Exit()
86
+
87
+
88
+ @app.command()
89
+ def put(
90
+ ctx: typer.Context,
91
+ key: Annotated[str, typer.Argument(help="The unique key to store the blob under.")],
92
+ file_path: Annotated[
93
+ Path,
94
+ typer.Argument(
95
+ exists=True,
96
+ dir_okay=False,
97
+ readable=True,
98
+ help="The path to the file to upload.",
99
+ ),
100
+ ],
101
+ metadata: Annotated[
102
+ Optional[str], typer.Option(help="Optional metadata as a JSON string.")
103
+ ] = None,
104
+ ):
105
+ """
106
+ Put (upload) a file into the blob store.
107
+ """
108
+ db = ctx.obj["db"]
109
+ name = ctx.obj["name"]
110
+ try:
111
+ parsed_metadata = _parse_metadata(metadata)
112
+
113
+ with open(file_path, "rb") as f:
114
+ file_bytes = f.read()
115
+
116
+ db.blob(name).put(key, file_bytes, metadata=parsed_metadata)
117
+ rich.print(
118
+ f"[green]Success:[/] File '{file_path}' stored as key '{key}' in blob store '{name}'."
119
+ )
120
+ except Exception as e:
121
+ rich.print(f"[bold red]Error:[/] {e}")
122
+ raise typer.Exit(code=1)
123
+
124
+
125
+ @app.command()
126
+ def get(
127
+ ctx: typer.Context,
128
+ key: Annotated[str, typer.Argument(help="The key of the blob to retrieve.")],
129
+ output_path: Annotated[
130
+ Path,
131
+ typer.Argument(
132
+ exists=True,
133
+ dir_okay=False,
134
+ readable=True,
135
+ help="The path to the file to upload.",
136
+ ),
137
+ ],
138
+ ):
139
+ """
140
+ Get (download) a file from the blob store.
141
+ """
142
+ db = ctx.obj["db"]
143
+ name = ctx.obj["name"]
144
+ try:
145
+ blob = db.blob(name).get(key)
146
+ if blob is None:
147
+ rich.print(f"[bold red]Error:[/] Key not found: '{key}'")
148
+ raise typer.Exit(code=1)
149
+
150
+ with open(output_path, "wb") as f:
151
+ f.write(blob.data)
152
+
153
+ rich.print(f"[green]Success:[/] Blob '{key}' saved to '{output_path}'.")
154
+ if blob.metadata:
155
+ rich.print("[bold]Metadata:[/bold]")
156
+ if isinstance(blob.metadata, (dict, list)):
157
+ rich.print_json(data=blob.metadata)
158
+ else:
159
+ rich.print(str(blob.metadata))
160
+
161
+ except Exception as e:
162
+ rich.print(f"[bold red]Error:[/] {e}")
163
+ raise typer.Exit(code=1)
164
+
165
+
166
+ @app.command(name="del")
167
+ def delete(
168
+ ctx: typer.Context,
169
+ key: Annotated[str, typer.Argument(help="The key of the blob to delete.")],
170
+ ):
171
+ """
172
+ Delete a blob from the store.
173
+ """
174
+ db = ctx.obj["db"]
175
+ name = ctx.obj["name"]
176
+ try:
177
+ db.blob(name).delete(key)
178
+ rich.print(f"[green]Success:[/] Blob '{key}' deleted from store '{name}'.")
179
+ except KeyError:
180
+ rich.print(f"[bold red]Error:[/] Key not found: '{key}'")
181
+ raise typer.Exit(code=1)
182
+ except Exception as e:
183
+ rich.print(f"[bold red]Error:[/] {e}")
184
+ raise typer.Exit(code=1)
185
+
186
+
187
+ @app.command(name="list")
188
+ def list_keys(ctx: typer.Context):
189
+ """
190
+ List all keys in the blob store.
191
+ """
192
+ db = ctx.obj["db"]
193
+ name = ctx.obj["name"]
194
+ try:
195
+ # The __iter__ for BlobManager yields keys
196
+ all_keys = list(db.blob(name))
197
+ if not all_keys:
198
+ rich.print(f"Blob store '{name}' is empty.")
199
+ return
200
+
201
+ table = rich.table.Table(title=f"Keys in Blob Store: [bold]{name}[/bold]")
202
+ table.add_column("Key")
203
+
204
+ for key in all_keys:
205
+ table.add_row(key)
206
+ rich.print(table)
207
+
208
+ except Exception as e:
209
+ rich.print(f"[bold red]Error:[/] {e}")
210
+ raise typer.Exit(code=1)
211
+
212
+
213
+ @app.command()
214
+ def dump(ctx: typer.Context):
215
+ """
216
+ Dump the entire blob store as JSON (with data as base64).
217
+ """
218
+ db = ctx.obj["db"]
219
+ name = ctx.obj["name"]
220
+ try:
221
+ dump_data = db.blob(name).dump()
222
+ rich.print_json(data=dump_data)
223
+ except Exception as e:
224
+ rich.print(f"[bold red]Error:[/] {e}")
225
+ raise typer.Exit(code=1)