shoveler 0.1.0__tar.gz

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.
@@ -0,0 +1,34 @@
1
+ # Build artifacts
2
+ dist/
3
+ build/
4
+ *.egg-info/
5
+ __pycache__/
6
+ *.py[cod]
7
+ *.pyo
8
+
9
+ # PyInstaller output — commit the .spec file, not the output
10
+ dist/
11
+ build/
12
+
13
+ # uv — commit uv.lock to the repo, ignore the venv
14
+ .venv/
15
+
16
+ # Testing
17
+ .pytest_cache/
18
+ .coverage
19
+ htmlcov/
20
+
21
+ # IDE
22
+ .vscode/
23
+ .idea/
24
+ *.swp
25
+
26
+ # Local agent instructions
27
+ .github/copilot-instructions.md
28
+
29
+ # OS
30
+ .DS_Store
31
+ Thumbs.db
32
+
33
+ # Github
34
+ .github/
@@ -0,0 +1 @@
1
+ 3.14
@@ -0,0 +1,90 @@
1
+ Metadata-Version: 2.4
2
+ Name: shoveler
3
+ Version: 0.1.0
4
+ Summary: Shoveler: A minimal desktop SQL workbench for DuckDB
5
+ License: MIT
6
+ Keywords: duckdb,education,sql,workbench
7
+ Requires-Python: >=3.11
8
+ Requires-Dist: duckdb>=0.10
9
+ Requires-Dist: pyside6>=6.6
10
+ Description-Content-Type: text/markdown
11
+
12
+ # DuckDB Workbench
13
+
14
+ A minimal desktop SQL workbench for DuckDB, built with Python and PySide6.
15
+
16
+ ## Setup (uv)
17
+
18
+ ```shell
19
+ git clone https://github.com/mrmatho/shoveler
20
+ cd shoveler
21
+ uv sync
22
+ ```
23
+
24
+ `uv sync` creates a `.venv`, installs all dependencies including the dev group,
25
+ and respects `uv.lock` if it exists. Run it again after pulling changes.
26
+
27
+ ## Running
28
+
29
+ ```shell
30
+ uv run python -m shoveler
31
+ ```
32
+
33
+ ## Testing
34
+
35
+ ```shell
36
+ uv run pytest
37
+ ```
38
+
39
+ ## Managing dependencies
40
+
41
+ ```shell
42
+ uv add somepackage # add a runtime dependency
43
+ uv add --dev somepackage # add to the dev group (not published to PyPI)
44
+ uv remove somepackage # remove a dependency
45
+ uv lock # regenerate uv.lock without installing
46
+ uv sync # install from uv.lock
47
+ ```
48
+
49
+ Commit `uv.lock` to the repository. It pins exact versions for reproducible installs.
50
+
51
+ ## Usage
52
+
53
+ - **Open File** — connect to an existing `.duckdb` or `.db` file (green indicator)
54
+ - **New In-Memory** — scratch database; data is lost when closed (amber indicator)
55
+ - **File > Save Database As...** — save current database state to a `.duckdb` file
56
+ - **Checkpoint** — flush the write-ahead log to disk (file databases only)
57
+ - **F5 or Ctrl+Enter** — run query
58
+ - Select part of your SQL to run only that selection
59
+ - Double-click a table name in the schema panel to insert it into the editor
60
+ - **View > Syntax highlighting** — toggle SQL highlighting on or off; enabled by default and remembered between launches
61
+
62
+ ## Building a standalone Windows executable
63
+
64
+ The project includes `shoveler.spec` with the correct PyInstaller
65
+ configuration. Use this rather than running `pyinstaller` with flags directly —
66
+ the spec handles a non-obvious issue with DuckDB's compiled extension.
67
+
68
+ ```shell
69
+ uv run pyinstaller shoveler.spec
70
+ ```
71
+
72
+ Output is in `dist/Shoveler/`. Distribute that folder as a zip.
73
+
74
+ **Why --onedir and not --onefile?**
75
+ `--onefile` extracts everything to a temp directory on every launch, which makes
76
+ startup noticeably slow for a PySide6 app. `--onedir` is faster and easier to
77
+ debug if something goes wrong.
78
+
79
+ **Known PyInstaller issue with DuckDB:**
80
+ DuckDB's compiled core (`_duckdb.pyd` on Windows) lives outside the `duckdb/`
81
+ package directory. PyInstaller does not find it automatically. The spec file
82
+ uses `collect_all('duckdb')` which handles this correctly. If you ever regenerate
83
+ the spec from scratch, make sure this is included or the packaged app will fail
84
+ to start.
85
+
86
+ ## Extending
87
+
88
+ |Feature|Where to add it|
89
+ |---|---|
90
+ |More export formats|Add menu items in `ResultsPanel._show_export_menu()` — raw data is in `_last_rows` / `_last_columns`|
@@ -0,0 +1,79 @@
1
+ # DuckDB Workbench
2
+
3
+ A minimal desktop SQL workbench for DuckDB, built with Python and PySide6.
4
+
5
+ ## Setup (uv)
6
+
7
+ ```shell
8
+ git clone https://github.com/mrmatho/shoveler
9
+ cd shoveler
10
+ uv sync
11
+ ```
12
+
13
+ `uv sync` creates a `.venv`, installs all dependencies including the dev group,
14
+ and respects `uv.lock` if it exists. Run it again after pulling changes.
15
+
16
+ ## Running
17
+
18
+ ```shell
19
+ uv run python -m shoveler
20
+ ```
21
+
22
+ ## Testing
23
+
24
+ ```shell
25
+ uv run pytest
26
+ ```
27
+
28
+ ## Managing dependencies
29
+
30
+ ```shell
31
+ uv add somepackage # add a runtime dependency
32
+ uv add --dev somepackage # add to the dev group (not published to PyPI)
33
+ uv remove somepackage # remove a dependency
34
+ uv lock # regenerate uv.lock without installing
35
+ uv sync # install from uv.lock
36
+ ```
37
+
38
+ Commit `uv.lock` to the repository. It pins exact versions for reproducible installs.
39
+
40
+ ## Usage
41
+
42
+ - **Open File** — connect to an existing `.duckdb` or `.db` file (green indicator)
43
+ - **New In-Memory** — scratch database; data is lost when closed (amber indicator)
44
+ - **File > Save Database As...** — save current database state to a `.duckdb` file
45
+ - **Checkpoint** — flush the write-ahead log to disk (file databases only)
46
+ - **F5 or Ctrl+Enter** — run query
47
+ - Select part of your SQL to run only that selection
48
+ - Double-click a table name in the schema panel to insert it into the editor
49
+ - **View > Syntax highlighting** — toggle SQL highlighting on or off; enabled by default and remembered between launches
50
+
51
+ ## Building a standalone Windows executable
52
+
53
+ The project includes `shoveler.spec` with the correct PyInstaller
54
+ configuration. Use this rather than running `pyinstaller` with flags directly —
55
+ the spec handles a non-obvious issue with DuckDB's compiled extension.
56
+
57
+ ```shell
58
+ uv run pyinstaller shoveler.spec
59
+ ```
60
+
61
+ Output is in `dist/Shoveler/`. Distribute that folder as a zip.
62
+
63
+ **Why --onedir and not --onefile?**
64
+ `--onefile` extracts everything to a temp directory on every launch, which makes
65
+ startup noticeably slow for a PySide6 app. `--onedir` is faster and easier to
66
+ debug if something goes wrong.
67
+
68
+ **Known PyInstaller issue with DuckDB:**
69
+ DuckDB's compiled core (`_duckdb.pyd` on Windows) lives outside the `duckdb/`
70
+ package directory. PyInstaller does not find it automatically. The spec file
71
+ uses `collect_all('duckdb')` which handles this correctly. If you ever regenerate
72
+ the spec from scratch, make sure this is included or the packaged app will fail
73
+ to start.
74
+
75
+ ## Extending
76
+
77
+ |Feature|Where to add it|
78
+ |---|---|
79
+ |More export formats|Add menu items in `ResultsPanel._show_export_menu()` — raw data is in `_last_rows` / `_last_columns`|
@@ -0,0 +1,35 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "shoveler"
7
+ version = "0.1.0"
8
+ description = "Shoveler: A minimal desktop SQL workbench for DuckDB"
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ license = { text = "MIT" }
12
+ keywords = ["duckdb", "sql", "workbench", "education"]
13
+ dependencies = [
14
+ "PySide6>=6.6",
15
+ "duckdb>=0.10",
16
+ ]
17
+
18
+ # gui-scripts suppresses the Windows console window; use this instead of scripts
19
+ [project.gui-scripts]
20
+ shoveler = "shoveler.__main__:main"
21
+
22
+ [tool.hatch.build.targets.wheel]
23
+ packages = ["src/shoveler"]
24
+
25
+ # dependency-groups are uv/PEP 735 dev-only deps.
26
+ # They are NOT published to PyPI — use [project.optional-dependencies] for that.
27
+ # Install with: uv sync (dev group is included by default)
28
+ [dependency-groups]
29
+ dev = [
30
+ "pytest>=8.0",
31
+ "pyinstaller>=6.0",
32
+ ]
33
+
34
+ [tool.pytest.ini_options]
35
+ testpaths = ["tests"]
@@ -0,0 +1,57 @@
1
+ # shoveler.spec
2
+ #
3
+ # Build with: uv run pyinstaller shoveler.spec
4
+ # Output: dist/Shoveler/ (--onedir, recommended)
5
+ #
6
+ # Why --onedir and not --onefile:
7
+ # --onefile extracts everything to a temp directory on every launch,
8
+ # making startup slow. --onedir is faster and easier to debug.
9
+ # Distribute the dist/DuckDB Workbench/ folder as a zip.
10
+
11
+ from PyInstaller.utils.hooks import collect_all
12
+
13
+ # collect_all picks up:
14
+ # - the duckdb Python package
15
+ # - _duckdb.pyd (the compiled extension, which lives outside the package dir)
16
+ # - any data files shipped with duckdb
17
+ # Without this, the packaged app fails at runtime with an ImportError on _duckdb.
18
+ duckdb_datas, duckdb_binaries, duckdb_hiddenimports = collect_all("duckdb")
19
+
20
+ a = Analysis(
21
+ ["src/shoveler/__main__.py"],
22
+ pathex=[],
23
+ binaries=duckdb_binaries,
24
+ datas=duckdb_datas,
25
+ hiddenimports=duckdb_hiddenimports,
26
+ hookspath=[],
27
+ hooksconfig={},
28
+ runtime_hooks=[],
29
+ excludes=[],
30
+ noarchive=False,
31
+ )
32
+
33
+ pyz = PYZ(a.pure)
34
+
35
+ exe = EXE(
36
+ pyz,
37
+ a.scripts,
38
+ [],
39
+ exclude_binaries=True, # required for COLLECT (onedir mode)
40
+ name="Shoveler",
41
+ debug=False,
42
+ bootloader_ignore_signals=False,
43
+ strip=False,
44
+ upx=True,
45
+ console=False, # no console window — same effect as gui-scripts in pyproject.toml
46
+ disable_windowed_traceback=False,
47
+ )
48
+
49
+ coll = COLLECT(
50
+ exe,
51
+ a.binaries,
52
+ a.datas,
53
+ strip=False,
54
+ upx=True,
55
+ upx_exclude=[],
56
+ name="Shoveler",
57
+ )
@@ -0,0 +1,7 @@
1
+ from importlib.metadata import version, PackageNotFoundError
2
+
3
+ try:
4
+ __version__ = version("shoveler")
5
+ except PackageNotFoundError:
6
+ # Running from source without being installed
7
+ __version__ = "0.0.0.dev"
@@ -0,0 +1,20 @@
1
+ import sys
2
+ from PySide6.QtWidgets import QApplication
3
+
4
+ try:
5
+ from .main_window import MainWindow
6
+ except ImportError:
7
+ # PyInstaller may execute this file as a top-level script during startup.
8
+ from shoveler.main_window import MainWindow
9
+
10
+
11
+ def main():
12
+ app = QApplication(sys.argv)
13
+ app.setStyle("Fusion")
14
+ window = MainWindow()
15
+ window.show()
16
+ sys.exit(app.exec())
17
+
18
+
19
+ if __name__ == "__main__":
20
+ main()
@@ -0,0 +1,112 @@
1
+ import duckdb
2
+ import os
3
+ import time
4
+ from typing import Literal
5
+
6
+
7
+ class Database:
8
+ def __init__(self):
9
+ self.conn = None
10
+ self.mode: Literal["file", "memory"] | None = None
11
+ self.path: str | None = None
12
+
13
+ def open_file(self, path: str):
14
+ self._close_existing()
15
+ self.conn = duckdb.connect(path)
16
+ self.mode = "file"
17
+ self.path = path
18
+
19
+ def new_memory(self):
20
+ self._close_existing()
21
+ self.conn = duckdb.connect(":memory:")
22
+ self.mode = "memory"
23
+ self.path = None
24
+
25
+ def checkpoint(self):
26
+ if self.mode == "file" and self.conn:
27
+ self.conn.execute("CHECKPOINT")
28
+
29
+ def save_as(self, path: str) -> str:
30
+ if not self.conn:
31
+ raise RuntimeError("No database connected.")
32
+
33
+ target_path = os.path.abspath(path)
34
+ if self.mode == "file" and self.path and os.path.abspath(self.path) == target_path:
35
+ self.checkpoint()
36
+ return target_path
37
+
38
+ source_catalog = self.conn.execute("PRAGMA database_list").fetchone()[1]
39
+ source_catalog_escaped = source_catalog.replace('"', '""')
40
+ target_path_escaped = target_path.replace("'", "''")
41
+ target_catalog = "shoveler_save_target"
42
+
43
+ self.conn.execute(f"ATTACH '{target_path_escaped}' AS {target_catalog}")
44
+ try:
45
+ self.conn.execute(
46
+ f'COPY FROM DATABASE "{source_catalog_escaped}" TO {target_catalog}'
47
+ )
48
+ finally:
49
+ self.conn.execute(f"DETACH {target_catalog}")
50
+
51
+ self.open_file(target_path)
52
+ return target_path
53
+
54
+ def execute(self, sql: str) -> dict:
55
+ """Returns dict: columns, rows, elapsed, error"""
56
+ if not self.conn:
57
+ return {
58
+ "columns": [],
59
+ "rows": [],
60
+ "elapsed": 0.0,
61
+ "error": "No database connected.",
62
+ }
63
+ start = time.perf_counter()
64
+ try:
65
+ result = self.conn.execute(sql)
66
+ elapsed = time.perf_counter() - start
67
+ if result.description:
68
+ columns = [desc[0] for desc in result.description]
69
+ rows = result.fetchall()
70
+ else:
71
+ columns = []
72
+ rows = []
73
+ return {"columns": columns, "rows": rows, "elapsed": elapsed, "error": None}
74
+ except Exception as e:
75
+ elapsed = time.perf_counter() - start
76
+ return {"columns": [], "rows": [], "elapsed": elapsed, "error": str(e)}
77
+
78
+ def get_tables(self) -> list[str]:
79
+ if not self.conn:
80
+ return []
81
+ try:
82
+ result = self.conn.execute("SHOW TABLES").fetchall()
83
+ return [row[0] for row in result]
84
+ except Exception:
85
+ return []
86
+
87
+ def get_columns(self, table: str) -> list[tuple[str, str]]:
88
+ if not self.conn:
89
+ return []
90
+ try:
91
+ # DESCRIBE returns: column_name, column_type, null, key, default, extra
92
+ result = self.conn.execute(f"DESCRIBE {table}").fetchall()
93
+ return [(row[0], row[1]) for row in result]
94
+ except Exception:
95
+ return []
96
+
97
+ def _close_existing(self):
98
+ if self.conn:
99
+ try:
100
+ self.conn.close()
101
+ except Exception:
102
+ pass
103
+ self.conn = None
104
+
105
+ def close(self):
106
+ self._close_existing()
107
+ self.mode = None
108
+ self.path = None
109
+
110
+ @property
111
+ def is_connected(self) -> bool:
112
+ return self.conn is not None
@@ -0,0 +1,123 @@
1
+ import os
2
+
3
+ from PySide6.QtWidgets import (
4
+ QWidget,
5
+ QHBoxLayout,
6
+ QLabel,
7
+ QPushButton,
8
+ QFileDialog,
9
+ QFrame,
10
+ )
11
+ from PySide6.QtCore import Signal, Qt
12
+
13
+
14
+ # Colour constants — change here to restyle the indicator globally
15
+ COLOUR_FILE = "#2a9d2a" # green → file-backed database
16
+ COLOUR_MEMORY = "#e6a817" # amber → in-memory (unsaved)
17
+ COLOUR_NONE = "#999999" # grey → nothing connected
18
+
19
+
20
+ class DatabaseStatusWidget(QFrame):
21
+ """
22
+ Toolbar-level widget showing connection state with a colour-coded dot.
23
+
24
+ Signals:
25
+ file_opened(path) — user selected a .duckdb file
26
+ memory_requested() — user clicked New In-Memory
27
+ checkpoint_requested() — user clicked Checkpoint
28
+ """
29
+
30
+ file_opened = Signal(str)
31
+ memory_requested = Signal()
32
+ checkpoint_requested = Signal()
33
+
34
+ def __init__(self, parent=None):
35
+ super().__init__(parent)
36
+ self.setFrameShape(QFrame.Shape.StyledPanel)
37
+ self.setFixedHeight(38)
38
+
39
+ layout = QHBoxLayout(self)
40
+ layout.setContentsMargins(10, 0, 10, 0)
41
+ layout.setSpacing(10)
42
+
43
+ # Colour dot
44
+ self.dot = QLabel("●")
45
+ self.dot.setFixedWidth(16)
46
+ self.dot.setAlignment(Qt.AlignmentFlag.AlignVCenter)
47
+ layout.addWidget(self.dot)
48
+
49
+ # Status text
50
+ self.db_label = QLabel()
51
+ self.db_label.setStyleSheet("font-size: 12px;")
52
+ layout.addWidget(self.db_label)
53
+
54
+ layout.addStretch()
55
+
56
+ # Buttons
57
+ self.open_btn = QPushButton("Open File…")
58
+ self.open_btn.setFixedWidth(95)
59
+ self.open_btn.clicked.connect(self._on_open_file)
60
+ layout.addWidget(self.open_btn)
61
+
62
+ self.memory_btn = QPushButton("New In-Memory")
63
+ self.memory_btn.setFixedWidth(115)
64
+ self.memory_btn.clicked.connect(self.memory_requested.emit)
65
+ layout.addWidget(self.memory_btn)
66
+
67
+ self.checkpoint_btn = QPushButton("Checkpoint")
68
+ self.checkpoint_btn.setFixedWidth(95)
69
+ self.checkpoint_btn.setToolTip(
70
+ "Flush the write-ahead log to disk (file databases only)"
71
+ )
72
+ self.checkpoint_btn.clicked.connect(self.checkpoint_requested.emit)
73
+ layout.addWidget(self.checkpoint_btn)
74
+
75
+ self._set_state_disconnected()
76
+
77
+ # ── Public state setters ────────────────────────────────────────────────
78
+
79
+ def set_file_mode(self, path: str):
80
+ filename = os.path.basename(path)
81
+ self._apply(
82
+ colour=COLOUR_FILE,
83
+ text=f"<b>{filename}</b> <span style='color:#888'>({path})</span>",
84
+ checkpoint_enabled=True,
85
+ )
86
+
87
+ def set_memory_mode(self):
88
+ self._apply(
89
+ colour=COLOUR_MEMORY,
90
+ text="<b>In-memory</b> <span style='color:#888'>(unsaved — data is lost when closed)</span>",
91
+ checkpoint_enabled=False,
92
+ )
93
+
94
+ def notify_checkpoint_ok(self):
95
+ """Brief visual confirmation after a successful checkpoint."""
96
+ current = self.db_label.text()
97
+ # Append a transient tick — main window resets this on next open
98
+ if "✓" not in current:
99
+ self.db_label.setText(current + " ✓")
100
+
101
+ # ── Internals ───────────────────────────────────────────────────────────
102
+
103
+ def _set_state_disconnected(self):
104
+ self._apply(
105
+ colour=COLOUR_NONE,
106
+ text="No database connected",
107
+ checkpoint_enabled=False,
108
+ )
109
+
110
+ def _apply(self, colour: str, text: str, checkpoint_enabled: bool):
111
+ self.dot.setStyleSheet(f"font-size: 18px; color: {colour};")
112
+ self.db_label.setText(text)
113
+ self.checkpoint_btn.setEnabled(checkpoint_enabled)
114
+
115
+ def _on_open_file(self):
116
+ path, _ = QFileDialog.getOpenFileName(
117
+ self,
118
+ "Open DuckDB Database",
119
+ "",
120
+ "DuckDB Files (*.duckdb *.db);;All Files (*)",
121
+ )
122
+ if path:
123
+ self.file_opened.emit(path)