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/cli/channels.py ADDED
@@ -0,0 +1,166 @@
1
+ import json
2
+ import typer
3
+ import rich
4
+ from typing_extensions import Annotated
5
+ from typing import Optional
6
+ from datetime import datetime
7
+
8
+ from beaver import BeaverDB
9
+
10
+ app = typer.Typer(
11
+ name="channel",
12
+ help="Interact with pub/sub channels. (e.g., beaver channel events publish 'msg')",
13
+ )
14
+
15
+
16
+ def _get_db(ctx: typer.Context) -> BeaverDB:
17
+ """Helper to get the DB instance from the main context."""
18
+ return ctx.find_object(dict)["db"]
19
+
20
+
21
+ def _parse_value(value: str):
22
+ """
23
+ Intelligently parses the input string.
24
+ - Tries to parse as JSON if it starts with '{' or '['.
25
+ - Tries to parse as int, then float.
26
+ - Checks for 'true'/'false'/'null'.
27
+ - Defaults to a plain string.
28
+ """
29
+ # 1. Try JSON object or array
30
+ if value.startswith("{") or value.startswith("["):
31
+ try:
32
+ return json.loads(value)
33
+ except json.JSONDecodeError:
34
+ # It's not valid JSON, so treat it as a string
35
+ return value
36
+
37
+ # 2. Try boolean
38
+ if value.lower() == "true":
39
+ return True
40
+ if value.lower() == "false":
41
+ return False
42
+
43
+ # 3. Try null
44
+ if value.lower() == "null":
45
+ return None
46
+
47
+ # 4. Try int
48
+ try:
49
+ return int(value)
50
+ except ValueError:
51
+ pass
52
+
53
+ # 5. Try float
54
+ try:
55
+ return float(value)
56
+ except ValueError:
57
+ pass
58
+
59
+ # 6. Default to string (remove quotes if user added them)
60
+ if len(value) >= 2 and value.startswith('"') and value.endswith('"'):
61
+ return value[1:-1]
62
+
63
+ return value
64
+
65
+
66
+ @app.callback(invoke_without_command=True)
67
+ def channel_main(
68
+ ctx: typer.Context,
69
+ name: Annotated[
70
+ Optional[str], typer.Argument(help="The name of the channel to interact with.")
71
+ ] = None,
72
+ ):
73
+ """
74
+ Manage pub/sub channels.
75
+
76
+ If no name is provided, lists all channels that have a message history.
77
+ """
78
+ db = _get_db(ctx)
79
+
80
+ if name is None:
81
+ # No name given, so list all channels
82
+ rich.print("[bold]Available Channels (with history):[/bold]")
83
+ try:
84
+ channel_names = db.channels
85
+ if not channel_names:
86
+ rich.print(" (No channels with messages found in log)")
87
+ else:
88
+ for channel_name in channel_names:
89
+ rich.print(f" • {channel_name}")
90
+ rich.print(
91
+ "\n[bold]Usage:[/bold] beaver channel [bold]<NAME>[/bold] [COMMAND]"
92
+ )
93
+ return
94
+ except Exception as e:
95
+ rich.print(f"[bold red]Error querying channels:[/] {e}")
96
+ raise typer.Exit(code=1)
97
+
98
+ # A name was provided, store it in the context for subcommands
99
+ ctx.obj = {"name": name, "db": db}
100
+
101
+ if ctx.invoked_subcommand is None:
102
+ # A name was given, but no command
103
+ rich.print(f"Channel '[bold]{name}[/bold]'.")
104
+ rich.print("\n[bold]Commands:[/bold]")
105
+ rich.print(" publish, listen")
106
+ rich.print(
107
+ f"\nRun [bold]beaver channel {name} --help[/bold] for command-specific options."
108
+ )
109
+ raise typer.Exit()
110
+
111
+
112
+ @app.command()
113
+ def publish(
114
+ ctx: typer.Context,
115
+ message: Annotated[
116
+ str, typer.Argument(help="The message to publish (JSON, string, number, etc.).")
117
+ ],
118
+ ):
119
+ """
120
+ Publish a message to the channel.
121
+ """
122
+ db = ctx.obj["db"]
123
+ name = ctx.obj["name"]
124
+ try:
125
+ parsed_message = _parse_value(message)
126
+ db.channel(name).publish(parsed_message)
127
+ rich.print(f"[green]Success:[/] Message published to channel '{name}'.")
128
+ except Exception as e:
129
+ rich.print(f"[bold red]Error:[/] {e}")
130
+ raise typer.Exit(code=1)
131
+
132
+
133
+ @app.command()
134
+ def listen(ctx: typer.Context):
135
+ """
136
+ Listen for new messages on the channel in real-time.
137
+ """
138
+ db = ctx.obj["db"]
139
+ name = ctx.obj["name"]
140
+
141
+ rich.print(
142
+ f"[cyan]Listening to channel '[bold]{name}[/bold]'... Press Ctrl+C to stop.[/cyan]"
143
+ )
144
+
145
+ try:
146
+ with db.channel(name).subscribe() as listener:
147
+ for message in listener.listen():
148
+ now = datetime.now().strftime("%H:%M:%S")
149
+
150
+ if isinstance(message, (dict, list)):
151
+ message_str = json.dumps(message)
152
+ rich.print(
153
+ f"[dim]{now}[/dim] [bold yellow]►[/bold yellow] {message_str}"
154
+ )
155
+ else:
156
+ message_str = str(message)
157
+ rich.print(
158
+ f"[dim]{now}[/dim] [bold yellow]►[/bold yellow] {message_str}"
159
+ )
160
+
161
+ except KeyboardInterrupt:
162
+ rich.print("\n[cyan]Stopping listener...[/cyan]")
163
+ raise typer.Exit()
164
+ except Exception as e:
165
+ rich.print(f"[bold red]Error:[/] {e}")
166
+ raise typer.Exit(code=1)
@@ -0,0 +1,500 @@
1
+ import json
2
+ import os
3
+ import typer
4
+ import rich
5
+ import rich.table
6
+ from typing_extensions import Annotated
7
+ from typing import Optional, List, Any
8
+
9
+ # Import Document and WalkDirection for graph commands
10
+ from beaver import BeaverDB, Document
11
+
12
+ app = typer.Typer(
13
+ name="collection",
14
+ help="Interact with document collections (vector, text, graph). (e.g., beaver collection articles match 'python')",
15
+ )
16
+
17
+ # --- Helper Functions ---
18
+
19
+
20
+ def _get_db(ctx: typer.Context) -> BeaverDB:
21
+ """Helper to get the DB instance from the main context."""
22
+ return ctx.find_object(dict)["db"]
23
+
24
+
25
+ def _parse_json_or_file(input_str: str) -> Any:
26
+ """
27
+ Tries to read as a file path. If that fails, tries to parse as JSON.
28
+ If both fail, raises an error.
29
+ """
30
+ try:
31
+ if os.path.exists(input_str):
32
+ with open(input_str, "r") as f:
33
+ return json.load(f)
34
+ except Exception:
35
+ pass # Not a valid file path, or can't be read
36
+
37
+ # Not a file, try to parse as JSON string
38
+ try:
39
+ return json.loads(input_str)
40
+ except json.JSONDecodeError:
41
+ # Re-try by wrapping in quotes, in case it's a plain string
42
+ try:
43
+ return json.loads(f'"{input_str}"')
44
+ except json.JSONDecodeError:
45
+ raise typer.BadParameter(
46
+ f"Input '{input_str}' is not a valid file path or a valid JSON string."
47
+ )
48
+
49
+
50
+ def _truncate(text: str, length: int = 100) -> str:
51
+ """Truncates a string and adds an ellipsis if needed."""
52
+ if len(text) > length:
53
+ return text[:length] + "..."
54
+ return text
55
+
56
+
57
+ # --- Main Callback and Commands ---
58
+
59
+
60
+ @app.callback(invoke_without_command=True)
61
+ def collection_main(
62
+ ctx: typer.Context,
63
+ name: Annotated[
64
+ Optional[str],
65
+ typer.Argument(help="The name of the collection to interact with."),
66
+ ] = None,
67
+ ):
68
+ """
69
+ Manage document collections.
70
+
71
+ If no name is provided, lists all available collections.
72
+ """
73
+ db = _get_db(ctx)
74
+
75
+ if name is None:
76
+ # No name given, so list all collections
77
+ rich.print("[bold]Available Collections:[/bold]")
78
+ try:
79
+ collection_names = db.collections
80
+ if not collection_names:
81
+ rich.print(" (No collections found)")
82
+ else:
83
+ for col_name in collection_names:
84
+ rich.print(f" • {col_name}")
85
+ rich.print(
86
+ "\n[bold]Usage:[/bold] beaver collection [bold]<NAME>[/bold] [COMMAND]"
87
+ )
88
+ return
89
+ except Exception as e:
90
+ rich.print(f"[bold red]Error querying collections:[/] {e}")
91
+ raise typer.Exit(code=1)
92
+
93
+ # A name was provided, store it in the context for subcommands
94
+ ctx.obj = {"name": name, "db": db}
95
+
96
+ if ctx.invoked_subcommand is None:
97
+ # A name was given, but no command
98
+ try:
99
+ count = len(db.collection(name))
100
+ rich.print(f"Collection '[bold]{name}[/bold]' contains {count} documents.")
101
+ rich.print("\n[bold]Commands:[/bold]")
102
+ rich.print(
103
+ " get, index, drop, items, match, search, connect, neighboors, walk, dump"
104
+ )
105
+ rich.print(
106
+ f"\nRun [bold]beaver collection {name} --help[/bold] for command-specific options."
107
+ )
108
+ except Exception as e:
109
+ rich.print(f"[bold red]Error:[/] {e}")
110
+ raise typer.Exit(code=1)
111
+ raise typer.Exit()
112
+
113
+
114
+ @app.command()
115
+ def get(
116
+ ctx: typer.Context,
117
+ doc_id: Annotated[str, typer.Argument(help="The ID of the document to retrieve.")],
118
+ ):
119
+ """
120
+ Get and print a single document by its ID.
121
+ """
122
+ db = ctx.obj["db"]
123
+ name = ctx.obj["name"]
124
+ try:
125
+ # The CollectionManager doesn't have a .get(), so we query manually.
126
+ cursor = db.connection.cursor()
127
+ cursor.execute(
128
+ "SELECT item_id, item_vector, metadata FROM beaver_collections WHERE collection = ? AND item_id = ?",
129
+ (name, doc_id),
130
+ )
131
+ row = cursor.fetchone()
132
+ cursor.close()
133
+
134
+ if row is None:
135
+ rich.print(f"[bold red]Error:[/] Document not found: '{doc_id}'")
136
+ raise typer.Exit(code=1)
137
+
138
+ # Reconstruct the full document for printing
139
+ doc_data = json.loads(row["metadata"])
140
+ doc_data["id"] = row["item_id"]
141
+
142
+ # Handle vector decoding if numpy is available
143
+ try:
144
+ import numpy as np
145
+
146
+ doc_data["embedding"] = (
147
+ list(map(float, np.frombuffer(row["item_vector"], dtype=np.float32)))
148
+ if row["item_vector"]
149
+ else None
150
+ )
151
+ except ImportError:
152
+ doc_data["embedding"] = "N/A (requires numpy)"
153
+
154
+ rich.print_json(data=doc_data)
155
+
156
+ except Exception as e:
157
+ rich.print(f"[bold red]Error:[/] {e}")
158
+ raise typer.Exit(code=1)
159
+
160
+
161
+ @app.command()
162
+ def index(
163
+ ctx: typer.Context,
164
+ json_or_file: Annotated[
165
+ str, typer.Argument(help="A JSON string or a file path to a JSON file.")
166
+ ],
167
+ fts_on: Annotated[
168
+ Optional[str],
169
+ typer.Option(
170
+ "--fts",
171
+ help="Comma-separated list of fields for FTS (e.g., 'title,body'). Default: all string fields.",
172
+ ),
173
+ ] = None,
174
+ no_fts: Annotated[
175
+ bool, typer.Option("--no-fts", help="Disable FTS indexing for this document.")
176
+ ] = False,
177
+ fuzzy: Annotated[
178
+ bool, typer.Option("--fuzzy/--no-fuzzy", help="Enable fuzzy search indexing.")
179
+ ] = False,
180
+ ):
181
+ """
182
+ Index a new document from a JSON string or file.
183
+
184
+ The JSON object's top-level 'id' and 'embedding' keys are used for the
185
+ document ID and vector. All other keys are stored as metadata.
186
+ """
187
+ db = ctx.obj["db"]
188
+ name = ctx.obj["name"]
189
+ try:
190
+ doc_dict = _parse_json_or_file(json_or_file)
191
+ if not isinstance(doc_dict, dict):
192
+ raise typer.BadParameter("Input must be a JSON object (a dictionary).")
193
+
194
+ doc_id = doc_dict.pop("id", None)
195
+ doc_embedding = doc_dict.pop("embedding", None)
196
+
197
+ # The rest of the dict is metadata
198
+ doc = Document(id=doc_id, embedding=doc_embedding, **doc_dict)
199
+
200
+ # Determine FTS argument
201
+ if no_fts:
202
+ fts_arg = False
203
+ elif fts_on:
204
+ fts_arg = fts_on.split(",")
205
+ else:
206
+ fts_arg = True
207
+
208
+ db.collection(name).index(doc, fts=fts_arg, fuzzy=fuzzy)
209
+ rich.print(
210
+ f"[green]Success:[/] Document indexed with ID: [bold]{doc.id}[/bold]"
211
+ )
212
+
213
+ except Exception as e:
214
+ rich.print(f"[bold red]Error:[/] {e}")
215
+ raise typer.Exit(code=1)
216
+
217
+
218
+ @app.command(name="drop")
219
+ def drop_doc(
220
+ ctx: typer.Context,
221
+ doc_id: Annotated[str, typer.Argument(help="The ID of the document to delete.")],
222
+ ):
223
+ """
224
+ Delete a document from the collection by its ID.
225
+ """
226
+ db = ctx.obj["db"]
227
+ name = ctx.obj["name"]
228
+ try:
229
+ db.collection(name).drop(Document(id=doc_id))
230
+ rich.print(
231
+ f"[green]Success:[/] Document '{doc_id}' dropped from collection '{name}'."
232
+ )
233
+ except Exception as e:
234
+ rich.print(f"[bold red]Error:[/] {e}")
235
+ raise typer.Exit(code=1)
236
+
237
+
238
+ @app.command()
239
+ def items(ctx: typer.Context):
240
+ """
241
+ List all items in the collection.
242
+ """
243
+ db = ctx.obj["db"]
244
+ name = ctx.obj["name"]
245
+ try:
246
+ all_items = list(db.collection(name))
247
+ if not all_items:
248
+ rich.print(f"Collection '{name}' is empty.")
249
+ return
250
+
251
+ table = rich.table.Table(title=f"Documents in Collection: [bold]{name}[/bold]")
252
+ table.add_column("ID", style="cyan", no_wrap=True)
253
+ table.add_column("Embedding", style="magenta", justify="center")
254
+ table.add_column("Metadata (Summary)")
255
+
256
+ for doc in all_items:
257
+ metadata_dict = doc.to_dict() # This returns only metadata
258
+ metadata_str = json.dumps(metadata_dict)
259
+ embedding_str = (
260
+ f"{len(doc.embedding)}d" if doc.embedding is not None else "None"
261
+ )
262
+ table.add_row(doc.id, embedding_str, _truncate(metadata_str))
263
+
264
+ rich.print(table)
265
+
266
+ except Exception as e:
267
+ rich.print(f"[bold red]Error:[/] {e}")
268
+ raise typer.Exit(code=1)
269
+
270
+
271
+ @app.command()
272
+ def match(
273
+ ctx: typer.Context,
274
+ query: Annotated[str, typer.Argument(help="The search query text.")],
275
+ on: Annotated[
276
+ Optional[str],
277
+ typer.Option(
278
+ help="Comma-separated list of fields to search (e.g., 'title,body')."
279
+ ),
280
+ ] = None,
281
+ fuzziness: Annotated[
282
+ int, typer.Option(help="Fuzziness level (0=exact, 1-2=typos).")
283
+ ] = 0,
284
+ top_k: Annotated[int, typer.Option("--k", help="Number of results to return.")] = 5,
285
+ ):
286
+ """
287
+ Perform a full-text (or fuzzy) search.
288
+ """
289
+ db = ctx.obj["db"]
290
+ name = ctx.obj["name"]
291
+ try:
292
+ on_list = on.split(",") if on else None
293
+ results = db.collection(name).match(
294
+ query, on=on_list, top_k=top_k, fuzziness=fuzziness
295
+ )
296
+
297
+ if not results:
298
+ rich.print("No results found.")
299
+ return
300
+
301
+ score_title = "Distance" if fuzziness > 0 else "Rank"
302
+ table = rich.table.Table(title=f"Search Results for: [bold]'{query}'[/bold]")
303
+ table.add_column("ID", style="cyan", no_wrap=True)
304
+ table.add_column(score_title, style="magenta", justify="right")
305
+ table.add_column("Metadata (Summary)")
306
+
307
+ for doc, score in results:
308
+ metadata_str = json.dumps(doc.to_dict())
309
+ table.add_row(doc.id, f"{score:.4f}", _truncate(metadata_str))
310
+
311
+ rich.print(table)
312
+
313
+ except Exception as e:
314
+ rich.print(f"[bold red]Error:[/] {e}")
315
+ raise typer.Exit(code=1)
316
+
317
+
318
+ @app.command()
319
+ def search(
320
+ ctx: typer.Context,
321
+ vector_json: Annotated[
322
+ str,
323
+ typer.Argument(help="The query vector as a JSON list (e.g., '[0.1, 0.2]')."),
324
+ ],
325
+ top_k: Annotated[int, typer.Option("--k", help="Number of results to return.")] = 5,
326
+ ):
327
+ """
328
+ Perform an approximate nearest neighbor (vector) search.
329
+ Requires 'beaver-db[vector]' to be installed.
330
+ """
331
+ db = ctx.obj["db"]
332
+ name = ctx.obj["name"]
333
+ try:
334
+ vector_list = _parse_json_or_file(vector_json)
335
+ if not isinstance(vector_list, list):
336
+ raise typer.BadParameter(
337
+ "Input must be a JSON list (e.g., '[0.1, 0.2, 0.3]')."
338
+ )
339
+
340
+ results = db.collection(name).search(vector_list, top_k=top_k)
341
+
342
+ if not results:
343
+ rich.print("No results found.")
344
+ return
345
+
346
+ table = rich.table.Table(title="Vector Search Results")
347
+ table.add_column("ID", style="cyan", no_wrap=True)
348
+ table.add_column("Distance", style="magenta", justify="right")
349
+ table.add_column("Metadata (Summary)")
350
+
351
+ for doc, distance in results:
352
+ metadata_str = json.dumps(doc.to_dict())
353
+ table.add_row(doc.id, f"{distance:.4f}", _truncate(metadata_str))
354
+
355
+ rich.print(table)
356
+
357
+ except ImportError:
358
+ rich.print("[bold red]Error:[/] Vector search requires 'beaver-db[vector]'.")
359
+ rich.print('Please install it with: pip install "beaver-db[vector]"')
360
+ raise typer.Exit(code=1)
361
+ except Exception as e:
362
+ rich.print(f"[bold red]Error:[/] {e}")
363
+ raise typer.Exit(code=1)
364
+
365
+
366
+ @app.command()
367
+ def connect(
368
+ ctx: typer.Context,
369
+ source_id: Annotated[str, typer.Argument(help="The ID of the source document.")],
370
+ target_id: Annotated[str, typer.Argument(help="The ID of the target document.")],
371
+ label: Annotated[
372
+ str, typer.Argument(help="The label for the relationship (e.g., 'FOLLOWS').")
373
+ ],
374
+ metadata: Annotated[
375
+ Optional[str], typer.Option(help="Optional metadata as a JSON string.")
376
+ ] = None,
377
+ ):
378
+ """
379
+ Connect two documents with a directed, labeled relationship.
380
+ """
381
+ db = ctx.obj["db"]
382
+ name = ctx.obj["name"]
383
+ try:
384
+ parsed_metadata = _parse_json_or_file(metadata) if metadata else None
385
+ source_doc = Document(id=source_id)
386
+ target_doc = Document(id=target_id)
387
+
388
+ db.collection(name).connect(
389
+ source_doc, target_doc, label, metadata=parsed_metadata
390
+ )
391
+ rich.print(
392
+ f"[green]Success:[/] Connected '{source_id}' -> '{target_id}' with label '{label}'."
393
+ )
394
+
395
+ except Exception as e:
396
+ rich.print(f"[bold red]Error:[/] {e}")
397
+ raise typer.Exit(code=1)
398
+
399
+
400
+ @app.command()
401
+ def neighbors(
402
+ ctx: typer.Context,
403
+ doc_id: Annotated[str, typer.Argument(help="The ID of the source document.")],
404
+ label: Annotated[
405
+ Optional[str], typer.Option(help="Filter by edge label (e.g., 'FOLLOWS').")
406
+ ] = None,
407
+ ):
408
+ """
409
+ Find the 1-hop outgoing neighbors of a document.
410
+ """
411
+ db = ctx.obj["db"]
412
+ name = ctx.obj["name"]
413
+ try:
414
+ source_doc = Document(id=doc_id)
415
+ results = db.collection(name).neighbors(source_doc, label=label)
416
+
417
+ if not results:
418
+ rich.print(
419
+ f"No neighbors found for '{doc_id}'"
420
+ + (f" with label '{label}'." if label else ".")
421
+ )
422
+ return
423
+
424
+ table = rich.table.Table(title=f"Neighbors of: [bold]'{doc_id}'[/bold]")
425
+ table.add_column("ID", style="cyan", no_wrap=True)
426
+ table.add_column("Metadata (Summary)")
427
+
428
+ for doc in results:
429
+ metadata_str = json.dumps(doc.to_dict())
430
+ table.add_row(doc.id, _truncate(metadata_str))
431
+
432
+ rich.print(table)
433
+
434
+ except Exception as e:
435
+ rich.print(f"[bold red]Error:[/] {e}")
436
+ raise typer.Exit(code=1)
437
+
438
+
439
+ @app.command()
440
+ def walk(
441
+ ctx: typer.Context,
442
+ doc_id: Annotated[str, typer.Argument(help="The ID of the starting document.")],
443
+ labels: Annotated[
444
+ str,
445
+ typer.Option(
446
+ help="Comma-separated list of labels to follow (e.g., 'FOLLOWS,MENTIONS')."
447
+ ),
448
+ ],
449
+ depth: Annotated[int, typer.Option(help="How many steps to walk.")] = 1,
450
+ direction: Annotated[
451
+ str, typer.Option(case_sensitive=False, help="Direction of the walk.")
452
+ ] = "outgoing",
453
+ ):
454
+ """
455
+ Perform a multi-hop graph walk (BFS) from a document.
456
+ """
457
+ db = ctx.obj["db"]
458
+ name = ctx.obj["name"]
459
+ try:
460
+ source_doc = Document(id=doc_id)
461
+ label_list = labels.split(",")
462
+
463
+ results = db.collection(name).walk(
464
+ source=source_doc, labels=label_list, depth=depth, direction=direction
465
+ )
466
+
467
+ if not results:
468
+ rich.print(f"No results found for walk from '{doc_id}'.")
469
+ return
470
+
471
+ table = rich.table.Table(
472
+ title=f"Walk Results from: [bold]'{doc_id}'[/bold] (Depth: {depth})"
473
+ )
474
+ table.add_column("ID", style="cyan", no_wrap=True)
475
+ table.add_column("Metadata (Summary)")
476
+
477
+ for doc in results:
478
+ metadata_str = json.dumps(doc.to_dict())
479
+ table.add_row(doc.id, _truncate(metadata_str))
480
+
481
+ rich.print(table)
482
+
483
+ except Exception as e:
484
+ rich.print(f"[bold red]Error:[/] {e}")
485
+ raise typer.Exit(code=1)
486
+
487
+
488
+ @app.command()
489
+ def dump(ctx: typer.Context):
490
+ """
491
+ Dump the entire collection as JSON.
492
+ """
493
+ db = ctx.obj["db"]
494
+ name = ctx.obj["name"]
495
+ try:
496
+ dump_data = db.collection(name).dump()
497
+ rich.print_json(data=dump_data)
498
+ except Exception as e:
499
+ rich.print(f"[bold red]Error:[/] {e}")
500
+ raise typer.Exit(code=1)