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 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,6 @@
1
+ """Allow running openduck as a module: python -m openduck."""
2
+
3
+ from openduck.main import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
@@ -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 ""
@@ -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
@@ -0,0 +1,5 @@
1
+ """GUI module for OpenDuck containing application, screens, widgets, and dialogs."""
2
+
3
+ from openduck.gui.app import DuckCLI
4
+
5
+ __all__ = ["DuckCLI"]