Skip to content

Commit 09631f7

Browse files
authored
Merge pull request #40 from scalableminds/interactive-client
2 parents b875de1 + 4faf08d commit 09631f7

File tree

7 files changed

+394
-1
lines changed

7 files changed

+394
-1
lines changed

client/.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,5 @@ __pycache__
33

44
fossildbapi_pb2_grpc.py
55
fossildbapi_pb2.py
6+
7+
env

client/Dockerfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
FROM python:3.6-stretch
1+
FROM python:3.9
22

33
COPY src/main/protobuf /fossildb/src/main/protobuf
44
COPY client /fossildb/client

client/interactive/client.py

Lines changed: 283 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,283 @@
1+
import argparse
2+
3+
from db_connection import (connect, getKey, getMultipleKeys, listKeys,
4+
listVersions)
5+
from rich.text import Text
6+
from textual import on
7+
from textual.app import App, ComposeResult
8+
from textual.binding import Binding
9+
from textual.containers import Horizontal, Vertical
10+
from textual.reactive import reactive
11+
from textual.widget import Widget
12+
from textual.widgets import (Button, DataTable, Footer, Header, Input, Label,
13+
RichLog)
14+
15+
16+
class ListKeysWidget(Widget):
17+
def __init__(self, stub, **kwargs):
18+
super().__init__(**kwargs)
19+
self.stub = stub
20+
21+
def compose(self) -> ComposeResult:
22+
with Horizontal(id="horizontal-list"):
23+
yield DataTable(id="list-keys-table")
24+
info_widget = KeyInfoWidget(id="key-info")
25+
info_widget.key = "No key selected"
26+
info_widget.stub = self.stub
27+
yield info_widget
28+
29+
@on(DataTable.CellHighlighted)
30+
def on_data_table_row_highlighted(self, event):
31+
self.query_one(KeyInfoWidget).update_key(event.value)
32+
self.refresh()
33+
34+
35+
class FileNameHint(Widget):
36+
filename = reactive("none")
37+
38+
def render(self) -> str:
39+
return f"Press the button above to download the value for the selected key to {self.filename}"
40+
41+
42+
class KeyInfoWidget(Widget):
43+
key = ""
44+
key_save_filename = ""
45+
versions = []
46+
47+
def sanitize_filename(self, name):
48+
import re
49+
50+
s = str(name).strip().replace(" ", "_")
51+
return re.sub(r"(?u)[^-\w.]", "_", s)
52+
53+
def update_key(self, key):
54+
self.key = key
55+
56+
self.query_one("#write-button").styles.visibility = "visible"
57+
self.query_one("#write-label").styles.visibility = "visible"
58+
59+
log = self.query_one("#key-info-label")
60+
61+
log.clear()
62+
log.write(Text("Key:", style="bold magenta"))
63+
log.write(key)
64+
65+
if key == "More keys on the next page...":
66+
return
67+
68+
try:
69+
self.versions = listVersions(stub, self.collection, key)
70+
log.write(Text("Versions:", style="bold magenta"))
71+
log.write(",".join(map(str, self.versions)))
72+
self.key_save_filename = self.sanitize_filename(
73+
f"{self.collection}_{key}_{self.versions[-1]}"
74+
)
75+
self.query_one("#write-label").filename = self.key_save_filename
76+
except Exception as e:
77+
log.write("Could not load versions: " + str(e))
78+
79+
def write_key(self):
80+
try:
81+
value = getKey(stub, self.collection, self.key, self.versions[-1])
82+
with open(self.key_save_filename, "wb") as f:
83+
f.write(value)
84+
self.query_one("#key-info-label").write(
85+
f"Wrote data to {self.key_save_filename}"
86+
)
87+
except Exception as e:
88+
self.query_one("#key-info-label").write("Could not write key: " + str(e))
89+
90+
def on_button_pressed(self, event):
91+
if event.button.id == "write-button":
92+
self.write_key()
93+
94+
def compose(self) -> ComposeResult:
95+
with Vertical():
96+
yield RichLog(id="key-info-label", wrap=True)
97+
98+
writeButton = Button(label="Save latest version in file", id="write-button")
99+
writeButton.styles.visibility = "hidden"
100+
writeLabel = FileNameHint(id="write-label")
101+
writeLabel.styles.visibility = "hidden"
102+
yield writeButton
103+
yield writeLabel
104+
105+
106+
class FossilDBClient(App):
107+
108+
"""A Textual app to manage FossilDB databases."""
109+
110+
BINDINGS = [
111+
("d", "toggle_dark", "Toggle dark mode"),
112+
("q", "quit", "Quit the client"),
113+
("r", "refresh", "Refresh the data"),
114+
Binding(
115+
"pagedown",
116+
"show_next",
117+
f"Show next page of keys",
118+
priority=True,
119+
show=True,
120+
),
121+
Binding(
122+
"j",
123+
"show_next",
124+
f"Show next page of keys",
125+
show=True,
126+
),
127+
Binding(
128+
"pageup",
129+
"show_prev",
130+
f"Show previous page of keys",
131+
priority=True,
132+
show=True,
133+
),
134+
Binding(
135+
"k",
136+
"show_prev",
137+
f"Show previous page of keys",
138+
show=True,
139+
),
140+
Binding("down", "next_key", "Select the next key", priority=True, show=False),
141+
Binding("up", "prev_key", "Select the previous key", priority=True, show=False),
142+
]
143+
144+
after_key = ""
145+
prefix = ""
146+
collection = "volumeData"
147+
CSS_PATH = "client.tcss"
148+
149+
last_keys = [""]
150+
151+
def __init__(self, stub, collection, count):
152+
super().__init__()
153+
self.stub = stub
154+
self.collection = collection
155+
self.key_list_limit = int(count)
156+
157+
def compose(self) -> ComposeResult:
158+
"""Create child widgets for the app."""
159+
yield Header()
160+
yield Input(
161+
placeholder="Select collection:", id="collection", value=self.collection
162+
)
163+
yield Input(
164+
placeholder="Find keys with prefix: (leave empty to list all keys)",
165+
id="prefix",
166+
)
167+
yield ListKeysWidget(id="list-keys", stub=self.stub)
168+
169+
yield Footer()
170+
171+
@on(Input.Submitted)
172+
def on_input_submitted(self, event: Input.Submitted) -> None:
173+
if event.input.id == "collection":
174+
self.collection = event.input.value
175+
if event.input.id == "prefix":
176+
self.prefix = event.input.value
177+
self.refresh_data()
178+
179+
def refresh_data(self) -> None:
180+
"""Refresh the data in the table."""
181+
table = self.query_one(DataTable)
182+
self.query_one(KeyInfoWidget).collection = self.collection
183+
table.clear(columns=True)
184+
table.add_column("key")
185+
try:
186+
if self.prefix != "":
187+
keys = getMultipleKeys(
188+
self.stub,
189+
self.collection,
190+
self.prefix,
191+
self.after_key,
192+
self.key_list_limit + 1, # +1 to check if there are more keys
193+
)
194+
else:
195+
keys = listKeys(
196+
self.stub, self.collection, self.after_key, self.key_list_limit + 1
197+
)
198+
overlength = False
199+
if len(keys) > self.key_list_limit:
200+
keys = keys[:-1]
201+
overlength = True
202+
203+
for i, key in enumerate(keys):
204+
label = Text(str(i), style="#B0FC38 italic")
205+
table.add_row(key, label=label)
206+
if overlength:
207+
table.add_row(
208+
"More keys on the next page...",
209+
label=Text("...", style="#B0FC38 italic"),
210+
)
211+
self.last_keys.append(keys[-1])
212+
table.focus()
213+
except Exception as e:
214+
table.add_row("Could not load keys: " + str(e))
215+
216+
def action_toggle_dark(self) -> None:
217+
"""An action to toggle dark mode."""
218+
self.dark = not self.dark
219+
220+
def action_quit(self) -> None:
221+
"""An action to quit the app."""
222+
self.exit()
223+
224+
def action_refresh(self) -> None:
225+
"""An action to refresh the data."""
226+
self.refresh_data()
227+
228+
def action_show_next(self) -> None:
229+
"""An action to show the next key_list_limit keys."""
230+
self.after_key = self.last_keys[-1]
231+
self.refresh_data()
232+
233+
def action_show_prev(self) -> None:
234+
"""An action to show the previous key_list_limit keys."""
235+
if len(self.last_keys) > 2:
236+
self.last_keys.pop()
237+
self.last_keys.pop()
238+
self.after_key = self.last_keys[-1]
239+
self.refresh_data()
240+
241+
def action_next_key(self) -> None:
242+
"""An action to select the next key."""
243+
table = self.query_one(DataTable)
244+
current_row = table.cursor_coordinate.row
245+
if current_row < self.key_list_limit - 1:
246+
table.cursor_coordinate = (current_row + 1, table.cursor_coordinate.column)
247+
else:
248+
self.action_show_next()
249+
250+
def action_prev_key(self) -> None:
251+
"""An action to select the previous key."""
252+
table = self.query_one(DataTable)
253+
current_row = table.cursor_coordinate.row
254+
if current_row > 0:
255+
table.cursor_coordinate = (current_row - 1, table.cursor_coordinate.column)
256+
else:
257+
if self.after_key != "":
258+
self.action_show_prev()
259+
table.cursor_coordinate = (
260+
len(table.rows) - 2, # -1 for last row, -1 for the More keys row
261+
table.cursor_coordinate.column,
262+
)
263+
264+
265+
def init_argument_parser():
266+
parser = argparse.ArgumentParser()
267+
parser.add_argument(
268+
"host",
269+
help="fossildb host and ip, e.g. localhost:7155",
270+
default="localhost:7155",
271+
nargs="?",
272+
)
273+
parser.add_argument("-c", "--collection", help="collection to use", default="")
274+
parser.add_argument("-n", "--count", help="number of keys to list", default=40)
275+
return parser
276+
277+
278+
if __name__ == "__main__":
279+
parser = init_argument_parser()
280+
args = parser.parse_args()
281+
stub = connect(args.host)
282+
app = FossilDBClient(stub, args.collection, args.count)
283+
app.run()

client/interactive/client.tcss

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
ListKeysWidget #list-keys {
2+
layout: horizontal;
3+
color: red;
4+
}
5+
6+
#key-info {
7+
width: 30%;
8+
}
9+
10+
#key-info-label {
11+
height: 50%;
12+
}
13+
14+
Button {
15+
width: 100%;
16+
}

0 commit comments

Comments
 (0)