diff --git a/README.md b/README.md index afdc9888..514c5bf5 100644 --- a/README.md +++ b/README.md @@ -154,7 +154,7 @@ sidemantic migrator --queries legacy/ --generate-models output/ **Workbench** (TUI with SQL editor + charts): ```bash -uvx sidemantic workbench --demo +uvx --from "sidemantic[workbench]" sidemantic workbench --demo ``` **PostgreSQL server** (connect Tableau, DBeaver, etc.): diff --git a/sidemantic/cli.py b/sidemantic/cli.py index 3e1f088f..f0a81948 100644 --- a/sidemantic/cli.py +++ b/sidemantic/cli.py @@ -739,13 +739,17 @@ def tree( """ Alias for 'workbench' command (deprecated). """ - from sidemantic.workbench import run_workbench + from sidemantic.workbench import WorkbenchDependencyError, run_workbench if not directory.exists(): typer.echo(f"Error: Directory {directory} does not exist", err=True) raise typer.Exit(1) - run_workbench(directory) + try: + run_workbench(directory) + except WorkbenchDependencyError as e: + typer.echo(f"Error: {e}", err=True) + raise typer.Exit(1) @app.command() @@ -762,13 +766,17 @@ def validate( sidemantic validate sidemantic validate ./models --verbose """ - from sidemantic.workbench import run_validation + from sidemantic.workbench import WorkbenchDependencyError, run_validation if not directory.exists(): typer.echo(f"Error: Directory {directory} does not exist", err=True) raise typer.Exit(1) - run_validation(directory, verbose=verbose) + try: + run_validation(directory, verbose=verbose) + except WorkbenchDependencyError as e: + typer.echo(f"Error: {e}", err=True) + raise typer.Exit(1) @app.command() @@ -790,9 +798,9 @@ def workbench( sidemantic workbench --demo sidemantic workbench ./models --db data/warehouse.db sidemantic workbench ./models --connection "postgres://localhost:5432/db" - uvx sidemantic workbench --demo + uvx --from 'sidemantic[workbench]' sidemantic workbench --demo """ - from sidemantic.workbench import run_workbench + from sidemantic.workbench import WorkbenchDependencyError, run_workbench if demo: import sidemantic @@ -813,7 +821,11 @@ def workbench( raise typer.Exit(1) directory = demo_dir - run_workbench(directory, demo_mode=True, connection=None) + try: + run_workbench(directory, demo_mode=True, connection=None) + except WorkbenchDependencyError as e: + typer.echo(f"Error: {e}", err=True) + raise typer.Exit(1) elif not directory.exists(): typer.echo(f"Error: Directory {directory} does not exist", err=True) raise typer.Exit(1) @@ -831,10 +843,14 @@ def workbench( connection_str = build_connection_string(_loaded_config) # Only pass connection if it's not None - if connection_str: - run_workbench(directory, connection=connection_str) - else: - run_workbench(directory) + try: + if connection_str: + run_workbench(directory, connection=connection_str) + else: + run_workbench(directory) + except WorkbenchDependencyError as e: + typer.echo(f"Error: {e}", err=True) + raise typer.Exit(1) # Pre-aggregation recommendation commands diff --git a/sidemantic/workbench/__init__.py b/sidemantic/workbench/__init__.py index cdf26164..57b3ce57 100644 --- a/sidemantic/workbench/__init__.py +++ b/sidemantic/workbench/__init__.py @@ -2,17 +2,50 @@ from pathlib import Path -from sidemantic.workbench.app import SidequeryWorkbench -from sidemantic.workbench.validation_app import ValidationApp +WORKBENCH_EXTRA_INSTALL = "uvx --from 'sidemantic[workbench]' sidemantic workbench --demo" +WORKBENCH_EXTRA_ADD = "uv add 'sidemantic[workbench]'" + + +class WorkbenchDependencyError(RuntimeError): + """Raised when optional workbench dependencies are not installed.""" + + +def _is_optional_workbench_dependency(module_name: str | None) -> bool: + if not module_name: + return False + return module_name == "plotext" or module_name.startswith("textual") + + +def _missing_dependency_message(module_name: str | None, command: str) -> str: + missing = module_name or "required package" + return ( + f"Missing optional dependency for `sidemantic {command}`: {missing}. " + "Install the workbench extra or run it with uvx, for example: " + f"`{WORKBENCH_EXTRA_INSTALL}`. In a project, use `{WORKBENCH_EXTRA_ADD}`." + ) def run_workbench(directory: Path, demo_mode: bool = False, connection: str | None = None): """Run the interactive workbench application.""" + try: + from sidemantic.workbench.app import SidequeryWorkbench + except ModuleNotFoundError as exc: + if _is_optional_workbench_dependency(exc.name): + raise WorkbenchDependencyError(_missing_dependency_message(exc.name, "workbench")) from exc + raise + workbench_app = SidequeryWorkbench(directory, demo_mode=demo_mode, connection=connection) workbench_app.run() def run_validation(directory: Path, verbose: bool = False): """Run the validation UI application.""" + try: + from sidemantic.workbench.validation_app import ValidationApp + except ModuleNotFoundError as exc: + if _is_optional_workbench_dependency(exc.name): + raise WorkbenchDependencyError(_missing_dependency_message(exc.name, "validate")) from exc + raise + app = ValidationApp(directory, verbose=verbose) app.run() diff --git a/sidemantic/workbench/app.py b/sidemantic/workbench/app.py index 333e2ef5..06c79cba 100644 --- a/sidemantic/workbench/app.py +++ b/sidemantic/workbench/app.py @@ -12,14 +12,7 @@ from textual_plotext import PlotextPlot from sidemantic import SemanticLayer, load_from_directory - -# Example queries -EXAMPLE_QUERIES = { - "Timeseries": "-- Timeseries revenue by month and region\nSELECT \n orders.created_month,\n customers.region,\n orders.total_revenue,\n orders.order_count\nFROM orders\nORDER BY created_month DESC, region", - "Top Customers": "-- Top customers by revenue\nSELECT \n customers.name,\n customers.region,\n orders.total_revenue,\n orders.order_count\nFROM orders\nORDER BY orders.total_revenue DESC\nLIMIT 10", - "Aggregates": "-- Revenue metrics by region\nSELECT \n customers.region,\n orders.total_revenue,\n orders.avg_order_value,\n orders.order_count\nFROM orders\nGROUP BY customers.region\nORDER BY orders.total_revenue DESC", - "Custom": "-- Write your custom query here\nSELECT \n \nFROM ", -} +from sidemantic.workbench.examples import EXAMPLE_QUERIES class SidequeryWorkbench(App): diff --git a/sidemantic/workbench/examples.py b/sidemantic/workbench/examples.py new file mode 100644 index 00000000..1356a665 --- /dev/null +++ b/sidemantic/workbench/examples.py @@ -0,0 +1,34 @@ +"""Example semantic SQL queries for the workbench.""" + +EXAMPLE_QUERIES = { + "Timeseries": """-- Timeseries revenue by month and region +SELECT + orders.created_month, + customers.region, + orders.total_revenue, + orders.order_count +FROM orders +ORDER BY created_month DESC, region""", + "Top Customers": """-- Top customers by revenue +SELECT + customers.name, + customers.region, + orders.total_revenue, + orders.order_count +FROM orders +ORDER BY orders.total_revenue DESC +LIMIT 10""", + "Aggregates": """-- Revenue metrics by region +SELECT + customers.region, + orders.total_revenue, + orders.avg_order_value, + orders.order_count +FROM orders +GROUP BY customers.region +ORDER BY orders.total_revenue DESC""", + "Custom": """-- Write your custom query here +SELECT + +FROM """, +} diff --git a/tests/test_cli_commands.py b/tests/test_cli_commands.py index 0e9b2efe..820416bc 100644 --- a/tests/test_cli_commands.py +++ b/tests/test_cli_commands.py @@ -49,6 +49,26 @@ def fake_run_workbench(directory, demo_mode=False, connection=None): assert called["connection"] is None +def test_workbench_missing_extra_prints_install_hint(monkeypatch, tmp_path): + from sidemantic.workbench import WorkbenchDependencyError + + def fake_run_workbench(directory, demo_mode=False, connection=None): + raise WorkbenchDependencyError( + "Missing optional dependency for `sidemantic workbench`: textual. " + "Install the workbench extra or run it with uvx, for example: " + "`uvx --from 'sidemantic[workbench]' sidemantic workbench --demo`." + ) + + monkeypatch.setattr("sidemantic.workbench.run_workbench", fake_run_workbench) + + _write_min_model(tmp_path) + result = runner.invoke(app, ["workbench", str(tmp_path)]) + + assert result.exit_code == 1 + assert "Missing optional dependency" in result.output + assert "uvx --from 'sidemantic[workbench]' sidemantic workbench --demo" in result.output + + def test_validate_calls_runner(monkeypatch, tmp_path): pytest.importorskip("textual") called = {} diff --git a/tests/test_workbench_demo.py b/tests/test_workbench_demo.py new file mode 100644 index 00000000..5251fcbd --- /dev/null +++ b/tests/test_workbench_demo.py @@ -0,0 +1,54 @@ +"""Tests for bundled workbench demo behavior.""" + +import importlib.util +import sys +from pathlib import Path + +from sidemantic import SemanticLayer, load_from_directory +from sidemantic.sql.query_rewriter import QueryRewriter +from sidemantic.workbench.examples import EXAMPLE_QUERIES + + +def _load_demo_data_module(): + demo_data_path = Path(__file__).resolve().parent.parent / "examples" / "multi_format_demo" / "demo_data.py" + spec = importlib.util.spec_from_file_location("demo_data", demo_data_path) + module = importlib.util.module_from_spec(spec) + sys.modules["demo_data"] = module + spec.loader.exec_module(module) + return module + + +def _load_demo_layer(): + demo_dir = Path(__file__).resolve().parent.parent / "examples" / "multi_format_demo" + demo_data = _load_demo_data_module() + + layer = SemanticLayer(connection="duckdb:///:memory:") + demo_conn = demo_data.create_demo_database() + + for table in ["customers", "products", "orders"]: + rows = demo_conn.execute(f"SELECT * FROM {table}").fetchall() + columns = [desc[0] for desc in demo_conn.execute(f"SELECT * FROM {table} LIMIT 0").description] + create_sql = demo_conn.execute(f"SELECT sql FROM duckdb_tables() WHERE table_name = '{table}'").fetchone()[0] + layer.adapter.execute(create_sql) + + if rows: + placeholders = ", ".join(["?" for _ in columns]) + layer.adapter.executemany(f"INSERT INTO {table} VALUES ({placeholders})", rows) + + load_from_directory(layer, str(demo_dir)) + return layer + + +def test_workbench_demo_starter_queries_execute(): + layer = _load_demo_layer() + rewriter = QueryRewriter(layer.graph, dialect=layer.dialect) + + for name, sql in EXAMPLE_QUERIES.items(): + if name == "Custom": + continue + + rendered_sql = rewriter.rewrite(sql) + result = layer.adapter.execute(rendered_sql) + + assert result.description, name + assert result.fetchall(), name