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/__init__.py +16 -0
- beaver/blobs.py +223 -0
- beaver/bridge.py +167 -0
- beaver/cache.py +274 -0
- beaver/channels.py +249 -0
- beaver/cli/__init__.py +133 -0
- beaver/cli/blobs.py +225 -0
- beaver/cli/channels.py +166 -0
- beaver/cli/collections.py +500 -0
- beaver/cli/dicts.py +171 -0
- beaver/cli/lists.py +244 -0
- beaver/cli/locks.py +202 -0
- beaver/cli/logs.py +248 -0
- beaver/cli/queues.py +215 -0
- beaver/client.py +392 -0
- beaver/core.py +646 -0
- beaver/dicts.py +314 -0
- beaver/docs.py +459 -0
- beaver/events.py +155 -0
- beaver/graphs.py +212 -0
- beaver/lists.py +337 -0
- beaver/locks.py +186 -0
- beaver/logs.py +187 -0
- beaver/manager.py +203 -0
- beaver/queries.py +66 -0
- beaver/queues.py +215 -0
- beaver/security.py +144 -0
- beaver/server.py +452 -0
- beaver/sketches.py +307 -0
- beaver/types.py +32 -0
- beaver/vectors.py +198 -0
- beaver_db-2.0rc2.dist-info/METADATA +149 -0
- beaver_db-2.0rc2.dist-info/RECORD +36 -0
- beaver_db-2.0rc2.dist-info/WHEEL +4 -0
- beaver_db-2.0rc2.dist-info/entry_points.txt +2 -0
- beaver_db-2.0rc2.dist-info/licenses/LICENSE +21 -0
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)
|