Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.):
Expand Down
38 changes: 27 additions & 11 deletions sidemantic/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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()
Expand All @@ -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
Expand All @@ -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)
Expand All @@ -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
Expand Down
37 changes: 35 additions & 2 deletions sidemantic/workbench/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Add the LookML extra to the demo install hint

When users follow this new hint outside the dev environment, sidemantic[workbench] installs Textual/plotting only; it does not install lkml (see pyproject.toml workbench vs lookml extras). The packaged demo includes examples/multi_format_demo/lookml/orders.lkml, and load_from_directory() parses .lkml files through LookMLAdapter, which requires lkml; without it the orders model is skipped while the starter queries in sidemantic/workbench/examples.py all reference orders.*. The demo therefore launches with the suggested command but the bundled queries fail, so the hint/README command should include the lookml extra as well, e.g. sidemantic[workbench,lookml].

Useful? React with 👍 / 👎.

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()
9 changes: 1 addition & 8 deletions sidemantic/workbench/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
34 changes: 34 additions & 0 deletions sidemantic/workbench/examples.py
Original file line number Diff line number Diff line change
@@ -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 """,
}
20 changes: 20 additions & 0 deletions tests/test_cli_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {}
Expand Down
54 changes: 54 additions & 0 deletions tests/test_workbench_demo.py
Original file line number Diff line number Diff line change
@@ -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
Loading