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
openduck/__init__.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""OpenDuck - The Modern Terminal SQL IDE & Data Explorer for DuckDB."""
|
|
2
|
+
|
|
3
|
+
__version__ = "0.1.0"
|
|
4
|
+
|
|
5
|
+
from openduck.core.config import (
|
|
6
|
+
CWD,
|
|
7
|
+
CONFIG_FILE,
|
|
8
|
+
MAX_UI_ROWS,
|
|
9
|
+
DUCKDB_EXTENSIONS,
|
|
10
|
+
add_to_history,
|
|
11
|
+
delete_connection,
|
|
12
|
+
load_config,
|
|
13
|
+
save_config,
|
|
14
|
+
save_connection,
|
|
15
|
+
save_query,
|
|
16
|
+
)
|
|
17
|
+
from openduck.core.error_classifier import classify_file_error
|
|
18
|
+
from openduck.core.sql_utils import is_duckdb_file, sql_for_file, toggle_sql_comment_lines
|
|
19
|
+
from openduck.core.clipboard import copy_to_system_clipboard, get_system_clipboard
|
|
20
|
+
from openduck.gui.app import DuckCLI
|
|
21
|
+
from openduck.main import main
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"DuckCLI",
|
|
25
|
+
"main",
|
|
26
|
+
"load_config",
|
|
27
|
+
"save_config",
|
|
28
|
+
"add_to_history",
|
|
29
|
+
"save_query",
|
|
30
|
+
"save_connection",
|
|
31
|
+
"delete_connection",
|
|
32
|
+
"classify_file_error",
|
|
33
|
+
"is_duckdb_file",
|
|
34
|
+
"sql_for_file",
|
|
35
|
+
"toggle_sql_comment_lines",
|
|
36
|
+
"copy_to_system_clipboard",
|
|
37
|
+
"get_system_clipboard",
|
|
38
|
+
"CWD",
|
|
39
|
+
"CONFIG_FILE",
|
|
40
|
+
"MAX_UI_ROWS",
|
|
41
|
+
"DUCKDB_EXTENSIONS",
|
|
42
|
+
]
|
openduck/__main__.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Core modules for OpenDuck: config, clipboard, SQL utilities, and error classification."""
|
|
2
|
+
|
|
3
|
+
from openduck.core.clipboard import copy_to_system_clipboard, get_system_clipboard
|
|
4
|
+
from openduck.core.config import (
|
|
5
|
+
CONFIG_FILE,
|
|
6
|
+
CWD,
|
|
7
|
+
DUCKDB_EXTENSIONS,
|
|
8
|
+
MAX_UI_ROWS,
|
|
9
|
+
add_to_history,
|
|
10
|
+
delete_connection,
|
|
11
|
+
load_config,
|
|
12
|
+
save_config,
|
|
13
|
+
save_connection,
|
|
14
|
+
save_query,
|
|
15
|
+
)
|
|
16
|
+
from openduck.core.error_classifier import classify_file_error
|
|
17
|
+
from openduck.core.sql_utils import is_duckdb_file, sql_for_file, toggle_sql_comment_lines
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"CWD",
|
|
21
|
+
"CONFIG_FILE",
|
|
22
|
+
"MAX_UI_ROWS",
|
|
23
|
+
"DUCKDB_EXTENSIONS",
|
|
24
|
+
"load_config",
|
|
25
|
+
"save_config",
|
|
26
|
+
"add_to_history",
|
|
27
|
+
"save_query",
|
|
28
|
+
"save_connection",
|
|
29
|
+
"delete_connection",
|
|
30
|
+
"copy_to_system_clipboard",
|
|
31
|
+
"get_system_clipboard",
|
|
32
|
+
"is_duckdb_file",
|
|
33
|
+
"sql_for_file",
|
|
34
|
+
"toggle_sql_comment_lines",
|
|
35
|
+
"classify_file_error",
|
|
36
|
+
]
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Cross-platform system clipboard integration."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import subprocess
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
logger = logging.getLogger(__name__)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def copy_to_system_clipboard(text: str) -> bool:
|
|
11
|
+
"""Copy text to system clipboard (macOS pbcopy, xclip/wl-copy on Linux, clip on Windows)."""
|
|
12
|
+
if not text:
|
|
13
|
+
return False
|
|
14
|
+
try:
|
|
15
|
+
if sys.platform == "darwin":
|
|
16
|
+
p = subprocess.Popen(["pbcopy"], stdin=subprocess.PIPE, text=True)
|
|
17
|
+
p.communicate(input=text)
|
|
18
|
+
return True
|
|
19
|
+
elif sys.platform.startswith("linux"):
|
|
20
|
+
for cmd in (
|
|
21
|
+
["wl-copy"],
|
|
22
|
+
["xclip", "-selection", "clipboard"],
|
|
23
|
+
["xsel", "--clipboard", "--input"],
|
|
24
|
+
):
|
|
25
|
+
try:
|
|
26
|
+
p = subprocess.Popen(cmd, stdin=subprocess.PIPE, text=True)
|
|
27
|
+
p.communicate(input=text)
|
|
28
|
+
return True
|
|
29
|
+
except FileNotFoundError:
|
|
30
|
+
continue
|
|
31
|
+
elif sys.platform == "win32":
|
|
32
|
+
p = subprocess.Popen(["clip"], stdin=subprocess.PIPE, text=True)
|
|
33
|
+
p.communicate(input=text)
|
|
34
|
+
return True
|
|
35
|
+
except Exception as e:
|
|
36
|
+
logger.error(f"Failed to copy to system clipboard: {e}")
|
|
37
|
+
return False
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def get_system_clipboard() -> str:
|
|
41
|
+
"""Read text from system clipboard (macOS pbpaste, wl-paste/xclip on Linux)."""
|
|
42
|
+
try:
|
|
43
|
+
if sys.platform == "darwin":
|
|
44
|
+
return subprocess.check_output(["pbpaste"], text=True)
|
|
45
|
+
elif sys.platform.startswith("linux"):
|
|
46
|
+
for cmd in (
|
|
47
|
+
["wl-paste"],
|
|
48
|
+
["xclip", "-selection", "clipboard", "-o"],
|
|
49
|
+
["xsel", "--clipboard", "--output"],
|
|
50
|
+
):
|
|
51
|
+
try:
|
|
52
|
+
return subprocess.check_output(cmd, text=True)
|
|
53
|
+
except FileNotFoundError:
|
|
54
|
+
continue
|
|
55
|
+
except Exception as e:
|
|
56
|
+
logger.error(f"Failed to read from system clipboard: {e}")
|
|
57
|
+
return ""
|
openduck/core/config.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Configuration and persistence management for OpenDuck."""
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
import json
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
logger = logging.getLogger(__name__)
|
|
10
|
+
|
|
11
|
+
CWD = Path(os.getcwd())
|
|
12
|
+
CONFIG_FILE = CWD / "openduck.json"
|
|
13
|
+
MAX_UI_ROWS = 10000
|
|
14
|
+
DUCKDB_EXTENSIONS = {
|
|
15
|
+
".parquet", ".arrow", ".duckdb", ".csv", ".csv.gz",
|
|
16
|
+
".json", ".jsonl", ".sqlite", ".sqlite3", ".db", ".xlsx", ".xls"
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def load_config() -> dict:
|
|
21
|
+
"""Load config from openduck.json, create if doesn't exist or is invalid."""
|
|
22
|
+
if CONFIG_FILE.exists():
|
|
23
|
+
try:
|
|
24
|
+
with open(CONFIG_FILE, "r", encoding="utf-8") as f:
|
|
25
|
+
config = json.load(f)
|
|
26
|
+
if isinstance(config, dict):
|
|
27
|
+
if "connections" not in config:
|
|
28
|
+
config["connections"] = []
|
|
29
|
+
if "history" not in config:
|
|
30
|
+
config["history"] = []
|
|
31
|
+
if "saved_queries" not in config:
|
|
32
|
+
config["saved_queries"] = []
|
|
33
|
+
return config
|
|
34
|
+
except Exception as e:
|
|
35
|
+
logger.error(f"Failed to read config file, falling back to default: {e}")
|
|
36
|
+
|
|
37
|
+
default_config = {
|
|
38
|
+
"history": [],
|
|
39
|
+
"saved_queries": [],
|
|
40
|
+
"connections": []
|
|
41
|
+
}
|
|
42
|
+
save_config(default_config)
|
|
43
|
+
return default_config
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def save_config(config: dict) -> None:
|
|
47
|
+
"""Save config to openduck.json."""
|
|
48
|
+
with open(CONFIG_FILE, "w", encoding="utf-8") as f:
|
|
49
|
+
json.dump(config, f, indent=2, ensure_ascii=False)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def add_to_history(sql: str) -> dict:
|
|
53
|
+
"""Add a query to history in the config file, capped at the last 200 queries."""
|
|
54
|
+
config = load_config()
|
|
55
|
+
timestamp = datetime.now().isoformat()
|
|
56
|
+
config["history"].append({
|
|
57
|
+
"sql": sql.strip(),
|
|
58
|
+
"timestamp": timestamp
|
|
59
|
+
})
|
|
60
|
+
# Keep only the last 200 queries to prevent boundless file growth
|
|
61
|
+
config["history"] = config["history"][-200:]
|
|
62
|
+
save_config(config)
|
|
63
|
+
return config
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def save_query(name: str, sql: str) -> dict:
|
|
67
|
+
"""Save a query with a name to the config file."""
|
|
68
|
+
config = load_config()
|
|
69
|
+
timestamp = datetime.now().isoformat()
|
|
70
|
+
# Check if query with same name already exists and update it
|
|
71
|
+
for i, query in enumerate(config["saved_queries"]):
|
|
72
|
+
if query["name"] == name:
|
|
73
|
+
config["saved_queries"][i] = {
|
|
74
|
+
"name": name,
|
|
75
|
+
"sql": sql.strip(),
|
|
76
|
+
"timestamp": timestamp
|
|
77
|
+
}
|
|
78
|
+
save_config(config)
|
|
79
|
+
return config
|
|
80
|
+
# If not found, add as new query
|
|
81
|
+
config["saved_queries"].append({
|
|
82
|
+
"name": name,
|
|
83
|
+
"sql": sql.strip(),
|
|
84
|
+
"timestamp": timestamp
|
|
85
|
+
})
|
|
86
|
+
save_config(config)
|
|
87
|
+
return config
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def save_connection(conn_info: dict) -> dict:
|
|
91
|
+
"""Save a database connection to config."""
|
|
92
|
+
config = load_config()
|
|
93
|
+
for i, c in enumerate(config["connections"]):
|
|
94
|
+
if c["id"] == conn_info["id"]:
|
|
95
|
+
config["connections"][i] = conn_info
|
|
96
|
+
save_config(config)
|
|
97
|
+
return config
|
|
98
|
+
config["connections"].append(conn_info)
|
|
99
|
+
save_config(config)
|
|
100
|
+
return config
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def delete_connection(conn_id: str) -> dict:
|
|
104
|
+
"""Delete a database connection from config."""
|
|
105
|
+
config = load_config()
|
|
106
|
+
config["connections"] = [c for c in config["connections"] if c["id"] != conn_id]
|
|
107
|
+
save_config(config)
|
|
108
|
+
return config
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Diagnostic classification for file-related query errors."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def classify_file_error(e: Exception, file_path: str = "") -> str:
|
|
5
|
+
"""Classify file-related errors and return user-friendly message."""
|
|
6
|
+
error_msg = str(e).lower()
|
|
7
|
+
original_error = str(e)
|
|
8
|
+
|
|
9
|
+
# CSV-specific errors
|
|
10
|
+
if 'csv' in error_msg or 'read_csv' in error_msg:
|
|
11
|
+
if 'no such file' in error_msg or 'could not open' in error_msg:
|
|
12
|
+
return f"❌ File not found: {file_path}\n→ Check if the file exists and the path is correct"
|
|
13
|
+
elif 'permission' in error_msg:
|
|
14
|
+
return f"❌ Permission denied: {file_path}\n→ Check file permissions"
|
|
15
|
+
elif 'encoding' in error_msg:
|
|
16
|
+
return f"❌ Encoding error in CSV file\n→ The CSV file may have encoding issues. Try saving it with UTF-8 encoding"
|
|
17
|
+
elif 'delimiter' in error_msg or 'separator' in error_msg:
|
|
18
|
+
return f"❌ CSV format error\n→ The file may have inconsistent delimiters. Check the CSV format"
|
|
19
|
+
elif 'empty' in error_msg or 'no columns' in error_msg or 'no rows' in error_msg:
|
|
20
|
+
return f"❌ Empty or invalid CSV file\n→ The file appears to be empty or has no valid columns"
|
|
21
|
+
else:
|
|
22
|
+
return f"❌ Cannot read CSV file: {file_path}\n→ The file may be corrupted, empty, or in an unsupported format\n\nDetails: {str(e)}"
|
|
23
|
+
|
|
24
|
+
# Excel-specific errors
|
|
25
|
+
elif 'excel' in error_msg or 'xlsx' in error_msg or 'xls' in error_msg:
|
|
26
|
+
if 'no such file' in error_msg:
|
|
27
|
+
return f"❌ Excel file not found: {file_path}"
|
|
28
|
+
elif 'permission' in error_msg:
|
|
29
|
+
return f"❌ Permission denied: {file_path}"
|
|
30
|
+
else:
|
|
31
|
+
return f"❌ Cannot read Excel file: {file_path}\n→ The file may be corrupted, password-protected, or in an unsupported format\n\nDetails: {str(e)}"
|
|
32
|
+
|
|
33
|
+
# Parquet/Arrow errors
|
|
34
|
+
elif 'parquet' in error_msg or 'arrow' in error_msg:
|
|
35
|
+
return f"❌ Cannot read Parquet/Arrow file: {file_path}\n→ The file may be corrupted or in an incompatible format\n\nDetails: {str(e)}"
|
|
36
|
+
|
|
37
|
+
# JSON errors
|
|
38
|
+
elif 'json' in error_msg:
|
|
39
|
+
return f"❌ Cannot read JSON file: {file_path}\n→ The file may have invalid JSON syntax\n\nDetails: {str(e)}"
|
|
40
|
+
|
|
41
|
+
# Generic file errors
|
|
42
|
+
elif 'no such file' in error_msg or 'does not exist' in error_msg:
|
|
43
|
+
return f"❌ File not found: {file_path}\n→ The file does not exist at this location"
|
|
44
|
+
elif 'permission' in error_msg:
|
|
45
|
+
return f"❌ Permission denied: {file_path}\n→ You don't have permission to access this file"
|
|
46
|
+
elif 'disk i/o' in error_msg or 'io error' in error_msg:
|
|
47
|
+
return f"❌ Disk I/O error\n→ There may be a problem with the disk or file system"
|
|
48
|
+
elif 'out of memory' in error_msg:
|
|
49
|
+
return f"❌ Out of memory\n→ The file is too large to load into memory"
|
|
50
|
+
elif 'empty' in original_error.lower() or 'no columns' in original_error.lower():
|
|
51
|
+
return f"❌ Empty or invalid file\n→ The file appears to be empty or has no valid data"
|
|
52
|
+
else:
|
|
53
|
+
return f"❌ Error loading file: {file_path}\n\nDetails: {str(e)}"
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""SQL utilities, formatting, and file-query mappings."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
import re
|
|
5
|
+
from typing import List
|
|
6
|
+
|
|
7
|
+
from openduck.core.config import DUCKDB_EXTENSIONS
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def is_duckdb_file(path: Path) -> bool:
|
|
11
|
+
"""Check if the given path is a file supported by DuckDB/OpenDuck."""
|
|
12
|
+
return path.is_file() and any(path.name.lower().endswith(ext) for ext in DUCKDB_EXTENSIONS)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def sql_for_file(path: Path) -> str:
|
|
16
|
+
"""Generate the initial SQL query statement to preview a given file."""
|
|
17
|
+
p, s = path.as_posix(), path.suffix.lower()
|
|
18
|
+
if s == ".duckdb":
|
|
19
|
+
return f"ATTACH '{p}' AS other;\nSHOW TABLES;"
|
|
20
|
+
if s in {".sqlite", ".sqlite3", ".db"}:
|
|
21
|
+
return f"INSTALL sqlite;\nLOAD sqlite;\nATTACH '{p}' AS sqlite_db (TYPE SQLITE);\nSHOW TABLES FROM sqlite_db;"
|
|
22
|
+
if s in {".xlsx", ".xls"}:
|
|
23
|
+
table_name = re.sub(r'\W|^(?=\d)', '_', path.stem.replace(' ', '_').replace('-', '_'))
|
|
24
|
+
return f"SELECT * FROM {table_name} LIMIT 100;"
|
|
25
|
+
if s in {".csv", ".csv.gz"}:
|
|
26
|
+
return f"SELECT * FROM read_csv_auto('{p}') LIMIT 100;"
|
|
27
|
+
if s in {".json", ".jsonl"}:
|
|
28
|
+
return f"SELECT * FROM read_json_auto('{p}') LIMIT 100;"
|
|
29
|
+
return f"SELECT * FROM '{p}' LIMIT 100;"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def toggle_sql_comment_lines(lines: List[str]) -> List[str]:
|
|
33
|
+
"""Toggle SQL line comments (-- ) like in VS Code."""
|
|
34
|
+
non_empty = [l for l in lines if l.strip()]
|
|
35
|
+
if not non_empty:
|
|
36
|
+
return [("-- " + l if not l.startswith("--") else l) for l in lines]
|
|
37
|
+
|
|
38
|
+
all_commented = all(l.lstrip().startswith("--") for l in non_empty)
|
|
39
|
+
result = []
|
|
40
|
+
if all_commented:
|
|
41
|
+
for l in lines:
|
|
42
|
+
if not l.strip():
|
|
43
|
+
result.append(l)
|
|
44
|
+
continue
|
|
45
|
+
indent_len = len(l) - len(l.lstrip())
|
|
46
|
+
indent = l[:indent_len]
|
|
47
|
+
content = l[indent_len:]
|
|
48
|
+
if content.startswith("-- "):
|
|
49
|
+
result.append(indent + content[3:])
|
|
50
|
+
elif content.startswith("--"):
|
|
51
|
+
result.append(indent + content[2:])
|
|
52
|
+
else:
|
|
53
|
+
result.append(l)
|
|
54
|
+
else:
|
|
55
|
+
for l in lines:
|
|
56
|
+
if not l.strip():
|
|
57
|
+
result.append(l)
|
|
58
|
+
continue
|
|
59
|
+
indent_len = len(l) - len(l.lstrip())
|
|
60
|
+
indent = l[:indent_len]
|
|
61
|
+
content = l[indent_len:]
|
|
62
|
+
result.append(indent + "-- " + content)
|
|
63
|
+
return result
|