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/cli/dicts.py
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import typer
|
|
3
|
+
import rich
|
|
4
|
+
from typing_extensions import Annotated
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
from beaver import BeaverDB
|
|
8
|
+
|
|
9
|
+
app = typer.Typer(
|
|
10
|
+
name="dict",
|
|
11
|
+
help="Interact with namespaced dictionaries. (e.g., beaver dict my-dict get my-key)",
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _parse_value(value: str):
|
|
16
|
+
"""Parses the value string as JSON if appropriate."""
|
|
17
|
+
if value.startswith("{") or value.startswith("["):
|
|
18
|
+
try:
|
|
19
|
+
return json.loads(value)
|
|
20
|
+
except json.JSONDecodeError:
|
|
21
|
+
return value
|
|
22
|
+
return value
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@app.callback(invoke_without_command=True)
|
|
26
|
+
def dict_main(
|
|
27
|
+
ctx: typer.Context,
|
|
28
|
+
name: Annotated[
|
|
29
|
+
Optional[str],
|
|
30
|
+
typer.Argument(help="The name of the dictionary to interact with."),
|
|
31
|
+
] = None,
|
|
32
|
+
):
|
|
33
|
+
"""
|
|
34
|
+
Manage namespaced dictionaries.
|
|
35
|
+
|
|
36
|
+
If no name is provided, lists all available dictionaries.
|
|
37
|
+
"""
|
|
38
|
+
db = ctx.obj["db"]
|
|
39
|
+
|
|
40
|
+
if db is None:
|
|
41
|
+
rich.print(f"[bold red]Database not found![/]")
|
|
42
|
+
return
|
|
43
|
+
|
|
44
|
+
if name is None:
|
|
45
|
+
# No name given, so list all dicts
|
|
46
|
+
rich.print("[bold]Available Dictionaries:[/bold]")
|
|
47
|
+
try:
|
|
48
|
+
dict_names = db.dicts
|
|
49
|
+
if not dict_names:
|
|
50
|
+
rich.print(" (No dictionaries found)")
|
|
51
|
+
else:
|
|
52
|
+
for dict_name in dict_names:
|
|
53
|
+
rich.print(f" • {dict_name}")
|
|
54
|
+
rich.print(
|
|
55
|
+
"\n[bold]Usage:[/bold] beaver dict [bold]<NAME>[/bold] [COMMAND]"
|
|
56
|
+
)
|
|
57
|
+
return
|
|
58
|
+
except Exception as e:
|
|
59
|
+
rich.print(f"[bold red]Error querying dictionaries:[/] {e}")
|
|
60
|
+
raise typer.Exit(code=1)
|
|
61
|
+
|
|
62
|
+
# A name was provided, store it in the context for subcommands
|
|
63
|
+
ctx.obj = {"name": name, "db": db}
|
|
64
|
+
|
|
65
|
+
if ctx.invoked_subcommand is None:
|
|
66
|
+
# A name was given, but no command (e.g., "beaver dict my-dict")
|
|
67
|
+
rich.print(f"No command specified for dictionary '[bold]{name}[/bold]'.")
|
|
68
|
+
rich.print("\n[bold]Commands:[/bold]")
|
|
69
|
+
rich.print(" get, set, del, keys, dump")
|
|
70
|
+
rich.print(
|
|
71
|
+
f"\nRun [bold]beaver dict {name} --help[/bold] for command-specific options."
|
|
72
|
+
)
|
|
73
|
+
raise typer.Exit()
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@app.command()
|
|
77
|
+
def get(
|
|
78
|
+
ctx: typer.Context, key: Annotated[str, typer.Argument(help="The key to retrieve.")]
|
|
79
|
+
):
|
|
80
|
+
"""
|
|
81
|
+
Get a value by key.
|
|
82
|
+
"""
|
|
83
|
+
db = ctx.obj["db"]
|
|
84
|
+
name = ctx.obj["name"]
|
|
85
|
+
try:
|
|
86
|
+
value = db.dict(name).get(key)
|
|
87
|
+
if value is None:
|
|
88
|
+
rich.print(f"[bold red]Error:[/] Key not found: '{key}'")
|
|
89
|
+
raise typer.Exit(code=1)
|
|
90
|
+
|
|
91
|
+
if isinstance(value, (dict, list)):
|
|
92
|
+
rich.print_json(data=value)
|
|
93
|
+
else:
|
|
94
|
+
rich.print(value)
|
|
95
|
+
except Exception as e:
|
|
96
|
+
rich.print(f"[bold red]Error:[/] {e}")
|
|
97
|
+
raise typer.Exit(code=1)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@app.command()
|
|
101
|
+
def set(
|
|
102
|
+
ctx: typer.Context,
|
|
103
|
+
key: Annotated[str, typer.Argument(help="The key to set.")],
|
|
104
|
+
value: Annotated[str, typer.Argument(help="The value (JSON or string).")],
|
|
105
|
+
):
|
|
106
|
+
"""
|
|
107
|
+
Set a value for a key.
|
|
108
|
+
"""
|
|
109
|
+
db = ctx.obj["db"]
|
|
110
|
+
name = ctx.obj["name"]
|
|
111
|
+
try:
|
|
112
|
+
parsed_value = _parse_value(value)
|
|
113
|
+
db.dict(name)[key] = parsed_value
|
|
114
|
+
rich.print(f"[green]Success:[/] Key '{key}' set in dictionary '{name}'.")
|
|
115
|
+
except Exception as e:
|
|
116
|
+
rich.print(f"[bold red]Error:[/] {e}")
|
|
117
|
+
raise typer.Exit(code=1)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@app.command(name="del")
|
|
121
|
+
def delete(
|
|
122
|
+
ctx: typer.Context, key: Annotated[str, typer.Argument(help="The key to delete.")]
|
|
123
|
+
):
|
|
124
|
+
"""
|
|
125
|
+
Delete a key from the dictionary.
|
|
126
|
+
"""
|
|
127
|
+
db = ctx.obj["db"]
|
|
128
|
+
name = ctx.obj["name"]
|
|
129
|
+
try:
|
|
130
|
+
del db.dict(name)[key]
|
|
131
|
+
rich.print(f"[green]Success:[/] Key '{key}' deleted from dictionary '{name}'.")
|
|
132
|
+
except KeyError:
|
|
133
|
+
rich.print(f"[bold red]Error:[/] Key not found: '{key}'")
|
|
134
|
+
raise typer.Exit(code=1)
|
|
135
|
+
except Exception as e:
|
|
136
|
+
rich.print(f"[bold red]Error:[/] {e}")
|
|
137
|
+
raise typer.Exit(code=1)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@app.command()
|
|
141
|
+
def keys(ctx: typer.Context):
|
|
142
|
+
"""
|
|
143
|
+
List all keys in the dictionary.
|
|
144
|
+
"""
|
|
145
|
+
db = ctx.obj["db"]
|
|
146
|
+
name = ctx.obj["name"]
|
|
147
|
+
try:
|
|
148
|
+
all_keys = list(db.dict(name).keys())
|
|
149
|
+
if not all_keys:
|
|
150
|
+
rich.print(f"Dictionary '{name}' is empty.")
|
|
151
|
+
return
|
|
152
|
+
for key in all_keys:
|
|
153
|
+
rich.print(key)
|
|
154
|
+
except Exception as e:
|
|
155
|
+
rich.print(f"[bold red]Error:[/] {e}")
|
|
156
|
+
raise typer.Exit(code=1)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@app.command()
|
|
160
|
+
def dump(ctx: typer.Context):
|
|
161
|
+
"""
|
|
162
|
+
Dump the entire dictionary as JSON.
|
|
163
|
+
"""
|
|
164
|
+
db = ctx.obj["db"]
|
|
165
|
+
name = ctx.obj["name"]
|
|
166
|
+
try:
|
|
167
|
+
dump_data = db.dict(name).dump()
|
|
168
|
+
rich.print_json(data=dump_data)
|
|
169
|
+
except Exception as e:
|
|
170
|
+
rich.print(f"[bold red]Error:[/] {e}")
|
|
171
|
+
raise typer.Exit(code=1)
|
beaver/cli/lists.py
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
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="list",
|
|
12
|
+
help="Interact with persistent lists. (e.g., beaver list my-list push 'new item')",
|
|
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 list_main(
|
|
33
|
+
ctx: typer.Context,
|
|
34
|
+
name: Annotated[
|
|
35
|
+
Optional[str], typer.Argument(help="The name of the list to interact with.")
|
|
36
|
+
] = None,
|
|
37
|
+
):
|
|
38
|
+
"""
|
|
39
|
+
Manage persistent lists.
|
|
40
|
+
|
|
41
|
+
If no name is provided, lists all available lists.
|
|
42
|
+
"""
|
|
43
|
+
db = _get_db(ctx)
|
|
44
|
+
|
|
45
|
+
if name is None:
|
|
46
|
+
# No name given, so list all lists
|
|
47
|
+
rich.print("[bold]Available Lists:[/bold]")
|
|
48
|
+
try:
|
|
49
|
+
list_names = db.lists
|
|
50
|
+
if not list_names:
|
|
51
|
+
rich.print(" (No lists found)")
|
|
52
|
+
else:
|
|
53
|
+
for list_name in list_names:
|
|
54
|
+
rich.print(f" • {list_name}")
|
|
55
|
+
rich.print(
|
|
56
|
+
"\n[bold]Usage:[/bold] beaver list [bold]<NAME>[/bold] [COMMAND]"
|
|
57
|
+
)
|
|
58
|
+
return
|
|
59
|
+
except Exception as e:
|
|
60
|
+
rich.print(f"[bold red]Error querying lists:[/] {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.list(name))
|
|
70
|
+
rich.print(f"List '[bold]{name}[/bold]' contains {count} items.")
|
|
71
|
+
rich.print("\n[bold]Commands:[/bold]")
|
|
72
|
+
rich.print(" push, pop, deque, insert, remove, items, dump")
|
|
73
|
+
rich.print(
|
|
74
|
+
f"\nRun [bold]beaver list {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 push(
|
|
84
|
+
ctx: typer.Context,
|
|
85
|
+
value: Annotated[str, typer.Argument(help="The value to add (JSON or string).")],
|
|
86
|
+
):
|
|
87
|
+
"""
|
|
88
|
+
Add (push) an item to the end of the list.
|
|
89
|
+
"""
|
|
90
|
+
db = ctx.obj["db"]
|
|
91
|
+
name = ctx.obj["name"]
|
|
92
|
+
try:
|
|
93
|
+
parsed_value = _parse_value(value)
|
|
94
|
+
db.list(name).push(parsed_value)
|
|
95
|
+
rich.print(f"[green]Success:[/] Item pushed to list '{name}'.")
|
|
96
|
+
except Exception as e:
|
|
97
|
+
rich.print(f"[bold red]Error:[/] {e}")
|
|
98
|
+
raise typer.Exit(code=1)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@app.command()
|
|
102
|
+
def pop(ctx: typer.Context):
|
|
103
|
+
"""
|
|
104
|
+
Remove and return the last item from the list.
|
|
105
|
+
"""
|
|
106
|
+
db = ctx.obj["db"]
|
|
107
|
+
name = ctx.obj["name"]
|
|
108
|
+
try:
|
|
109
|
+
item = db.list(name).pop()
|
|
110
|
+
if item is None:
|
|
111
|
+
rich.print(f"List '{name}' is empty.")
|
|
112
|
+
return
|
|
113
|
+
|
|
114
|
+
rich.print("[green]Popped item:[/green]")
|
|
115
|
+
if isinstance(item, (dict, list)):
|
|
116
|
+
rich.print_json(data=item)
|
|
117
|
+
else:
|
|
118
|
+
rich.print(item)
|
|
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 deque(ctx: typer.Context):
|
|
127
|
+
"""
|
|
128
|
+
Remove and return the first item from the list.
|
|
129
|
+
"""
|
|
130
|
+
db = ctx.obj["db"]
|
|
131
|
+
name = ctx.obj["name"]
|
|
132
|
+
try:
|
|
133
|
+
item = db.list(name).deque()
|
|
134
|
+
if item is None:
|
|
135
|
+
rich.print(f"List '{name}' is empty.")
|
|
136
|
+
return
|
|
137
|
+
|
|
138
|
+
rich.print("[green]Dequeued item:[/green]")
|
|
139
|
+
if isinstance(item, (dict, list)):
|
|
140
|
+
rich.print_json(data=item)
|
|
141
|
+
else:
|
|
142
|
+
rich.print(item)
|
|
143
|
+
|
|
144
|
+
except Exception as e:
|
|
145
|
+
rich.print(f"[bold red]Error:[/] {e}")
|
|
146
|
+
raise typer.Exit(code=1)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
@app.command()
|
|
150
|
+
def insert(
|
|
151
|
+
ctx: typer.Context,
|
|
152
|
+
index: Annotated[
|
|
153
|
+
int, typer.Argument(help="The index to insert at (e.g., 0 for front).")
|
|
154
|
+
],
|
|
155
|
+
value: Annotated[str, typer.Argument(help="The value to insert (JSON or string).")],
|
|
156
|
+
):
|
|
157
|
+
"""
|
|
158
|
+
Insert an item at a specific index.
|
|
159
|
+
"""
|
|
160
|
+
db = ctx.obj["db"]
|
|
161
|
+
name = ctx.obj["name"]
|
|
162
|
+
try:
|
|
163
|
+
parsed_value = _parse_value(value)
|
|
164
|
+
db.list(name).insert(index, parsed_value)
|
|
165
|
+
rich.print(
|
|
166
|
+
f"[green]Success:[/] Item inserted at index {index} in list '{name}'."
|
|
167
|
+
)
|
|
168
|
+
except Exception as e:
|
|
169
|
+
rich.print(f"[bold red]Error:[/] {e}")
|
|
170
|
+
raise typer.Exit(code=1)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
@app.command()
|
|
174
|
+
def remove(
|
|
175
|
+
ctx: typer.Context,
|
|
176
|
+
index: Annotated[int, typer.Argument(help="The index of the item to remove.")],
|
|
177
|
+
):
|
|
178
|
+
"""
|
|
179
|
+
Remove and return an item from a specific index.
|
|
180
|
+
"""
|
|
181
|
+
db = ctx.obj["db"]
|
|
182
|
+
name = ctx.obj["name"]
|
|
183
|
+
try:
|
|
184
|
+
# Get the item before deleting it to print it
|
|
185
|
+
item = db.list(name)[index]
|
|
186
|
+
del db.list(name)[index]
|
|
187
|
+
|
|
188
|
+
rich.print(f"[green]Success:[/] Removed item from index {index}:")
|
|
189
|
+
if isinstance(item, (dict, list)):
|
|
190
|
+
rich.print_json(data=item)
|
|
191
|
+
else:
|
|
192
|
+
rich.print(item)
|
|
193
|
+
|
|
194
|
+
except IndexError:
|
|
195
|
+
rich.print(f"[bold red]Error:[/] Index {index} out of range for list '{name}'.")
|
|
196
|
+
raise typer.Exit(code=1)
|
|
197
|
+
except Exception as e:
|
|
198
|
+
rich.print(f"[bold red]Error:[/] {e}")
|
|
199
|
+
raise typer.Exit(code=1)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
@app.command()
|
|
203
|
+
def items(ctx: typer.Context):
|
|
204
|
+
"""
|
|
205
|
+
Print all items in the list, in order.
|
|
206
|
+
"""
|
|
207
|
+
db = ctx.obj["db"]
|
|
208
|
+
name = ctx.obj["name"]
|
|
209
|
+
try:
|
|
210
|
+
# `[:]` is the slice syntax to get all items from ListManager
|
|
211
|
+
all_items = db.list(name)[:]
|
|
212
|
+
if not all_items:
|
|
213
|
+
rich.print(f"List '{name}' is empty.")
|
|
214
|
+
return
|
|
215
|
+
|
|
216
|
+
table = rich.table.Table(title=f"Items in List: [bold]{name}[/bold]")
|
|
217
|
+
table.add_column("Index", style="cyan", justify="right")
|
|
218
|
+
table.add_column("Value")
|
|
219
|
+
|
|
220
|
+
for i, item in enumerate(all_items):
|
|
221
|
+
if isinstance(item, (dict, list)):
|
|
222
|
+
table.add_row(str(i), json.dumps(item))
|
|
223
|
+
else:
|
|
224
|
+
table.add_row(str(i), str(item))
|
|
225
|
+
rich.print(table)
|
|
226
|
+
|
|
227
|
+
except Exception as e:
|
|
228
|
+
rich.print(f"[bold red]Error:[/] {e}")
|
|
229
|
+
raise typer.Exit(code=1)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
@app.command()
|
|
233
|
+
def dump(ctx: typer.Context):
|
|
234
|
+
"""
|
|
235
|
+
Dump the entire list as JSON.
|
|
236
|
+
"""
|
|
237
|
+
db = ctx.obj["db"]
|
|
238
|
+
name = ctx.obj["name"]
|
|
239
|
+
try:
|
|
240
|
+
dump_data = db.list(name).dump()
|
|
241
|
+
rich.print_json(data=dump_data)
|
|
242
|
+
except Exception as e:
|
|
243
|
+
rich.print(f"[bold red]Error:[/] {e}")
|
|
244
|
+
raise typer.Exit(code=1)
|
beaver/cli/locks.py
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import typer
|
|
2
|
+
import rich
|
|
3
|
+
import subprocess
|
|
4
|
+
import threading
|
|
5
|
+
from typing_extensions import Annotated
|
|
6
|
+
from typing import Optional, List
|
|
7
|
+
|
|
8
|
+
from beaver import BeaverDB
|
|
9
|
+
from beaver.locks import LockManager
|
|
10
|
+
|
|
11
|
+
app = typer.Typer(
|
|
12
|
+
name="lock",
|
|
13
|
+
help="Run commands under lock or manage locks. (e.g., beaver lock my-lock run bash -c 'sleep 10')",
|
|
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
|
+
@app.callback(invoke_without_command=True)
|
|
23
|
+
def lock_main(
|
|
24
|
+
ctx: typer.Context,
|
|
25
|
+
name: Annotated[
|
|
26
|
+
Optional[str], typer.Argument(help="The unique name of the lock.")
|
|
27
|
+
] = None,
|
|
28
|
+
):
|
|
29
|
+
"""
|
|
30
|
+
Manage and run commands under distributed locks.
|
|
31
|
+
|
|
32
|
+
If no name is provided, lists all active locks.
|
|
33
|
+
"""
|
|
34
|
+
db = _get_db(ctx)
|
|
35
|
+
|
|
36
|
+
if name is None:
|
|
37
|
+
# No name given, so list all active locks
|
|
38
|
+
rich.print("[bold]Active Locks:[/bold]")
|
|
39
|
+
try:
|
|
40
|
+
lock_names = db.locks
|
|
41
|
+
if not lock_names:
|
|
42
|
+
rich.print(" (No active locks found)")
|
|
43
|
+
else:
|
|
44
|
+
for lock_name in lock_names:
|
|
45
|
+
rich.print(f" • {lock_name}")
|
|
46
|
+
rich.print(
|
|
47
|
+
"\n[bold]Usage:[/bold] beaver lock [bold]<LOCK_NAME>[/bold] [COMMAND]"
|
|
48
|
+
)
|
|
49
|
+
return
|
|
50
|
+
except Exception as e:
|
|
51
|
+
rich.print(f"[bold red]Error querying locks:[/] {e}")
|
|
52
|
+
raise typer.Exit(code=1)
|
|
53
|
+
|
|
54
|
+
# A name was provided, store it in the context for subcommands
|
|
55
|
+
ctx.obj = {"name": name, "db": db}
|
|
56
|
+
|
|
57
|
+
if ctx.invoked_subcommand is None:
|
|
58
|
+
# A name was given, but no command
|
|
59
|
+
rich.print(f"Lock '[bold]{name}[/bold]'.")
|
|
60
|
+
rich.print("\n[bold]Commands:[/bold]")
|
|
61
|
+
rich.print(" run, clear")
|
|
62
|
+
rich.print(
|
|
63
|
+
f"\nRun [bold]beaver lock {name} --help[/bold] for command-specific options."
|
|
64
|
+
)
|
|
65
|
+
raise typer.Exit()
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _heartbeat_task(lock: LockManager, ttl: float, stop_event: threading.Event):
|
|
69
|
+
"""
|
|
70
|
+
A background task that periodically renews the lock.
|
|
71
|
+
"""
|
|
72
|
+
# Renew at 50% of the TTL duration
|
|
73
|
+
renew_interval = ttl / 2.0
|
|
74
|
+
|
|
75
|
+
while not stop_event.wait(renew_interval):
|
|
76
|
+
try:
|
|
77
|
+
if not lock.renew(lock_ttl=ttl):
|
|
78
|
+
# We lost the lock for some reason
|
|
79
|
+
rich.print(
|
|
80
|
+
f"[bold red]Error:[/] Failed to renew lock '{lock._lock_name}'. Lock lost."
|
|
81
|
+
)
|
|
82
|
+
break
|
|
83
|
+
except Exception as e:
|
|
84
|
+
rich.print(f"[bold red]Heartbeat Error:[/] {e}")
|
|
85
|
+
break
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@app.command(
|
|
89
|
+
"run", context_settings={"allow_extra_args": True, "ignore_unknown_options": True}
|
|
90
|
+
)
|
|
91
|
+
def run_command(
|
|
92
|
+
ctx: typer.Context,
|
|
93
|
+
timeout: Annotated[
|
|
94
|
+
Optional[float],
|
|
95
|
+
typer.Option(
|
|
96
|
+
help="Max seconds to wait for the lock. Waits forever by default."
|
|
97
|
+
),
|
|
98
|
+
] = None,
|
|
99
|
+
ttl: Annotated[
|
|
100
|
+
float, typer.Option(help="Seconds the lock can be held before auto-expiring.")
|
|
101
|
+
] = 60.0,
|
|
102
|
+
):
|
|
103
|
+
"""
|
|
104
|
+
Run a command while holding the lock.
|
|
105
|
+
|
|
106
|
+
This command will acquire the lock, run your command as a subprocess,
|
|
107
|
+
and automatically renew the lock in the background until your command
|
|
108
|
+
finishes.
|
|
109
|
+
|
|
110
|
+
Example:
|
|
111
|
+
|
|
112
|
+
beaver lock my-cron-job run bash -c 'run_daily_report.sh'
|
|
113
|
+
"""
|
|
114
|
+
db: BeaverDB = ctx.obj["db"]
|
|
115
|
+
name = ctx.obj["name"]
|
|
116
|
+
command: List[str] = ctx.args
|
|
117
|
+
|
|
118
|
+
if not command:
|
|
119
|
+
rich.print("[bold red]Error:[/] No command provided to 'run'.")
|
|
120
|
+
raise typer.Exit(code=1)
|
|
121
|
+
|
|
122
|
+
lock = db.lock(name, timeout=timeout, ttl=ttl)
|
|
123
|
+
stop_heartbeat = threading.Event()
|
|
124
|
+
heartbeat_thread = threading.Thread(
|
|
125
|
+
target=_heartbeat_task, args=(lock, ttl, stop_heartbeat), daemon=True
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
process = None
|
|
129
|
+
return_code = 1 # Default to error
|
|
130
|
+
|
|
131
|
+
try:
|
|
132
|
+
rich.print(
|
|
133
|
+
f"[cyan]Waiting to acquire lock '[bold]{name}[/bold]' (Timeout: {timeout or 'inf'})...[/cyan]"
|
|
134
|
+
)
|
|
135
|
+
lock.acquire()
|
|
136
|
+
rich.print(
|
|
137
|
+
f"[green]Lock acquired. Running command:[/green] {' '.join(command)}"
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
# Start the heartbeat thread AFTER acquiring the lock
|
|
141
|
+
heartbeat_thread.start()
|
|
142
|
+
|
|
143
|
+
# Run the subprocess
|
|
144
|
+
process = subprocess.Popen(command, shell=False)
|
|
145
|
+
process.wait()
|
|
146
|
+
return_code = process.returncode
|
|
147
|
+
|
|
148
|
+
if return_code == 0:
|
|
149
|
+
rich.print(f"[green]Command finished successfully.[/green]")
|
|
150
|
+
else:
|
|
151
|
+
rich.print(f"[bold red]Command failed with exit code {return_code}.[/bold]")
|
|
152
|
+
|
|
153
|
+
except TimeoutError:
|
|
154
|
+
rich.print(f"[bold yellow]Timeout:[/] Failed to acquire lock '{name}'.")
|
|
155
|
+
raise typer.Exit(code=1)
|
|
156
|
+
except FileNotFoundError:
|
|
157
|
+
rich.print(
|
|
158
|
+
f"[bold red]Error:[/] Command not found: '{command[0]}'. Check your system's PATH."
|
|
159
|
+
)
|
|
160
|
+
raise typer.Exit(code=127) # Standard exit code for "command not found"
|
|
161
|
+
except KeyboardInterrupt:
|
|
162
|
+
rich.print(
|
|
163
|
+
"\n[bold yellow]Interrupted.[/bold] Releasing lock and stopping subprocess..."
|
|
164
|
+
)
|
|
165
|
+
if process:
|
|
166
|
+
process.terminate()
|
|
167
|
+
process.wait()
|
|
168
|
+
return_code = 130 # Standard exit code for Ctrl+C
|
|
169
|
+
except Exception as e:
|
|
170
|
+
rich.print(f"[bold red]Error:[/] {e}")
|
|
171
|
+
raise typer.Exit(code=1)
|
|
172
|
+
finally:
|
|
173
|
+
# Stop the heartbeat and release the lock
|
|
174
|
+
stop_heartbeat.set()
|
|
175
|
+
heartbeat_thread.join(timeout=1.0) # Wait briefly for thread to stop
|
|
176
|
+
lock.release()
|
|
177
|
+
rich.print(f"Lock '[bold]{name}[/bold]' released.")
|
|
178
|
+
|
|
179
|
+
if return_code != 0:
|
|
180
|
+
raise typer.Exit(code=return_code)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
@app.command("clear")
|
|
184
|
+
def clear(ctx: typer.Context):
|
|
185
|
+
"""
|
|
186
|
+
Forcibly clear all waiters for the lock.
|
|
187
|
+
|
|
188
|
+
This removes ALL entries for the lock, including the
|
|
189
|
+
current holder and all waiting processes.
|
|
190
|
+
"""
|
|
191
|
+
db: BeaverDB = ctx.obj["db"]
|
|
192
|
+
name = ctx.obj["name"]
|
|
193
|
+
|
|
194
|
+
try:
|
|
195
|
+
# Use the static method from the core library
|
|
196
|
+
if db.lock(name).clear():
|
|
197
|
+
rich.print(f"[green]Success:[/] Cleared lock '[bold]{name}[/bold]'.")
|
|
198
|
+
else:
|
|
199
|
+
rich.print(f"[yellow]No waiter on lock[/] '[bold]{name}[/bold]'.")
|
|
200
|
+
except Exception as e:
|
|
201
|
+
rich.print(f"[bold red]Error:[/] {e}")
|
|
202
|
+
raise typer.Exit(code=1)
|