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/logs.py ADDED
@@ -0,0 +1,248 @@
1
+ import json
2
+ import typer
3
+ import rich
4
+ import rich.table
5
+ import statistics
6
+ import time
7
+ from collections import defaultdict
8
+ from datetime import timedelta
9
+ from typing_extensions import Annotated
10
+ from typing import Optional, List, Dict, Any
11
+ from rich.live import Live
12
+ from rich.table import Table
13
+
14
+ from beaver import BeaverDB
15
+
16
+ app = typer.Typer(
17
+ name="log",
18
+ help="Interact with time-indexed logs. (e.g., beaver log errors write '{\"code\": 500}')",
19
+ )
20
+
21
+ # --- Helper Functions ---
22
+
23
+
24
+ def _get_db(ctx: typer.Context) -> BeaverDB:
25
+ """Helper to get the DB instance from the main context."""
26
+ return ctx.find_object(dict)["db"]
27
+
28
+
29
+ def _parse_value(value: str):
30
+ """
31
+ Intelligently parses the input string.
32
+ - Tries to parse as JSON if it starts with '{' or '['.
33
+ - Tries to parse as int, then float.
34
+ - Checks for 'true'/'false'/'null'.
35
+ - Defaults to a plain string.
36
+ """
37
+ # 1. Try JSON object or array
38
+ if value.startswith("{") or value.startswith("["):
39
+ try:
40
+ return json.loads(value)
41
+ except json.JSONDecodeError:
42
+ # It's not valid JSON, so treat it as a string
43
+ return value
44
+
45
+ # 2. Try boolean
46
+ if value.lower() == "true":
47
+ return True
48
+ if value.lower() == "false":
49
+ return False
50
+
51
+ # 3. Try null
52
+ if value.lower() == "null":
53
+ return None
54
+
55
+ # 4. Try int
56
+ try:
57
+ return int(value)
58
+ except ValueError:
59
+ pass
60
+
61
+ # 5. Try float
62
+ try:
63
+ return float(value)
64
+ except ValueError:
65
+ pass
66
+
67
+ # 6. Default to string (remove quotes if user added them)
68
+ if len(value) >= 2 and value.startswith('"') and value.endswith('"'):
69
+ return value[1:-1]
70
+
71
+ return value
72
+
73
+
74
+ def _build_stats_aggregator(window: List[Dict[str, Any]]) -> dict:
75
+ """
76
+ The custom aggregator function.
77
+ It processes a list of log entries and returns a stats summary.
78
+ """
79
+ total_count = len(window)
80
+ # Use defaultdict to easily build nested stats
81
+ key_stats = defaultdict(lambda: {"count": 0, "numeric_values": [], "types": set()})
82
+ non_dict_count = 0
83
+
84
+ for entry in window:
85
+ if not isinstance(entry, dict):
86
+ non_dict_count += 1
87
+ continue # Only aggregate stats for dict logs
88
+
89
+ for key, value in entry.items():
90
+ stats = key_stats[key]
91
+ stats["count"] += 1
92
+ stats["types"].add(type(value).__name__)
93
+ if isinstance(value, (int, float)):
94
+ stats["numeric_values"].append(value)
95
+
96
+ # Finalize stats
97
+ summary = {"total_count": total_count, "non_dict_count": non_dict_count, "keys": {}}
98
+ for key, stats in sorted(key_stats.items()):
99
+ key_summary = {"count": stats["count"], "types": sorted(list(stats["types"]))}
100
+ if stats["numeric_values"]:
101
+ key_summary["min"] = min(stats["numeric_values"])
102
+ key_summary["max"] = max(stats["numeric_values"])
103
+ key_summary["mean"] = statistics.mean(stats["numeric_values"])
104
+ summary["keys"][key] = key_summary
105
+
106
+ return summary
107
+
108
+
109
+ def _generate_stats_table(summary: dict, name: str, window_s: int) -> Table:
110
+ """Builds a rich.Table object from the stats summary."""
111
+
112
+ table = Table(title=f"Live Log Stats: [bold]{name}[/bold] ({window_s}s window)")
113
+ table.add_column("Key", style="cyan", no_wrap=True)
114
+ table.add_column("Count", style="magenta", justify="right")
115
+ table.add_column("Types", style="green")
116
+ table.add_column("Min", style="blue", justify="right")
117
+ table.add_column("Max", style="blue", justify="right")
118
+ table.add_column("Mean", style="blue", justify="right")
119
+
120
+ for key, stats in summary.get("keys", {}).items():
121
+ table.add_row(
122
+ key,
123
+ str(stats["count"]),
124
+ ", ".join(stats["types"]),
125
+ f"{stats.get('min', 'N/A'):.2f}" if "min" in stats else "N/A",
126
+ f"{stats.get('max', 'N/A'):.2f}" if "max" in stats else "N/A",
127
+ f"{stats.get('mean', 'N/A'):.2f}" if "mean" in stats else "N/A",
128
+ )
129
+
130
+ caption = f"Total Events: {summary['total_count']}"
131
+ if summary["non_dict_count"] > 0:
132
+ caption += f" ({summary['non_dict_count']} non-JSON-object events not shown)"
133
+ table.caption = caption
134
+ return table
135
+
136
+
137
+ # --- CLI Commands ---
138
+
139
+
140
+ @app.callback(invoke_without_command=True)
141
+ def log_main(
142
+ ctx: typer.Context,
143
+ name: Annotated[
144
+ Optional[str], typer.Argument(help="The name of the log to interact with.")
145
+ ] = None,
146
+ ):
147
+ """
148
+ Manage time-indexed logs.
149
+
150
+ If no name is provided, lists all available logs.
151
+ """
152
+ db = _get_db(ctx)
153
+
154
+ if name is None:
155
+ rich.print("[bold]Available Logs:[/bold]")
156
+ try:
157
+ log_names = db.logs
158
+ if not log_names:
159
+ rich.print(" (No logs found)")
160
+ else:
161
+ for log_name in log_names:
162
+ rich.print(f" • {log_name}")
163
+ rich.print("\n[bold]Usage:[/bold] beaver log [bold]<NAME>[/bold] [COMMAND]")
164
+ return
165
+ except Exception as e:
166
+ rich.print(f"[bold red]Error querying logs:[/] {e}")
167
+ raise typer.Exit(code=1)
168
+
169
+ ctx.obj = {"name": name, "db": db}
170
+
171
+ if ctx.invoked_subcommand is None:
172
+ rich.print(f"Log '[bold]{name}[/bold]'.")
173
+ rich.print("\n[bold]Commands:[/bold]")
174
+ rich.print(" write, watch")
175
+ rich.print(
176
+ f"\nRun [bold]beaver log {name} --help[/bold] for command-specific options."
177
+ )
178
+ raise typer.Exit()
179
+
180
+
181
+ @app.command()
182
+ def write(
183
+ ctx: typer.Context,
184
+ data: Annotated[
185
+ str,
186
+ typer.Argument(
187
+ help="The data to log (e.g., '{\"a\": 1}', '\"my string\"', '123.45', 'true')."
188
+ ),
189
+ ],
190
+ ):
191
+ """
192
+ Write a new data entry to the log.
193
+
194
+ The data will be parsed as JSON, a number, a boolean, or a string.
195
+ """
196
+ db = ctx.obj["db"]
197
+ name = ctx.obj["name"]
198
+ try:
199
+ parsed_data = _parse_value(data)
200
+ db.log(name).log(parsed_data)
201
+ rich.print(f"[green]Success:[/] Log entry added to '{name}'.")
202
+ except Exception as e:
203
+ rich.print(f"[bold red]Error:[/] {e}")
204
+ raise typer.Exit(code=1)
205
+
206
+
207
+ @app.command()
208
+ def watch(
209
+ ctx: typer.Context,
210
+ window: Annotated[
211
+ int, typer.Option("--window", help="Time window in seconds to aggregate over.")
212
+ ] = 60,
213
+ frequency: Annotated[
214
+ int, typer.Option("--frequency", help="Time in seconds between updates.")
215
+ ] = 1,
216
+ ):
217
+ """
218
+ Watch a live, aggregated view of JSON log entries.
219
+
220
+ This command only processes logs that are JSON objects and provides
221
+ basic statistics for the keys found in those objects.
222
+ """
223
+ db: BeaverDB = ctx.obj["db"]
224
+ name = ctx.obj["name"]
225
+
226
+ try:
227
+ log_manager = db.log(name)
228
+ live_stream = log_manager.live(
229
+ window=timedelta(seconds=window),
230
+ period=timedelta(seconds=frequency),
231
+ aggregator=_build_stats_aggregator,
232
+ )
233
+
234
+ rich.print(
235
+ f"[cyan]Watching log '[bold]{name}[/bold]' (Window: {window}s, Freq: {frequency}s)... Press Ctrl+C to stop.[/cyan]"
236
+ )
237
+
238
+ # Use screen=True to create a new buffer and avoid flickering
239
+ with Live(screen=True, refresh_per_second=4, transient=True) as live:
240
+ for summary in live_stream:
241
+ live.update(_generate_stats_table(summary, name, window))
242
+
243
+ except KeyboardInterrupt:
244
+ rich.print("\n[cyan]Stopping watcher...[/cyan]")
245
+ raise typer.Exit()
246
+ except Exception as e:
247
+ rich.print(f"[bold red]Error:[/] {e}")
248
+ raise typer.Exit(code=1)
beaver/cli/queues.py ADDED
@@ -0,0 +1,215 @@
1
+ import json
2
+ import typer
3
+ import rich
4
+ import rich.table
5
+ from typing_extensions import Annotated
6
+ from typing import Optional
7
+
8
+ from beaver import BeaverDB
9
+
10
+ app = typer.Typer(
11
+ name="queue",
12
+ help="Interact with persistent priority queues. (e.g., beaver queue my-tasks put 1 'new task')",
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
+ """Parses the value string as JSON if appropriate."""
23
+ if value.startswith("{") or value.startswith("["):
24
+ try:
25
+ return json.loads(value)
26
+ except json.JSONDecodeError:
27
+ return value
28
+ return value
29
+
30
+
31
+ @app.callback(invoke_without_command=True)
32
+ def queue_main(
33
+ ctx: typer.Context,
34
+ name: Annotated[
35
+ Optional[str], typer.Argument(help="The name of the queue to interact with.")
36
+ ] = None,
37
+ ):
38
+ """
39
+ Manage persistent priority queues.
40
+
41
+ If no name is provided, lists all available queues.
42
+ """
43
+ db = _get_db(ctx)
44
+
45
+ if name is None:
46
+ # No name given, so list all queues
47
+ rich.print("[bold]Available Queues:[/bold]")
48
+ try:
49
+ queue_names = db.queues
50
+ if not queue_names:
51
+ rich.print(" (No queues found)")
52
+ else:
53
+ for queue_name in queue_names:
54
+ rich.print(f" • {queue_name}")
55
+ rich.print(
56
+ "\n[bold]Usage:[/bold] beaver queue [bold]<NAME>[/bold] [COMMAND]"
57
+ )
58
+ return
59
+ except Exception as e:
60
+ rich.print(f"[bold red]Error querying queues:[/] {e}")
61
+ raise typer.Exit(code=1)
62
+
63
+ # A name was provided, store it in the context for subcommands
64
+ ctx.obj = {"name": name, "db": db}
65
+
66
+ if ctx.invoked_subcommand is None:
67
+ # A name was given, but no command
68
+ try:
69
+ count = len(db.queue(name))
70
+ rich.print(f"Queue '[bold]{name}[/bold]' contains {count} items.")
71
+ rich.print("\n[bold]Commands:[/bold]")
72
+ rich.print(" put, get, peek, show, dump")
73
+ rich.print(
74
+ f"\nRun [bold]beaver queue {name} --help[/bold] for command-specific options."
75
+ )
76
+ except Exception as e:
77
+ rich.print(f"[bold red]Error:[/] {e}")
78
+ raise typer.Exit(code=1)
79
+ raise typer.Exit()
80
+
81
+
82
+ @app.command()
83
+ def put(
84
+ ctx: typer.Context,
85
+ priority: Annotated[
86
+ float,
87
+ typer.Argument(help="The item priority (float). Lower is higher priority."),
88
+ ],
89
+ value: Annotated[str, typer.Argument(help="The value to add (JSON or string).")],
90
+ ):
91
+ """
92
+ Add (put) an item into the queue with a specific priority.
93
+ """
94
+ db = ctx.obj["db"]
95
+ name = ctx.obj["name"]
96
+ try:
97
+ parsed_value = _parse_value(value)
98
+ db.queue(name).put(parsed_value, priority=priority)
99
+ rich.print(
100
+ f"[green]Success:[/] Item added to queue '{name}' with priority {priority}."
101
+ )
102
+ except Exception as e:
103
+ rich.print(f"[bold red]Error:[/] {e}")
104
+ raise typer.Exit(code=1)
105
+
106
+
107
+ @app.command()
108
+ def get(
109
+ ctx: typer.Context,
110
+ block: Annotated[
111
+ bool,
112
+ typer.Option("--block/--no-block", help="Block until an item is available."),
113
+ ] = True,
114
+ timeout: Annotated[
115
+ Optional[float], typer.Option(help="Max seconds to block. Requires --block.")
116
+ ] = 5.0,
117
+ ):
118
+ """
119
+ Get and remove the highest-priority item from the queue.
120
+ """
121
+ db = ctx.obj["db"]
122
+ name = ctx.obj["name"]
123
+ try:
124
+ if block:
125
+ rich.print(f"Waiting for item from '{name}' (timeout={timeout}s)...")
126
+
127
+ item = db.queue(name).get(block=block, timeout=timeout)
128
+
129
+ rich.print(f"[green]Got item (Priority: {item.priority}):[/green]")
130
+ if isinstance(item.data, (dict, list)):
131
+ rich.print_json(data=item.data)
132
+ else:
133
+ rich.print(item.data)
134
+
135
+ except IndexError:
136
+ rich.print(f"Queue '{name}' is empty (non-blocking get).")
137
+ except TimeoutError:
138
+ rich.print(
139
+ f"[bold yellow]Timeout:[/] No item received from queue '{name}' after {timeout}s."
140
+ )
141
+ except Exception as e:
142
+ rich.print(f"[bold red]Error:[/] {e}")
143
+ raise typer.Exit(code=1)
144
+
145
+
146
+ @app.command()
147
+ def peek(ctx: typer.Context):
148
+ """
149
+ View the highest-priority item without removing it.
150
+ """
151
+ db = ctx.obj["db"]
152
+ name = ctx.obj["name"]
153
+ try:
154
+ item = db.queue(name).peek()
155
+ if item is None:
156
+ rich.print(f"Queue '{name}' is empty.")
157
+ return
158
+
159
+ rich.print(f"[green]Next item (Priority: {item.priority}):[/green]")
160
+ if isinstance(item.data, (dict, list)):
161
+ rich.print_json(data=item.data)
162
+ else:
163
+ rich.print(item.data)
164
+
165
+ except Exception as e:
166
+ rich.print(f"[bold red]Error:[/] {e}")
167
+ raise typer.Exit(code=1)
168
+
169
+
170
+ @app.command()
171
+ def show(ctx: typer.Context):
172
+ """
173
+ Print all items in the queue, in priority order.
174
+ """
175
+ db = ctx.obj["db"]
176
+ name = ctx.obj["name"]
177
+ try:
178
+ # The __iter__ for QueueManager yields all items in order
179
+ all_items = list(db.queue(name))
180
+ if not all_items:
181
+ rich.print(f"Queue '{name}' is empty.")
182
+ return
183
+
184
+ table = rich.table.Table(title=f"Items in Queue: [bold]{name}[/bold]")
185
+ table.add_column("Priority", style="cyan", justify="right")
186
+ table.add_column("Timestamp", style="magenta")
187
+ table.add_column("Data")
188
+
189
+ for item in all_items:
190
+ data_str = (
191
+ json.dumps(item.data)
192
+ if isinstance(item.data, (dict, list))
193
+ else str(item.data)
194
+ )
195
+ table.add_row(str(item.priority), str(item.timestamp), data_str)
196
+ rich.print(table)
197
+
198
+ except Exception as e:
199
+ rich.print(f"[bold red]Error:[/] {e}")
200
+ raise typer.Exit(code=1)
201
+
202
+
203
+ @app.command()
204
+ def dump(ctx: typer.Context):
205
+ """
206
+ Dump the entire queue as JSON.
207
+ """
208
+ db = ctx.obj["db"]
209
+ name = ctx.obj["name"]
210
+ try:
211
+ dump_data = db.queue(name).dump()
212
+ rich.print_json(data=dump_data)
213
+ except Exception as e:
214
+ rich.print(f"[bold red]Error:[/] {e}")
215
+ raise typer.Exit(code=1)