openduck-cli 0.1.0__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.
- openduck/__init__.py +42 -0
- openduck/__main__.py +6 -0
- openduck/core/__init__.py +36 -0
- openduck/core/clipboard.py +57 -0
- openduck/core/config.py +108 -0
- openduck/core/error_classifier.py +53 -0
- openduck/core/sql_utils.py +63 -0
- openduck/gui/__init__.py +5 -0
- openduck/gui/app.py +802 -0
- openduck/gui/dialogs/__init__.py +7 -0
- openduck/gui/dialogs/add_connection_dialog.py +77 -0
- openduck/gui/dialogs/export_dialog.py +109 -0
- openduck/gui/dialogs/save_query_dialog.py +41 -0
- openduck/gui/screens/__init__.py +6 -0
- openduck/gui/screens/about.py +84 -0
- openduck/gui/screens/matrix.py +90 -0
- openduck/gui/styles.py +71 -0
- openduck/gui/widgets/__init__.py +17 -0
- openduck/gui/widgets/database_tree.py +106 -0
- openduck/gui/widgets/file_tree.py +14 -0
- openduck/gui/widgets/query_tab.py +63 -0
- openduck/gui/widgets/resize_handle.py +36 -0
- openduck/gui/widgets/results_table.py +54 -0
- openduck/gui/widgets/sql_editor.py +207 -0
- openduck/main.py +25 -0
- openduck_cli-0.1.0.dist-info/METADATA +204 -0
- openduck_cli-0.1.0.dist-info/RECORD +30 -0
- openduck_cli-0.1.0.dist-info/WHEEL +5 -0
- openduck_cli-0.1.0.dist-info/entry_points.txt +3 -0
- openduck_cli-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Query tab container combining editor, results table, action buttons, and status bar."""
|
|
2
|
+
|
|
3
|
+
from textual.app import ComposeResult
|
|
4
|
+
from textual.containers import Horizontal, Vertical
|
|
5
|
+
from textual.widgets import Button, DataTable, Static
|
|
6
|
+
|
|
7
|
+
from openduck.gui.widgets.results_table import ResultsTable
|
|
8
|
+
from openduck.gui.widgets.sql_editor import SqlTextArea
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class QueryTab(Vertical):
|
|
12
|
+
"""A tab containing a SQL text editor, results table, action toolbar, and metadata bar."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, sql: str = "", connection_id: str = None):
|
|
15
|
+
super().__init__()
|
|
16
|
+
self.initial_sql = sql
|
|
17
|
+
self.full_data, self.column_names, self.col_states = [], [], {}
|
|
18
|
+
self.running_task = None
|
|
19
|
+
self.connection_id = connection_id
|
|
20
|
+
|
|
21
|
+
def compose(self) -> ComposeResult:
|
|
22
|
+
text_area = SqlTextArea(
|
|
23
|
+
self.initial_sql,
|
|
24
|
+
language="sql",
|
|
25
|
+
id="query-input",
|
|
26
|
+
theme="vscode_dark",
|
|
27
|
+
soft_wrap=False, # Disabled to prevent crash with long lines
|
|
28
|
+
read_only=False,
|
|
29
|
+
tab_behavior="indent",
|
|
30
|
+
show_line_numbers=True,
|
|
31
|
+
)
|
|
32
|
+
# Force SQL highlighting
|
|
33
|
+
try:
|
|
34
|
+
text_area.language = "sql"
|
|
35
|
+
except Exception:
|
|
36
|
+
pass
|
|
37
|
+
yield text_area
|
|
38
|
+
with Horizontal(): # Container for table and export sidebar
|
|
39
|
+
yield ResultsTable(id="results-table")
|
|
40
|
+
with Vertical(id="export-sidebar"):
|
|
41
|
+
yield Button("▶ Run", id="btn-run-query", classes="export-btn", variant="primary", tooltip="Run query / selection (Ctrl+Enter)")
|
|
42
|
+
yield Button("💬 --", id="btn-comment", classes="export-btn", tooltip="Toggle comment (F3 / Cmd+/)")
|
|
43
|
+
yield Button("↓CSV", id="export-csv", classes="export-btn")
|
|
44
|
+
yield Button("↓XLSX", id="export-excel", classes="export-btn")
|
|
45
|
+
yield Button("Abort", id="cancel-query", classes="export-btn", tooltip="Cancel Query", variant="error")
|
|
46
|
+
with Horizontal(id="metadata-container"):
|
|
47
|
+
yield Static("Ready", id="metadata-bar")
|
|
48
|
+
yield Static("Memory: N/A", id="memory-bar")
|
|
49
|
+
|
|
50
|
+
def on_mount(self):
|
|
51
|
+
t = self.query_one(DataTable)
|
|
52
|
+
t.cursor_type, t.zebra_stripes = "row", True
|
|
53
|
+
# Use slight delay for focus to allow UI to settle
|
|
54
|
+
self.set_timer(0.1, self.safe_focus)
|
|
55
|
+
|
|
56
|
+
def safe_focus(self):
|
|
57
|
+
"""Focus the editor safely."""
|
|
58
|
+
try:
|
|
59
|
+
editor = self.query_one("#query-input")
|
|
60
|
+
editor.move_cursor((0, 0))
|
|
61
|
+
editor.focus()
|
|
62
|
+
except Exception:
|
|
63
|
+
pass
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Draggable sidebar resize handle widget."""
|
|
2
|
+
|
|
3
|
+
from textual.events import MouseDown, MouseMove, MouseUp
|
|
4
|
+
from textual.widgets import Static
|
|
5
|
+
|
|
6
|
+
from openduck.gui.styles import RESIZE_CSS
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ResizeHandle(Static):
|
|
10
|
+
"""A draggable handle to resize the sidebar."""
|
|
11
|
+
|
|
12
|
+
DEFAULT_CSS = RESIZE_CSS
|
|
13
|
+
|
|
14
|
+
def __init__(self):
|
|
15
|
+
super().__init__("┃")
|
|
16
|
+
self._dragging = False
|
|
17
|
+
|
|
18
|
+
def on_mouse_down(self, event: MouseDown) -> None:
|
|
19
|
+
self._dragging = True
|
|
20
|
+
self.capture_mouse()
|
|
21
|
+
event.stop()
|
|
22
|
+
|
|
23
|
+
def on_mouse_move(self, event: MouseMove) -> None:
|
|
24
|
+
if self._dragging:
|
|
25
|
+
# screen_x gives us the absolute position of the mouse
|
|
26
|
+
screen_width = self.screen.size.width
|
|
27
|
+
new_width = max(10, min(event.screen_x, screen_width - 20))
|
|
28
|
+
sidebar = self.screen.query_one("#sidebar")
|
|
29
|
+
sidebar.styles.width = new_width
|
|
30
|
+
event.stop()
|
|
31
|
+
|
|
32
|
+
def on_mouse_up(self, event: MouseUp) -> None:
|
|
33
|
+
if self._dragging:
|
|
34
|
+
self._dragging = False
|
|
35
|
+
self.release_mouse()
|
|
36
|
+
event.stop()
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Data table widget for query results with copy and keyboard bindings."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from textual.binding import Binding
|
|
5
|
+
from textual.widgets import DataTable
|
|
6
|
+
|
|
7
|
+
from openduck.core.clipboard import copy_to_system_clipboard
|
|
8
|
+
|
|
9
|
+
logger = logging.getLogger(__name__)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ResultsTable(DataTable):
|
|
13
|
+
"""DataTable for displaying SQL query results with system clipboard row/table copying."""
|
|
14
|
+
|
|
15
|
+
BINDINGS = [
|
|
16
|
+
*DataTable.BINDINGS,
|
|
17
|
+
Binding("ctrl+c,super+c", "copy_row", "Copy Row", show=False),
|
|
18
|
+
Binding("ctrl+a,super+a", "copy_table", "Copy All", show=False),
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
def action_copy_row(self) -> None:
|
|
22
|
+
"""Copy highlighted row or cell to clipboard."""
|
|
23
|
+
try:
|
|
24
|
+
if self.cursor_type == "row" and self.cursor_row is not None:
|
|
25
|
+
row_data = self.get_row_at(self.cursor_row)
|
|
26
|
+
text = "\t".join(str(v) if v is not None else "" for v in row_data)
|
|
27
|
+
copy_to_system_clipboard(text)
|
|
28
|
+
self.app.copy_to_clipboard(text)
|
|
29
|
+
self.notify(f"Rij gekopieerd ({len(row_data)} kolommen)", timeout=1.5)
|
|
30
|
+
elif self.cursor_type == "cell" and self.cursor_coordinate is not None:
|
|
31
|
+
cell_data = self.get_cell_at(self.cursor_coordinate)
|
|
32
|
+
text = str(cell_data) if cell_data is not None else ""
|
|
33
|
+
copy_to_system_clipboard(text)
|
|
34
|
+
self.app.copy_to_clipboard(text)
|
|
35
|
+
self.notify("Cel gekopieerd naar klembord", timeout=1.5)
|
|
36
|
+
except Exception as e:
|
|
37
|
+
logger.error(f"Error copying table row: {e}")
|
|
38
|
+
|
|
39
|
+
def action_copy_table(self) -> None:
|
|
40
|
+
"""Copy entire table with column headers as TSV."""
|
|
41
|
+
try:
|
|
42
|
+
lines = []
|
|
43
|
+
headers = [col.label.plain for col in self.columns.values()]
|
|
44
|
+
if headers:
|
|
45
|
+
lines.append("\t".join(headers))
|
|
46
|
+
for row_idx in range(self.row_count):
|
|
47
|
+
row_data = self.get_row_at(row_idx)
|
|
48
|
+
lines.append("\t".join(str(v) if v is not None else "" for v in row_data))
|
|
49
|
+
text = "\n".join(lines)
|
|
50
|
+
copy_to_system_clipboard(text)
|
|
51
|
+
self.app.copy_to_clipboard(text)
|
|
52
|
+
self.notify(f"Tabel gekopieerd ({self.row_count} rijen)", timeout=1.5)
|
|
53
|
+
except Exception as e:
|
|
54
|
+
logger.error(f"Error copying entire table: {e}")
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"""SQL editor widget with syntax highlighting, undo/redo, and keyboard bindings."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from textual.binding import Binding
|
|
5
|
+
from textual.events import Click
|
|
6
|
+
from textual.widgets import TextArea
|
|
7
|
+
from textual.widgets.text_area import Selection
|
|
8
|
+
|
|
9
|
+
from openduck.core.clipboard import copy_to_system_clipboard, get_system_clipboard
|
|
10
|
+
from openduck.core.sql_utils import toggle_sql_comment_lines
|
|
11
|
+
|
|
12
|
+
# Construct bindings for SqlTextArea
|
|
13
|
+
_sql_text_area_bindings = []
|
|
14
|
+
for b in TextArea.BINDINGS:
|
|
15
|
+
if b.action == "cursor_line_start":
|
|
16
|
+
_sql_text_area_bindings.append(Binding("home", "cursor_line_start", "Cursor line start", show=False))
|
|
17
|
+
elif b.action == "select_all":
|
|
18
|
+
_sql_text_area_bindings.append(Binding("f7,ctrl+a,super+a,cmd+a,command+a", "select_all", "Select all", show=False))
|
|
19
|
+
elif b.action == "copy":
|
|
20
|
+
_sql_text_area_bindings.append(Binding("ctrl+c,super+c,cmd+c,command+c", "copy", "Copy", show=False))
|
|
21
|
+
elif b.action == "paste":
|
|
22
|
+
_sql_text_area_bindings.append(Binding("ctrl+v,super+v,cmd+v,command+v", "paste", "Paste", show=False))
|
|
23
|
+
elif b.action == "cut":
|
|
24
|
+
_sql_text_area_bindings.append(Binding("ctrl+x,super+x,cmd+x,command+x", "cut", "Cut", show=False))
|
|
25
|
+
elif b.action == "undo":
|
|
26
|
+
_sql_text_area_bindings.append(Binding("ctrl+z,super+z,cmd+z,command+z", "undo", "Undo", show=False))
|
|
27
|
+
elif b.action == "redo":
|
|
28
|
+
_sql_text_area_bindings.append(Binding(
|
|
29
|
+
"ctrl+y,super+y,cmd+y,command+y,shift+ctrl+z,ctrl+shift+z,shift+super+z,super+shift+z,shift+cmd+z,cmd+shift+z,shift+command+z,command+shift+z",
|
|
30
|
+
"redo", "Redo", show=False
|
|
31
|
+
))
|
|
32
|
+
else:
|
|
33
|
+
_sql_text_area_bindings.append(b)
|
|
34
|
+
|
|
35
|
+
_sql_text_area_bindings.append(
|
|
36
|
+
Binding(
|
|
37
|
+
"f3,ctrl+slash,super+slash,cmd+slash,command+slash,ctrl+underscore,super+underscore,cmd+underscore,command+underscore,ctrl+7,super+7,ctrl+minus,super+minus",
|
|
38
|
+
"toggle_comment", "Comment", show=False
|
|
39
|
+
)
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class SqlTextArea(TextArea):
|
|
44
|
+
"""Specialized TextArea widget for SQL input with enhanced selection & clipboard support."""
|
|
45
|
+
|
|
46
|
+
allow_select = False
|
|
47
|
+
BINDINGS = _sql_text_area_bindings
|
|
48
|
+
|
|
49
|
+
def select_word_at(self, location: tuple[int, int]) -> None:
|
|
50
|
+
"""Select the word at the given (row, col) location."""
|
|
51
|
+
row, col = location
|
|
52
|
+
if row >= self.document.line_count:
|
|
53
|
+
return
|
|
54
|
+
line = self.document.get_line(row)
|
|
55
|
+
if not line:
|
|
56
|
+
return
|
|
57
|
+
col = max(0, min(col, len(line)))
|
|
58
|
+
|
|
59
|
+
word_char_regex = re.compile(r'\w')
|
|
60
|
+
if col < len(line) and not word_char_regex.match(line[col]) and col > 0 and word_char_regex.match(line[col - 1]):
|
|
61
|
+
col -= 1
|
|
62
|
+
|
|
63
|
+
if col < len(line) and word_char_regex.match(line[col]):
|
|
64
|
+
start_col = col
|
|
65
|
+
while start_col > 0 and word_char_regex.match(line[start_col - 1]):
|
|
66
|
+
start_col -= 1
|
|
67
|
+
end_col = col
|
|
68
|
+
while end_col < len(line) and word_char_regex.match(line[end_col]):
|
|
69
|
+
end_col += 1
|
|
70
|
+
self.selection = Selection((row, start_col), (row, end_col))
|
|
71
|
+
else:
|
|
72
|
+
start_col = col
|
|
73
|
+
while start_col > 0 and not line[start_col - 1].isspace() and not word_char_regex.match(line[start_col - 1]):
|
|
74
|
+
start_col -= 1
|
|
75
|
+
end_col = col
|
|
76
|
+
while end_col < len(line) and not line[end_col].isspace() and not word_char_regex.match(line[end_col]):
|
|
77
|
+
end_col += 1
|
|
78
|
+
if start_col < end_col:
|
|
79
|
+
self.selection = Selection((row, start_col), (row, end_col))
|
|
80
|
+
|
|
81
|
+
async def _on_click(self, event: Click) -> None:
|
|
82
|
+
if event.chain == 2:
|
|
83
|
+
target = self.get_target_document_location(event)
|
|
84
|
+
self.select_word_at(target)
|
|
85
|
+
if self.selected_text:
|
|
86
|
+
copy_to_system_clipboard(self.selected_text)
|
|
87
|
+
self.app._clipboard = self.selected_text
|
|
88
|
+
event.stop()
|
|
89
|
+
return
|
|
90
|
+
elif event.chain == 3:
|
|
91
|
+
cursor_row, _ = self.get_target_document_location(event)
|
|
92
|
+
self.select_line(cursor_row)
|
|
93
|
+
if self.selected_text:
|
|
94
|
+
copy_to_system_clipboard(self.selected_text)
|
|
95
|
+
self.app._clipboard = self.selected_text
|
|
96
|
+
event.stop()
|
|
97
|
+
return
|
|
98
|
+
await super()._on_click(event)
|
|
99
|
+
|
|
100
|
+
def _end_mouse_selection(self) -> None:
|
|
101
|
+
"""When mouse selection finishes, copy selected text automatically to system clipboard."""
|
|
102
|
+
super()._end_mouse_selection()
|
|
103
|
+
if self.selected_text:
|
|
104
|
+
copy_to_system_clipboard(self.selected_text)
|
|
105
|
+
self.app._clipboard = self.selected_text
|
|
106
|
+
|
|
107
|
+
def action_copy(self) -> None:
|
|
108
|
+
"""Copy selected text (or current line if no selection, like VS Code) to system clipboard."""
|
|
109
|
+
selected_text = self.selected_text
|
|
110
|
+
if selected_text:
|
|
111
|
+
copy_to_system_clipboard(selected_text)
|
|
112
|
+
self.app.copy_to_clipboard(selected_text)
|
|
113
|
+
self.notify("Gekopieerd naar klembord", timeout=1.5)
|
|
114
|
+
else:
|
|
115
|
+
row = self.cursor_location[0]
|
|
116
|
+
line = self.document.get_line(row)
|
|
117
|
+
if line:
|
|
118
|
+
copy_to_system_clipboard(line)
|
|
119
|
+
self.app.copy_to_clipboard(line)
|
|
120
|
+
self.notify("Regel gekopieerd naar klembord", timeout=1.5)
|
|
121
|
+
|
|
122
|
+
def action_cut(self) -> None:
|
|
123
|
+
"""Cut selected text to system clipboard."""
|
|
124
|
+
selected_text = self.selected_text
|
|
125
|
+
if selected_text:
|
|
126
|
+
copy_to_system_clipboard(selected_text)
|
|
127
|
+
self.app.copy_to_clipboard(selected_text)
|
|
128
|
+
self.replace("", *self.selection)
|
|
129
|
+
self.notify("Geknipt naar klembord", timeout=1.5)
|
|
130
|
+
else:
|
|
131
|
+
row = self.cursor_location[0]
|
|
132
|
+
line = self.document.get_line(row)
|
|
133
|
+
if line:
|
|
134
|
+
copy_to_system_clipboard(line)
|
|
135
|
+
self.app.copy_to_clipboard(line)
|
|
136
|
+
self.action_delete_line()
|
|
137
|
+
self.notify("Regel geknipt naar klembord", timeout=1.5)
|
|
138
|
+
|
|
139
|
+
def action_paste(self) -> None:
|
|
140
|
+
"""Paste from system clipboard (macOS pbpaste / local clipboard)."""
|
|
141
|
+
if self.read_only:
|
|
142
|
+
return
|
|
143
|
+
text = get_system_clipboard()
|
|
144
|
+
if not text:
|
|
145
|
+
text = self.app.clipboard
|
|
146
|
+
if text:
|
|
147
|
+
if result := self._replace_via_keyboard(text, *self.selection):
|
|
148
|
+
self.move_cursor(result.end_location)
|
|
149
|
+
|
|
150
|
+
def action_select_all(self) -> None:
|
|
151
|
+
"""Select all text in document."""
|
|
152
|
+
self.select_all()
|
|
153
|
+
|
|
154
|
+
def action_toggle_comment(self) -> None:
|
|
155
|
+
"""Toggle SQL comment (-- ) like in VS Code."""
|
|
156
|
+
selection = self.selection
|
|
157
|
+
start_row, start_col = selection.start
|
|
158
|
+
end_row, end_col = selection.end
|
|
159
|
+
|
|
160
|
+
if start_row > end_row or (start_row == end_row and start_col > end_col):
|
|
161
|
+
start_row, end_row = end_row, start_row
|
|
162
|
+
start_col, end_col = end_col, start_col
|
|
163
|
+
|
|
164
|
+
if end_row > start_row and end_col == 0:
|
|
165
|
+
actual_end_row = end_row - 1
|
|
166
|
+
else:
|
|
167
|
+
actual_end_row = end_row
|
|
168
|
+
|
|
169
|
+
actual_end_row = min(actual_end_row, max(0, self.document.line_count - 1))
|
|
170
|
+
lines = [self.document.get_line(r) for r in range(start_row, actual_end_row + 1)]
|
|
171
|
+
new_lines = toggle_sql_comment_lines(lines)
|
|
172
|
+
|
|
173
|
+
replacement = "\n".join(new_lines)
|
|
174
|
+
self.replace(replacement, (start_row, 0), (actual_end_row, len(lines[-1])), maintain_selection_offset=False)
|
|
175
|
+
|
|
176
|
+
if selection.is_empty:
|
|
177
|
+
new_col = min(len(new_lines[0]), max(0, start_col + (len(new_lines[0]) - len(lines[0]))))
|
|
178
|
+
self.move_cursor((start_row, new_col))
|
|
179
|
+
else:
|
|
180
|
+
self.selection = Selection((start_row, 0), (actual_end_row, len(new_lines[-1])))
|
|
181
|
+
|
|
182
|
+
is_commented = any(l.lstrip().startswith("--") for l in new_lines if l.strip())
|
|
183
|
+
status = "toegevoegd (--)" if is_commented else "verwijderd"
|
|
184
|
+
self.notify(f"Commentaar {status}", timeout=1.2)
|
|
185
|
+
|
|
186
|
+
def on_key(self, event) -> None:
|
|
187
|
+
key = (event.key or "").lower()
|
|
188
|
+
if key in ("super+z", "cmd+z", "command+z", "ctrl+z"):
|
|
189
|
+
self.action_undo()
|
|
190
|
+
event.stop()
|
|
191
|
+
event.prevent_default()
|
|
192
|
+
elif key in (
|
|
193
|
+
"super+shift+z", "shift+super+z", "cmd+shift+z", "shift+cmd+z",
|
|
194
|
+
"command+shift+z", "shift+command+z", "ctrl+shift+z", "shift+ctrl+z",
|
|
195
|
+
"super+y", "cmd+y", "command+y", "ctrl+y"
|
|
196
|
+
):
|
|
197
|
+
self.action_redo()
|
|
198
|
+
event.stop()
|
|
199
|
+
event.prevent_default()
|
|
200
|
+
|
|
201
|
+
def action_undo(self) -> None:
|
|
202
|
+
"""Undo last action in editor."""
|
|
203
|
+
self.undo()
|
|
204
|
+
|
|
205
|
+
def action_redo(self) -> None:
|
|
206
|
+
"""Redo last undone action in editor."""
|
|
207
|
+
self.redo()
|
openduck/main.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""OpenDuck - Main application entry point."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from openduck.gui.app import DuckCLI
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def setup_logging():
|
|
8
|
+
"""Configure application logging."""
|
|
9
|
+
logging.basicConfig(
|
|
10
|
+
filename='openduck_debug.log',
|
|
11
|
+
level=logging.DEBUG,
|
|
12
|
+
format='%(asctime)s - %(name)s - %(levelname)s - %(funcName)s:%(lineno)d - %(message)s',
|
|
13
|
+
filemode='a'
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def main():
|
|
18
|
+
"""Run OpenDuck."""
|
|
19
|
+
setup_logging()
|
|
20
|
+
app = DuckCLI()
|
|
21
|
+
app.run()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
if __name__ == "__main__":
|
|
25
|
+
main()
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: openduck-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: The Modern, Lightning-Fast Terminal SQL IDE & Data Explorer for DuckDB
|
|
5
|
+
Author: Bertatron
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
Requires-Dist: textual[syntax]>=0.50.0
|
|
10
|
+
Requires-Dist: duckdb>=0.9.0
|
|
11
|
+
Requires-Dist: pandas>=2.0.0
|
|
12
|
+
Requires-Dist: openpyxl>=3.1.0
|
|
13
|
+
Provides-Extra: dev
|
|
14
|
+
Requires-Dist: pytest; extra == "dev"
|
|
15
|
+
Requires-Dist: pyinstaller; extra == "dev"
|
|
16
|
+
Provides-Extra: db
|
|
17
|
+
Requires-Dist: pymssql; extra == "db"
|
|
18
|
+
|
|
19
|
+
# 🦆 OpenDuck
|
|
20
|
+
|
|
21
|
+
<p align="center">
|
|
22
|
+
<b>The Modern, Lightning-Fast Terminal SQL IDE & Data Explorer for DuckDB</b>
|
|
23
|
+
</p>
|
|
24
|
+
|
|
25
|
+
<p align="center">
|
|
26
|
+
<img src="https://img.shields.io/badge/Python-3.10%2B-blue?style=for-the-badge&logo=python&logoColor=white" alt="Python 3.10+" />
|
|
27
|
+
<img src="https://img.shields.io/badge/DuckDB-Fast%20Analytics-yellow?style=for-the-badge&logo=duckdb&logoColor=black" alt="DuckDB" />
|
|
28
|
+
<img src="https://img.shields.io/badge/TUI-Textual-green?style=for-the-badge" alt="Textual" />
|
|
29
|
+
<img src="https://img.shields.io/badge/License-MIT-orange?style=for-the-badge" alt="MIT License" />
|
|
30
|
+
</p>
|
|
31
|
+
|
|
32
|
+
---
|
|
33
|
+
|
|
34
|
+
<p align="center">
|
|
35
|
+
<img width="100%" alt="OpenDuck Interface" src="https://github.com/user-attachments/assets/4aea9df6-6f00-494d-a171-d76a9a3b8133" />
|
|
36
|
+
</p>
|
|
37
|
+
|
|
38
|
+
**OpenDuck** brings the power and speed of **DuckDB** straight to your terminal in an intuitive, multi-tab Text User Interface (TUI). Effortlessly browse files, inspect datasets, write queries with full syntax highlighting, sort and filter millions of rows in milliseconds, and export results — all without leaving your shell.
|
|
39
|
+
|
|
40
|
+
---
|
|
41
|
+
|
|
42
|
+
## ✨ Features
|
|
43
|
+
|
|
44
|
+
- ⚡ **Instant File Queries**: Click or select any file (`.csv`, `.parquet`, `.xlsx`, `.json`, `.duckdb`, `.sqlite`, `.arrow`) from the sidebar to instantly load and execute queries with zero boilerplates.
|
|
45
|
+
- 🎨 **Rich SQL Syntax Highlighting**: Tree-sitter powered VS Code Dark styling with real-time token highlighting for SQL keywords, strings, numbers, identifiers, and comments.
|
|
46
|
+
- 🚀 **Asynchronous & Non-Blocking**: Queries execute in dedicated background threads — the interface remains buttery smooth and never freezes, even on heavy multi-gigabyte queries.
|
|
47
|
+
- 📑 **Multi-Tab Workspace**: Work with multiple datasets simultaneously with tab navigation, close buttons (`✕`), and tab titles dynamically bound to files and queries.
|
|
48
|
+
- 📊 **Interactive Data Viewer**:
|
|
49
|
+
- Sort columns ascending/descending with clickable headers (safe null handling).
|
|
50
|
+
- Quick column filters.
|
|
51
|
+
- Live query execution runtime, row count, and memory consumption stats.
|
|
52
|
+
- 📋 **Terminal & System Clipboard Integration**:
|
|
53
|
+
- Copy highlighted cells or entire rows (`Ctrl+C`).
|
|
54
|
+
- Copy full query results as TSV (`Ctrl+A`).
|
|
55
|
+
- Native terminal copy & paste with multi-level Undo/Redo (`Cmd+Z` / `Ctrl+Z` / `Ctrl+Y`).
|
|
56
|
+
- Toggle SQL comments effortlessly (`F3` / `Cmd+/`).
|
|
57
|
+
- 💾 **One-Click Export**: Export results to `.csv` or `.xlsx` files with a single click.
|
|
58
|
+
- 📜 **History & Saved Queries**: Automatically preserves query history and lets you save reusable named queries with quick one-click recall into your editor.
|
|
59
|
+
- 🎛️ **Draggable Resizable Sidebar**: Adjust the width of your sidebar on the fly with mouse drag.
|
|
60
|
+
- 🛡️ **Intelligent Error Diagnostics**: Corrupted file, wrong delimiter, permission issue, or missing dataset? OpenDuck identifies the exact cause and displays human-readable actionable solutions.
|
|
61
|
+
- 🐇 **Easter Egg**: Secret Matrix rain screensaver!
|
|
62
|
+
|
|
63
|
+
---
|
|
64
|
+
|
|
65
|
+
## 📂 Supported File Formats
|
|
66
|
+
|
|
67
|
+
OpenDuck automatically recognises and formats queries for:
|
|
68
|
+
|
|
69
|
+
| Format | Extensions | Query Mode |
|
|
70
|
+
| :--- | :--- | :--- |
|
|
71
|
+
| **Parquet** | `.parquet` | Native direct query |
|
|
72
|
+
| **Arrow** | `.arrow` | Native direct query |
|
|
73
|
+
| **CSV & GZ** | `.csv`, `.csv.gz` | `read_csv_auto(...)` |
|
|
74
|
+
| **JSON & JSONL** | `.json`, `.jsonl` | `read_json_auto(...)` |
|
|
75
|
+
| **Excel** | `.xlsx`, `.xls` | In-memory table registration (`pandas` + `openpyxl`) |
|
|
76
|
+
| **SQLite** | `.sqlite`, `.sqlite3`, `.db` | Automatic SQLite extension attach |
|
|
77
|
+
| **DuckDB** | `.duckdb` | Database attach & table explore |
|
|
78
|
+
|
|
79
|
+
---
|
|
80
|
+
|
|
81
|
+
## 🚀 Quickstart
|
|
82
|
+
|
|
83
|
+
### 1. Installation
|
|
84
|
+
|
|
85
|
+
**Via PyPI:**
|
|
86
|
+
```bash
|
|
87
|
+
pip install openduck-cli
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
**From source:**
|
|
91
|
+
```bash
|
|
92
|
+
git clone https://github.com/klandermans/openduck.git
|
|
93
|
+
cd openduck
|
|
94
|
+
pip install -r requirements.txt
|
|
95
|
+
# or: pip install -e .
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
> **Note:** `textual[syntax]` installs the required `tree-sitter` and `tree-sitter-sql` engines for syntax highlighting out of the box.
|
|
99
|
+
|
|
100
|
+
### 2. Run OpenDuck
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
python openduck.py
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Or make it available system-wide as a command:
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
chmod +x openduck.sh
|
|
110
|
+
sudo cp openduck.sh /usr/local/bin/openduck
|
|
111
|
+
openduck
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
---
|
|
115
|
+
|
|
116
|
+
## ⌨️ Keyboard & Mouse Shortcuts
|
|
117
|
+
|
|
118
|
+
| Shortcut | Description |
|
|
119
|
+
| :--- | :--- |
|
|
120
|
+
| `Ctrl+Enter` / `Cmd+Enter` | **Run Query** (runs selection if text is selected, or full editor) |
|
|
121
|
+
| `Ctrl+S` / `Cmd+S` | **Save Query** with custom name to `openduck.json` |
|
|
122
|
+
| `F3` / `Cmd+/` / `Ctrl+/` | **Toggle SQL Comment** (`--`) on current line or selection |
|
|
123
|
+
| `Ctrl+Z` / `Cmd+Z` | **Undo** last edit |
|
|
124
|
+
| `Ctrl+Y` / `Cmd+Shift+Z` | **Redo** undone edit |
|
|
125
|
+
| `Ctrl+W` / `Cmd+W` | **Close Current Tab** |
|
|
126
|
+
| `Ctrl+M` | **Toggle Mouse Mode** (switch between TUI mouse and native terminal text selection) |
|
|
127
|
+
| `Ctrl+C` / `Cmd+C` | **Copy** (Editor selection, or table row/cell to system clipboard) |
|
|
128
|
+
| `Ctrl+A` / `Cmd+A` | **Copy Table** (when focused on results) / Select all (in editor) |
|
|
129
|
+
| `F1` | **About Dialog** (click the duck for a surprise! 🕶️) |
|
|
130
|
+
| `Ctrl+Q` / `Cmd+Q` | **Quit** OpenDuck |
|
|
131
|
+
|
|
132
|
+
---
|
|
133
|
+
|
|
134
|
+
## ⚙️ Configuration & Storage
|
|
135
|
+
|
|
136
|
+
OpenDuck automatically manages `openduck.json` in your working directory. It persists:
|
|
137
|
+
- The last 200 executed queries in chronological order.
|
|
138
|
+
- Your named saved queries.
|
|
139
|
+
- Database configurations.
|
|
140
|
+
|
|
141
|
+
```json
|
|
142
|
+
{
|
|
143
|
+
"history": [
|
|
144
|
+
{
|
|
145
|
+
"sql": "SELECT * FROM read_csv_auto('sales.csv') WHERE revenue > 1000 LIMIT 100;",
|
|
146
|
+
"timestamp": "2026-10-14T15:30:00.123456"
|
|
147
|
+
}
|
|
148
|
+
],
|
|
149
|
+
"saved_queries": [
|
|
150
|
+
{
|
|
151
|
+
"name": "Q3 Revenue by Region",
|
|
152
|
+
"sql": "SELECT region, SUM(amount) FROM read_parquet('data/*.parquet') GROUP BY region;",
|
|
153
|
+
"timestamp": "2026-10-14T15:32:00.654321"
|
|
154
|
+
}
|
|
155
|
+
],
|
|
156
|
+
"connections": []
|
|
157
|
+
}
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
---
|
|
161
|
+
|
|
162
|
+
## 💡 Tips & Tricks
|
|
163
|
+
|
|
164
|
+
- **Click to Query**: Clicking any file in the left sidebar automatically populates the query into your active tab, updates the tab name, and executes the preview.
|
|
165
|
+
- **Run Selection Only**: Highlight any part of your SQL script and press `Ctrl+Enter` to run only the highlighted snippet — just like in DBeaver, DataGrip, or VS Code!
|
|
166
|
+
- **Selective Comments**: Select multiple lines and press `F3` or `Cmd+/` to comment or uncomment SQL blocks simultaneously.
|
|
167
|
+
- **Instant Clipboard Sharing**: Results copied via `Ctrl+C` or `Ctrl+A` are immediately synced to your macOS (`pbcopy`), Linux (`wl-copy`/`xclip`), or Windows clipboard.
|
|
168
|
+
|
|
169
|
+
---
|
|
170
|
+
|
|
171
|
+
## 📁 Project Structure
|
|
172
|
+
|
|
173
|
+
```text
|
|
174
|
+
openduck/
|
|
175
|
+
├── openduck/ # Core Python package
|
|
176
|
+
│ ├── __init__.py # Package entry and exports
|
|
177
|
+
│ ├── __main__.py # CLI invocation (python -m openduck)
|
|
178
|
+
│ ├── main.py # Application bootstrap & logging setup
|
|
179
|
+
│ ├── core/ # Core logic & backend
|
|
180
|
+
│ │ ├── config.py # Config persistence, query history, saved queries
|
|
181
|
+
│ │ ├── clipboard.py # Cross-platform system clipboard integration
|
|
182
|
+
│ │ ├── sql_utils.py # SQL formatting, file-to-query mapper, comments
|
|
183
|
+
│ │ └── error_classifier.py # Diagnostic error classifier
|
|
184
|
+
│ └── gui/ # Textual user interface
|
|
185
|
+
│ ├── app.py # Main DuckCLI application
|
|
186
|
+
│ ├── styles.py # Textual CSS stylesheets
|
|
187
|
+
│ ├── screens/ # Modal screens (About, Matrix screensaver)
|
|
188
|
+
│ ├── dialogs/ # Modal dialogs (Save Query, Export, Add DB)
|
|
189
|
+
│ └── widgets/ # Custom widgets (Editor, ResultsTable, Trees, etc.)
|
|
190
|
+
├── docs/ # Documentation & guides
|
|
191
|
+
├── scripts/ # Build scripts & packaging specs
|
|
192
|
+
├── tests/ # Automated test suite
|
|
193
|
+
├── samples/ # Sample datasets for testing & exploration
|
|
194
|
+
├── openduck.py # Root executable script
|
|
195
|
+
├── openduck.sh # Shell launcher script
|
|
196
|
+
└── pyproject.toml # Python package configuration
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
---
|
|
200
|
+
|
|
201
|
+
## 📜 License
|
|
202
|
+
|
|
203
|
+
Distributed under the **MIT License**. See `LICENSE` for details.
|
|
204
|
+
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
openduck/__init__.py,sha256=blZkVJJEm5MV8UvCh7dU0Uhlteew0j6RpQPsCZHKX9M,1022
|
|
2
|
+
openduck/__main__.py,sha256=vbtNJq4whJ_j0wceIQeAz_VpPmNUYEEK6K-X9HWfjyo,133
|
|
3
|
+
openduck/main.py,sha256=1on3Q6dNMHglPq7Dy4FmlBm8CAbCNIsx7PIOoSm5oXc,513
|
|
4
|
+
openduck/core/__init__.py,sha256=uXQyQ1S-e1NtHD2MkyrKZJoGVzAQnCGvKmScFFU3CyQ,924
|
|
5
|
+
openduck/core/clipboard.py,sha256=BMfgqngof3lqBAwzbqX7xSeRKi-p4xMaYusp48nvwjo,1996
|
|
6
|
+
openduck/core/config.py,sha256=Rwv0hYkDgeNymDRWsT11A6Ck3NzrJOfuX-B2OI5NTzI,3401
|
|
7
|
+
openduck/core/error_classifier.py,sha256=rPCUYt8DeBg8kW_U7Cf5_ulWFYm0E9Wf010MPzSKbxA,3123
|
|
8
|
+
openduck/core/sql_utils.py,sha256=SVUI3HUXAMuqBX5j_5uEXf_Mfdx5h4AdP_GjVXZ04lQ,2377
|
|
9
|
+
openduck/gui/__init__.py,sha256=bwTTe2INx35q9QOCxS4Twz9y-SsbtD_cDj_BJzOVvc8,146
|
|
10
|
+
openduck/gui/app.py,sha256=HsH_jy0BjUSe6XnqymU8Yiayf3fyFF1xBoFs3LVS22c,33735
|
|
11
|
+
openduck/gui/styles.py,sha256=Psog9l8jgpI9zhNcmYgiu2Eufo2M73eycEQ_jCzGvGs,2603
|
|
12
|
+
openduck/gui/dialogs/__init__.py,sha256=5coq1SnwAzs5wSWVN9IJu7HFgIT_3L9dQM2r_JpTsaI,307
|
|
13
|
+
openduck/gui/dialogs/add_connection_dialog.py,sha256=RuMlgIPJOJlJ3BY82pCl9-BLwZJT39Fj98YNbmJQqbY,4092
|
|
14
|
+
openduck/gui/dialogs/export_dialog.py,sha256=pb_Cy-priGiGIvB9isYXtnMEZ-GnnTIoqmYS2lZsdvA,4436
|
|
15
|
+
openduck/gui/dialogs/save_query_dialog.py,sha256=s0Qf8B5uaKoDx7jIRH__zj9R2_dzMwPyj0CkcMad7oI,1600
|
|
16
|
+
openduck/gui/screens/__init__.py,sha256=vp5_5h5igLaNjyvzbGfh1qR9aj4QCaBBL7WyY4FtpWU,244
|
|
17
|
+
openduck/gui/screens/about.py,sha256=myUEf0x12ORnm92SNdsKEw__67ZCnF9fYUGuunIc54o,3153
|
|
18
|
+
openduck/gui/screens/matrix.py,sha256=kxckiA0ErxAUFGVo2WK4dK-Nv1DkhRz8jKV2V-j2pJY,3181
|
|
19
|
+
openduck/gui/widgets/__init__.py,sha256=6v47jeMAjSuksc2QqOn6glV7LejuFl_uzMNaxqXMz3g,498
|
|
20
|
+
openduck/gui/widgets/database_tree.py,sha256=iyrZVsslvXlk2NX7O9HSGBnLO255ku7tnKWkMcCyHKU,4289
|
|
21
|
+
openduck/gui/widgets/file_tree.py,sha256=iCTeWoVVDk2WaBntZ544qz_hg4zr6FOibzOxtE06W4U,464
|
|
22
|
+
openduck/gui/widgets/query_tab.py,sha256=xnyr3hUIa231a1aPN6BtXOpsi4FJgSeCIwK8RJ6aZfM,2613
|
|
23
|
+
openduck/gui/widgets/resize_handle.py,sha256=g4nBIvS0sKf-lmnUEzFx6jwzCklsgxRX2fDHdIjMwtY,1089
|
|
24
|
+
openduck/gui/widgets/results_table.py,sha256=vUx2qTbV0y2nLRlSQbPaMqg4xWeJcClMTFUfLfXLzgI,2358
|
|
25
|
+
openduck/gui/widgets/sql_editor.py,sha256=16ADxQZfWJQo1byv51hruBCwv8P3njUrUvmdjJ5zNEg,8774
|
|
26
|
+
openduck_cli-0.1.0.dist-info/METADATA,sha256=1wHkEYmUW1zWjXOLgOkN3xriie9JTGPRudAG-Oy41pM,8670
|
|
27
|
+
openduck_cli-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
28
|
+
openduck_cli-0.1.0.dist-info/entry_points.txt,sha256=MHqMGWlgdS2pUc2ra2zovSadppo0RhYStZWkBMDWZ1I,82
|
|
29
|
+
openduck_cli-0.1.0.dist-info/top_level.txt,sha256=RI2CatGYvLV8ZiXyOEKqXcTfR5WEB7WOKc2SFfdZO3E,9
|
|
30
|
+
openduck_cli-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
openduck
|