From 3e6868835444de777221064e71ec56f9ea6f7ec3 Mon Sep 17 00:00:00 2001 From: boringdata Date: Mon, 22 Jun 2026 19:18:24 +0000 Subject: [PATCH] Add Perspective artifact integration --- docs/md/doc/perspective.md | 92 +++++++ docs/md/index.json | 5 + .../claude-code/bsl-model-builder/SKILL.md | 2 + .../claude-code/bsl-query-expert/SKILL.md | 2 + docs/md/skills/codex/bsl-model-builder.codex | 2 + docs/md/skills/codex/bsl-query-expert.codex | 2 + docs/md/skills/cursor/bsl-model-builder.mdc | 2 + docs/md/skills/cursor/bsl-query-expert.mdc | 2 + pyproject.toml | 1 + .../integrations/__init__.py | 17 ++ .../integrations/perspective.py | 247 ++++++++++++++++++ .../tests/test_perspective_integration.py | 175 +++++++++++++ uv.lock | 6 +- 13 files changed, 554 insertions(+), 1 deletion(-) create mode 100644 docs/md/doc/perspective.md create mode 100644 src/boring_semantic_layer/integrations/__init__.py create mode 100644 src/boring_semantic_layer/integrations/perspective.py create mode 100644 src/boring_semantic_layer/tests/test_perspective_integration.py diff --git a/docs/md/doc/perspective.md b/docs/md/doc/perspective.md new file mode 100644 index 00000000..54a873dc --- /dev/null +++ b/docs/md/doc/perspective.md @@ -0,0 +1,92 @@ +# Perspective.js Integration + +BSL can write semantic query results as Perspective.js-compatible Arrow artifacts. This integration is intentionally narrow: BSL prepares a static backend artifact, and host applications decide how to serve the files and instantiate `@finos/perspective` or ``. + +## Why Perspective artifacts + +Perspective is useful for BI-style tables and exploratory dashboard panels: + +- interactive sorting, filtering, grouping, and pivoting +- browser-side WebAssembly execution +- Arrow-friendly client/server replication +- embeddable web component runtime + +BSL does not depend on Perspective's frontend packages. It writes a small host-generic manifest next to an Arrow file. + +## Backend/frontend model + +The intended integration boundary is expression-first and artifact-focused: + +```text +LLM or application intent + -> BSL semantic query / Ibis expression + -> backend writes Arrow + Perspective manifest + -> host serves those files + -> frontend loads Arrow into Perspective and applies viewer hints +``` + +Agents and application code should describe semantic intent in BSL terms: models, dimensions, measures, filters, and optional viewer hints. They should not generate raw Perspective runtime objects or frontend-specific table setup code. + +The backend should own: + +- compiling semantic intent into a BSL/Ibis expression +- materializing the expression as Arrow +- writing the Arrow artifact and manifest + +The frontend should own: + +- fetching the manifest and Arrow file +- loading Arrow into Perspective +- instantiating the viewer component +- applying allowed viewer hints such as plugin, columns, grouping, sorting, and filters + +## Write a Perspective artifact + +```python +result = orders.group_by("customer", "region").aggregate("revenue", "order_count") + +from boring_semantic_layer.integrations.perspective import write_perspective_artifact + +artifact = write_perspective_artifact( + result, + "./dashboard-data", + id="orders_by_customer", + viewer={"plugin": "Datagrid", "group_by": ["region"]}, +) + +print(artifact.manifest_path) +print(artifact.data_path) +``` + +This writes: + +```text +dashboard-data/ + orders_by_customer.perspective.json + orders_by_customer.arrow +``` + +The manifest uses `kind: "bsl.perspective.artifact"` and stores the external file reference in `data_ref`: + +```json +{ + "kind": "bsl.perspective.artifact", + "version": 1, + "id": "orders_by_customer", + "schema": [ + {"name": "customer", "type": "string", "role": "dimension"}, + {"name": "region", "type": "string", "role": "dimension"}, + {"name": "revenue", "type": "float", "role": "measure"} + ], + "viewer": {"plugin": "Datagrid", "group_by": ["region"]}, + "data_ref": {"format": "arrow", "path": "orders_by_customer.arrow"} +} +``` + +Arrow export requires `pyarrow`; install it with `pip install 'boring-semantic-layer[viz-perspective]'` or your project's equivalent extra installation. + +## Use with host applications + +A host application can load the manifest, fetch the referenced Arrow file, and pass the Arrow bytes into a Perspective table or viewer. Hosts should expose high-level application components instead of asking agents to generate raw Perspective options. + +BSL owns query execution and artifact writing; the host owns transport, UI rendering, and component mapping. diff --git a/docs/md/index.json b/docs/md/index.json index 49470f0d..11960a8b 100644 --- a/docs/md/index.json +++ b/docs/md/index.json @@ -75,6 +75,11 @@ "description": "ASCII charts for terminal/CLI with Plotext backend", "source": "prompts/chart/plotext.md" }, + "perspective": { + "title": "Perspective.js Integration", + "description": "Export BSL query results as Perspective-compatible datasets and artifacts", + "source": "doc/perspective.md" + }, "sessionized": { "title": "Sessionized Data", "description": "Working with session-based data and user journey analysis", diff --git a/docs/md/skills/claude-code/bsl-model-builder/SKILL.md b/docs/md/skills/claude-code/bsl-model-builder/SKILL.md index 9eae3391..f655dc8f 100644 --- a/docs/md/skills/claude-code/bsl-model-builder/SKILL.md +++ b/docs/md/skills/claude-code/bsl-model-builder/SKILL.md @@ -262,6 +262,8 @@ flights_st = flights_st.with_measures( - URL: https://github.com/boringdata/boring-semantic-layer/blob/main/docs/md/prompts/chart/plotly.md - **Terminal Charts**: ASCII charts for terminal/CLI with Plotext backend - URL: https://github.com/boringdata/boring-semantic-layer/blob/main/docs/md/prompts/chart/plotext.md +- **Perspective.js Integration**: Export BSL query results as Perspective-compatible datasets and artifacts + - URL: https://github.com/boringdata/boring-semantic-layer/blob/main/docs/md/doc/perspective.md - **Sessionized Data**: Working with session-based data and user journey analysis - URL: https://github.com/boringdata/boring-semantic-layer/blob/main/docs/md/doc/sessionized.md - **Comparison Queries**: Period-over-period comparisons and trend analysis diff --git a/docs/md/skills/claude-code/bsl-query-expert/SKILL.md b/docs/md/skills/claude-code/bsl-query-expert/SKILL.md index d9e6e989..51ba03d7 100644 --- a/docs/md/skills/claude-code/bsl-query-expert/SKILL.md +++ b/docs/md/skills/claude-code/bsl-query-expert/SKILL.md @@ -107,6 +107,8 @@ Step 2 (filter): query_model(query="model.filter(lambda t: t.region.isin(['CA' - URL: https://github.com/boringdata/boring-semantic-layer/blob/main/docs/md/prompts/chart/plotly.md - **Terminal Charts**: ASCII charts for terminal/CLI with Plotext backend - URL: https://github.com/boringdata/boring-semantic-layer/blob/main/docs/md/prompts/chart/plotext.md +- **Perspective.js Integration**: Export BSL query results as Perspective-compatible datasets and artifacts + - URL: https://github.com/boringdata/boring-semantic-layer/blob/main/docs/md/doc/perspective.md - **Sessionized Data**: Working with session-based data and user journey analysis - URL: https://github.com/boringdata/boring-semantic-layer/blob/main/docs/md/doc/sessionized.md - **Comparison Queries**: Period-over-period comparisons and trend analysis diff --git a/docs/md/skills/codex/bsl-model-builder.codex b/docs/md/skills/codex/bsl-model-builder.codex index a9eaf96f..7c7a32e1 100644 --- a/docs/md/skills/codex/bsl-model-builder.codex +++ b/docs/md/skills/codex/bsl-model-builder.codex @@ -261,6 +261,8 @@ flights_st = flights_st.with_measures( - URL: https://github.com/boringdata/boring-semantic-layer/blob/main/docs/md/prompts/chart/plotly.md - **Terminal Charts**: ASCII charts for terminal/CLI with Plotext backend - URL: https://github.com/boringdata/boring-semantic-layer/blob/main/docs/md/prompts/chart/plotext.md +- **Perspective.js Integration**: Export BSL query results as Perspective-compatible datasets and artifacts + - URL: https://github.com/boringdata/boring-semantic-layer/blob/main/docs/md/doc/perspective.md - **Sessionized Data**: Working with session-based data and user journey analysis - URL: https://github.com/boringdata/boring-semantic-layer/blob/main/docs/md/doc/sessionized.md - **Comparison Queries**: Period-over-period comparisons and trend analysis diff --git a/docs/md/skills/codex/bsl-query-expert.codex b/docs/md/skills/codex/bsl-query-expert.codex index 5ac572fd..44873592 100644 --- a/docs/md/skills/codex/bsl-query-expert.codex +++ b/docs/md/skills/codex/bsl-query-expert.codex @@ -106,6 +106,8 @@ Step 2 (filter): query_model(query="model.filter(lambda t: t.region.isin(['CA' - URL: https://github.com/boringdata/boring-semantic-layer/blob/main/docs/md/prompts/chart/plotly.md - **Terminal Charts**: ASCII charts for terminal/CLI with Plotext backend - URL: https://github.com/boringdata/boring-semantic-layer/blob/main/docs/md/prompts/chart/plotext.md +- **Perspective.js Integration**: Export BSL query results as Perspective-compatible datasets and artifacts + - URL: https://github.com/boringdata/boring-semantic-layer/blob/main/docs/md/doc/perspective.md - **Sessionized Data**: Working with session-based data and user journey analysis - URL: https://github.com/boringdata/boring-semantic-layer/blob/main/docs/md/doc/sessionized.md - **Comparison Queries**: Period-over-period comparisons and trend analysis diff --git a/docs/md/skills/cursor/bsl-model-builder.mdc b/docs/md/skills/cursor/bsl-model-builder.mdc index 05efa062..da0bf2ff 100644 --- a/docs/md/skills/cursor/bsl-model-builder.mdc +++ b/docs/md/skills/cursor/bsl-model-builder.mdc @@ -263,6 +263,8 @@ flights_st = flights_st.with_measures( - URL: https://github.com/boringdata/boring-semantic-layer/blob/main/docs/md/prompts/chart/plotly.md - **Terminal Charts**: ASCII charts for terminal/CLI with Plotext backend - URL: https://github.com/boringdata/boring-semantic-layer/blob/main/docs/md/prompts/chart/plotext.md +- **Perspective.js Integration**: Export BSL query results as Perspective-compatible datasets and artifacts + - URL: https://github.com/boringdata/boring-semantic-layer/blob/main/docs/md/doc/perspective.md - **Sessionized Data**: Working with session-based data and user journey analysis - URL: https://github.com/boringdata/boring-semantic-layer/blob/main/docs/md/doc/sessionized.md - **Comparison Queries**: Period-over-period comparisons and trend analysis diff --git a/docs/md/skills/cursor/bsl-query-expert.mdc b/docs/md/skills/cursor/bsl-query-expert.mdc index d57c5c9c..dd81af7c 100644 --- a/docs/md/skills/cursor/bsl-query-expert.mdc +++ b/docs/md/skills/cursor/bsl-query-expert.mdc @@ -108,6 +108,8 @@ Step 2 (filter): query_model(query="model.filter(lambda t: t.region.isin(['CA' - URL: https://github.com/boringdata/boring-semantic-layer/blob/main/docs/md/prompts/chart/plotly.md - **Terminal Charts**: ASCII charts for terminal/CLI with Plotext backend - URL: https://github.com/boringdata/boring-semantic-layer/blob/main/docs/md/prompts/chart/plotext.md +- **Perspective.js Integration**: Export BSL query results as Perspective-compatible datasets and artifacts + - URL: https://github.com/boringdata/boring-semantic-layer/blob/main/docs/md/doc/perspective.md - **Sessionized Data**: Working with session-based data and user journey analysis - URL: https://github.com/boringdata/boring-semantic-layer/blob/main/docs/md/doc/sessionized.md - **Comparison Queries**: Period-over-period comparisons and trend analysis diff --git a/pyproject.toml b/pyproject.toml index 5764a028..2c16631f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,6 +32,7 @@ examples = ["xorq[duckdb]>=0.3.31", "duckdb<1.4"] viz-altair = ["altair>=5.0.0", "vl-convert-python>=1.0.0"] viz-plotly = ["plotly>=6.3.0", "kaleido", "nbformat>=4.2.0"] viz-plotext = ["plotext>=5.0.0"] +viz-perspective = ["pyarrow>=14.0.0"] agent = [ "langchain>=0.3.0", diff --git a/src/boring_semantic_layer/integrations/__init__.py b/src/boring_semantic_layer/integrations/__init__.py new file mode 100644 index 00000000..c844d9d9 --- /dev/null +++ b/src/boring_semantic_layer/integrations/__init__.py @@ -0,0 +1,17 @@ +"""Optional integrations for dashboard and BI runtimes.""" + +from .perspective import ( + PerspectiveArtifact, + PerspectiveColumn, + infer_perspective_schema, + perspective_viewer_config, + write_perspective_artifact, +) + +__all__ = [ + "PerspectiveArtifact", + "PerspectiveColumn", + "infer_perspective_schema", + "perspective_viewer_config", + "write_perspective_artifact", +] diff --git a/src/boring_semantic_layer/integrations/perspective.py b/src/boring_semantic_layer/integrations/perspective.py new file mode 100644 index 00000000..1c7907ec --- /dev/null +++ b/src/boring_semantic_layer/integrations/perspective.py @@ -0,0 +1,247 @@ +"""Perspective.js Arrow artifact helpers. + +This integration intentionally has one job: write BSL/Ibis query results as a +static Arrow artifact plus a small Perspective-compatible manifest. Host +applications decide how to serve the files and render Perspective in the +frontend. +""" + +from __future__ import annotations + +import json +import math +import re +from dataclasses import dataclass +from decimal import Decimal +from pathlib import Path +from typing import Any, Literal + +PerspectiveType = Literal["string", "integer", "float", "boolean", "date", "datetime"] +ColumnRole = Literal["dimension", "measure", "time", "unknown"] + + +@dataclass(frozen=True) +class PerspectiveColumn: + """Column metadata for a Perspective-compatible artifact manifest.""" + + name: str + type: PerspectiveType + role: ColumnRole = "unknown" + + def to_dict(self) -> dict[str, str]: + return {"name": self.name, "type": self.type, "role": self.role} + + +@dataclass(frozen=True) +class PerspectiveArtifact: + """Files written for a Perspective-compatible Arrow artifact.""" + + manifest_path: Path + data_path: Path + manifest: dict[str, Any] + + +def _json_safe(value: Any) -> Any: + if isinstance(value, Decimal): + if not value.is_finite(): + raise ValueError( + f"Non-finite decimal is not valid JSON for Perspective manifest: {value!r}" + ) + return float(value) + if hasattr(value, "isoformat"): + return value.isoformat() + if isinstance(value, float): + if not math.isfinite(value): + raise ValueError( + f"Non-finite float is not valid JSON for Perspective manifest: {value!r}" + ) + return value + if value is None or isinstance(value, str | int | bool): + return value + raise TypeError(f"Value is not JSON serializable for Perspective manifest: {value!r}") + + +def _json_safe_tree(value: Any) -> Any: + if isinstance(value, dict): + return {str(key): _json_safe_tree(item) for key, item in value.items()} + if isinstance(value, list | tuple): + return [_json_safe_tree(item) for item in value] + return _json_safe(value) + + +def _import_pyarrow() -> tuple[Any, Any]: + try: + import pyarrow as pa + import pyarrow.ipc as ipc + except ImportError as exc: + raise ImportError( + "Perspective artifact export requires pyarrow. Install with " + "`pip install 'boring-semantic-layer[viz-perspective]'`." + ) from exc + return pa, ipc + + +def _perspective_type(pa: Any, arrow_type: Any) -> PerspectiveType: + if pa.types.is_boolean(arrow_type): + return "boolean" + if pa.types.is_integer(arrow_type): + return "integer" + if pa.types.is_floating(arrow_type) or pa.types.is_decimal(arrow_type): + return "float" + if pa.types.is_date(arrow_type): + return "date" + if pa.types.is_timestamp(arrow_type): + return "datetime" + return "string" + + +def _role_for_type(column_type: PerspectiveType, role: ColumnRole = "unknown") -> ColumnRole: + if role != "unknown": + return role + if column_type in {"integer", "float"}: + return "measure" + if column_type in {"date", "datetime"}: + return "time" + return "dimension" + + +def _metadata_roles(query: Any) -> dict[str, ColumnRole]: + """Best-effort semantic roles from a BSL aggregate.""" + if not hasattr(query, "op"): + return {} + try: + from boring_semantic_layer.chart.utils import ( + detect_time_dimension, + extract_aggregate_metadata, + ) + + dimensions, measures, *_ = extract_aggregate_metadata(query) + roles: dict[str, ColumnRole] = {dim: "dimension" for dim in dimensions} + roles.update({measure: "measure" for measure in measures}) + if time_dimension := detect_time_dimension(query, dimensions): + roles[time_dimension] = "time" + return roles + except Exception: + return {} + + +def _manifest_schema(arrow_schema: Any, roles: dict[str, ColumnRole]) -> list[PerspectiveColumn]: + pa, _ = _import_pyarrow() + return [ + PerspectiveColumn( + field.name, + column_type := _perspective_type(pa, field.type), + _role_for_type(column_type, roles.get(field.name, "unknown")), + ) + for field in arrow_schema + ] + + +def infer_perspective_schema(query_or_table: Any) -> list[PerspectiveColumn]: + """Return the Perspective manifest schema from an Arrow-backed query/table.""" + return _manifest_schema(_to_arrow_table(query_or_table).schema, _metadata_roles(query_or_table)) + + +def _to_arrow_table(query_or_table: Any, *, execute_kwargs: dict[str, Any] | None = None) -> Any: + pa, _ = _import_pyarrow() + execute_kwargs = execute_kwargs or {} + + if isinstance(query_or_table, pa.Table): + return query_or_table + + to_pyarrow = getattr(query_or_table, "to_pyarrow", None) + if callable(to_pyarrow): + return to_pyarrow(**execute_kwargs) + + raise TypeError( + "Expected an Arrow table or BSL/Ibis expression with to_pyarrow() " + "for Perspective artifact export; " + f"got {type(query_or_table).__name__}" + ) + + +def perspective_viewer_config( + *, + plugin: str = "Datagrid", + columns: list[str] | None = None, + group_by: list[str] | None = None, + split_by: list[str] | None = None, + sort: list[list[str]] | None = None, + filter: list[list[Any]] | None = None, + aggregates: dict[str, str] | None = None, + settings: bool | None = None, + expressions: list[str] | None = None, + **extra: Any, +) -> dict[str, Any]: + """Build optional Perspective viewer hints for the artifact manifest.""" + viewer: dict[str, Any] = {"plugin": plugin} + viewer.update( + { + key: value + for key, value in { + "columns": columns, + "group_by": group_by, + "split_by": split_by, + "sort": sort, + "filter": filter, + "aggregates": aggregates, + "settings": settings, + "expressions": expressions, + }.items() + if value is not None + } + ) + viewer.update(extra) + return viewer + + +_SAFE_ARTIFACT_ID = re.compile(r"^[A-Za-z0-9_.-]+$") + + +def _validate_artifact_id(id: str) -> None: + if not _SAFE_ARTIFACT_ID.fullmatch(id) or id in {".", ".."}: + raise ValueError( + "Perspective artifact id must be a safe file stem containing only " + "letters, numbers, '.', '_' and '-'" + ) + + +def write_perspective_artifact( + query_or_table: Any, + output_dir: str | Path, + *, + id: str, + viewer: dict[str, Any] | None = None, + execute_kwargs: dict[str, Any] | None = None, +) -> PerspectiveArtifact: + """Write ``.arrow`` plus ``.perspective.json``. + + The manifest is host-generic. It tells a frontend where the Arrow file is, + what Perspective-compatible schema to expect, and which viewer hints a host + may choose to apply. + """ + _validate_artifact_id(id) + _, ipc = _import_pyarrow() + table = _to_arrow_table(query_or_table, execute_kwargs=execute_kwargs) + roles = _metadata_roles(query_or_table) + schema = _manifest_schema(table.schema, roles) + + output_path = Path(output_dir) + output_path.mkdir(parents=True, exist_ok=True) + data_path = output_path / f"{id}.arrow" + manifest_path = output_path / f"{id}.perspective.json" + manifest = { + "kind": "bsl.perspective.artifact", + "version": 1, + "id": id, + "schema": [column.to_dict() for column in schema], + "viewer": _json_safe_tree(viewer or perspective_viewer_config()), + "data_ref": {"format": "arrow", "path": data_path.name}, + } + manifest_json = json.dumps(manifest, indent=2, allow_nan=False) + "\n" + + with data_path.open("wb") as handle, ipc.new_file(handle, table.schema) as writer: + writer.write_table(table) + manifest_path.write_text(manifest_json) + + return PerspectiveArtifact(manifest_path=manifest_path, data_path=data_path, manifest=manifest) diff --git a/src/boring_semantic_layer/tests/test_perspective_integration.py b/src/boring_semantic_layer/tests/test_perspective_integration.py new file mode 100644 index 00000000..ea2c3bd3 --- /dev/null +++ b/src/boring_semantic_layer/tests/test_perspective_integration.py @@ -0,0 +1,175 @@ +"""Tests for Perspective.js Arrow artifact helpers.""" + +import json +from decimal import Decimal + +import ibis +import pandas as pd +import pytest + +from boring_semantic_layer import to_semantic_table +from boring_semantic_layer.integrations.perspective import ( + infer_perspective_schema, + perspective_viewer_config, + write_perspective_artifact, +) + + +def test_infer_schema_from_arrow_backed_ibis_expression(): + expr = ibis.memtable( + pd.DataFrame( + { + "region": ["EU"], + "revenue": [Decimal("10.50")], + "orders": pd.Series([1], dtype="Int64"), + "day": pd.to_datetime(["2024-01-01"]), + } + ), + schema={ + "region": "string", + "revenue": "decimal(10, 2)", + "orders": "int64", + "day": "date", + }, + ) + + schema = infer_perspective_schema(expr) + + assert [column.to_dict() for column in schema] == [ + {"name": "region", "type": "string", "role": "dimension"}, + {"name": "revenue", "type": "float", "role": "measure"}, + {"name": "orders", "type": "integer", "role": "measure"}, + {"name": "day", "type": "date", "role": "time"}, + ] + + +def test_write_perspective_arrow_artifact(tmp_path): + pyarrow = pytest.importorskip("pyarrow") + ipc = pytest.importorskip("pyarrow.ipc") + expr = ibis.memtable( + pd.DataFrame({"region": ["EU"], "revenue": [Decimal("10.50")]}), + schema={"region": "string", "revenue": "decimal(10, 2)"}, + ) + + artifact = write_perspective_artifact( + expr, + tmp_path, + id="sales", + viewer=perspective_viewer_config(group_by=["region"]), + ) + + assert artifact.manifest_path.name == "sales.perspective.json" + assert artifact.data_path.name == "sales.arrow" + manifest = json.loads(artifact.manifest_path.read_text()) + assert manifest == artifact.manifest + assert manifest["kind"] == "bsl.perspective.artifact" + assert manifest["data_ref"] == {"format": "arrow", "path": "sales.arrow"} + assert manifest["viewer"] == {"plugin": "Datagrid", "group_by": ["region"]} + assert manifest["schema"] == [ + {"name": "region", "type": "string", "role": "dimension"}, + {"name": "revenue", "type": "float", "role": "measure"}, + ] + with artifact.data_path.open("rb") as handle: + arrow_schema = ipc.open_file(handle).schema + assert pyarrow.types.is_decimal(arrow_schema.field("revenue").type) + + +def test_write_perspective_artifact_normalizes_viewer_values(tmp_path): + expr = ibis.memtable( + pd.DataFrame({"region": ["EU"], "revenue": [Decimal("10.50")]}), + schema={"region": "string", "revenue": "decimal(10, 2)"}, + ) + + artifact = write_perspective_artifact( + expr, + tmp_path, + id="sales", + viewer={"plugin": "Datagrid", "filter": [["revenue", ">", Decimal("1.5")]]}, + ) + + assert artifact.manifest["viewer"] == { + "plugin": "Datagrid", + "filter": [["revenue", ">", 1.5]], + } + + +def test_write_perspective_artifact_rejects_non_finite_viewer_values(tmp_path): + expr = ibis.memtable( + pd.DataFrame({"region": ["EU"], "revenue": [10.5]}), + schema={"region": "string", "revenue": "float64"}, + ) + + with pytest.raises(ValueError, match="Non-finite float"): + write_perspective_artifact( + expr, + tmp_path, + id="sales", + viewer={"plugin": "Datagrid", "filter": [["revenue", ">", float("nan")]]}, + ) + + assert not (tmp_path / "sales.arrow").exists() + + +def test_write_perspective_artifact_rejects_non_finite_decimal_viewer_values(tmp_path): + expr = ibis.memtable( + pd.DataFrame({"region": ["EU"], "revenue": [10.5]}), + schema={"region": "string", "revenue": "float64"}, + ) + + with pytest.raises(ValueError, match="Non-finite decimal"): + write_perspective_artifact( + expr, + tmp_path, + id="sales_decimal", + viewer={"plugin": "Datagrid", "filter": [["revenue", ">", Decimal("NaN")]]}, + ) + + assert not (tmp_path / "sales_decimal.arrow").exists() + + +def test_write_perspective_artifact_uses_to_pyarrow_without_execute(tmp_path): + pyarrow = pytest.importorskip("pyarrow") + + class ArrowBackedQuery: + def to_pyarrow(self): + return pyarrow.table({"region": ["EU"], "revenue": [10.5]}) + + def execute(self): + raise AssertionError("Perspective artifact export should prefer Arrow") + + artifact = write_perspective_artifact(ArrowBackedQuery(), tmp_path, id="sales") + + assert artifact.manifest["schema"] == [ + {"name": "region", "type": "string", "role": "dimension"}, + {"name": "revenue", "type": "float", "role": "measure"}, + ] + + +def test_write_perspective_artifact_rejects_unsafe_id(tmp_path): + df = pd.DataFrame({"region": ["EU"], "revenue": [10]}) + + with pytest.raises(ValueError, match="safe file stem"): + write_perspective_artifact(df, tmp_path, id="../outside") + + +def test_semantic_query_artifact_includes_roles(tmp_path): + con = ibis.duckdb.connect(":memory:") + table = con.create_table( + "orders_perspective", + pd.DataFrame({"year": [2024, 2024, 2025], "revenue": [10, 20, 7]}), + overwrite=True, + ) + orders = ( + to_semantic_table(table, name="orders") + .with_dimensions(year=lambda t: t.year) + .with_measures(revenue=lambda t: t.revenue.sum()) + ) + result = orders.group_by("year").aggregate("revenue") + + artifact = write_perspective_artifact(result, tmp_path, id="orders_by_year") + + assert artifact.manifest["schema"] == [ + {"name": "year", "type": "integer", "role": "dimension"}, + {"name": "revenue", "type": "integer", "role": "measure"}, + ] + assert artifact.manifest["id"] == "orders_by_year" diff --git a/uv.lock b/uv.lock index dcc4aec3..19330eaf 100644 --- a/uv.lock +++ b/uv.lock @@ -214,6 +214,9 @@ viz-altair = [ { name = "altair" }, { name = "vl-convert-python" }, ] +viz-perspective = [ + { name = "pyarrow" }, +] viz-plotext = [ { name = "plotext" }, ] @@ -255,6 +258,7 @@ requires-dist = [ { name = "plotext", marker = "extra == 'viz-plotext'", specifier = ">=5.0.0" }, { name = "plotly", marker = "extra == 'viz-plotly'", specifier = ">=6.3.0" }, { name = "pre-commit", marker = "extra == 'dev'", specifier = ">=4.2.0" }, + { name = "pyarrow", marker = "extra == 'viz-perspective'", specifier = ">=14.0.0" }, { name = "pytest", marker = "extra == 'dev'" }, { name = "pytest", marker = "extra == 'test-core'" }, { name = "pytest-asyncio", marker = "extra == 'dev'" }, @@ -271,7 +275,7 @@ requires-dist = [ { name = "xorq", marker = "extra == 'xorq'", specifier = ">=0.3.31" }, { name = "xorq", extras = ["duckdb"], marker = "extra == 'examples'", specifier = ">=0.3.31" }, ] -provides-extras = ["xorq", "examples", "viz-altair", "viz-plotly", "viz-plotext", "agent", "mcp", "server", "test-core", "dev"] +provides-extras = ["xorq", "examples", "viz-altair", "viz-plotly", "viz-plotext", "viz-perspective", "agent", "mcp", "server", "test-core", "dev"] [package.metadata.requires-dev] dev = [{ name = "ipython", specifier = ">=8.38.0" }]