From 6ef9efb443778f138b995c7c4dad2134be259166 Mon Sep 17 00:00:00 2001 From: James Hobbs Date: Wed, 26 Aug 2026 15:03:52 -0500 Subject: [PATCH 1/7] feat(streamlit): add Deepnote app helpers --- README.md | 2 + deepnote_toolkit/streamlit/__init__.py | 40 +++ deepnote_toolkit/streamlit/auth.py | 189 ++++++++++ deepnote_toolkit/streamlit/client.py | 335 +++++++++++++++++ deepnote_toolkit/streamlit/document.py | 316 ++++++++++++++++ deepnote_toolkit/streamlit/widgets.py | 118 ++++++ docs/streamlit-apps.md | 77 ++++ tests/unit/test_deepnote_streamlit_auth.py | 209 +++++++++++ tests/unit/test_deepnote_streamlit_client.py | 337 ++++++++++++++++++ .../unit/test_deepnote_streamlit_document.py | 166 +++++++++ tests/unit/test_deepnote_streamlit_widgets.py | 98 +++++ 11 files changed, 1887 insertions(+) create mode 100644 deepnote_toolkit/streamlit/__init__.py create mode 100644 deepnote_toolkit/streamlit/auth.py create mode 100644 deepnote_toolkit/streamlit/client.py create mode 100644 deepnote_toolkit/streamlit/document.py create mode 100644 deepnote_toolkit/streamlit/widgets.py create mode 100644 docs/streamlit-apps.md create mode 100644 tests/unit/test_deepnote_streamlit_auth.py create mode 100644 tests/unit/test_deepnote_streamlit_client.py create mode 100644 tests/unit/test_deepnote_streamlit_document.py create mode 100644 tests/unit/test_deepnote_streamlit_widgets.py diff --git a/README.md b/README.md index 5a8cab41..b911a97b 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,8 @@ It starts and manages Jupyter, Streamlit, and LSP servers, and provides runtime - Native **Deepnote component library** including beautiful `DataFrame` rendering and interactive inputs - **Python kernel with curated set of libraries preinstalled**, allowing you to focus on work instead of fighting with Python dependencies - Run multiple **interactive applications built with Streamlit** +- Build custom Streamlit interfaces over local Deepnote files and hosted runs with + [per-viewer authentication](docs/streamlit-apps.md) - Language Server Protocol integration for code completion and intelligence - Git integration with SSH/HTTPS authentication diff --git a/deepnote_toolkit/streamlit/__init__.py b/deepnote_toolkit/streamlit/__init__.py new file mode 100644 index 00000000..87a67393 --- /dev/null +++ b/deepnote_toolkit/streamlit/__init__.py @@ -0,0 +1,40 @@ +"""Helpers for building Streamlit apps over local Deepnote files.""" + +from .auth import ( + CurrentUserApiCredentials, + CurrentUserApiTokenError, + current_user_api_credentials, + current_user_api_token, +) +from .client import DeepnoteCloudRunner, DeepnoteRunner, RunnerError, RunnerInfo +from .document import ( + DATAFRAME_MIME, + INDEX_COLUMN, + DeepnoteDataframe, + DeepnoteDocument, + InputBlock, + NotebookOutput, + RunResult, + join_text, +) +from .widgets import render_inputs + +__all__ = [ + "DATAFRAME_MIME", + "INDEX_COLUMN", + "CurrentUserApiTokenError", + "CurrentUserApiCredentials", + "DeepnoteDataframe", + "DeepnoteCloudRunner", + "DeepnoteDocument", + "DeepnoteRunner", + "InputBlock", + "NotebookOutput", + "RunResult", + "RunnerError", + "RunnerInfo", + "current_user_api_token", + "current_user_api_credentials", + "join_text", + "render_inputs", +] diff --git a/deepnote_toolkit/streamlit/auth.py b/deepnote_toolkit/streamlit/auth.py new file mode 100644 index 00000000..c5aabdcf --- /dev/null +++ b/deepnote_toolkit/streamlit/auth.py @@ -0,0 +1,189 @@ +"""Per-viewer authentication for Streamlit apps hosted by Deepnote.""" + +from __future__ import annotations + +import json +import re +from collections.abc import Callable, Mapping +from dataclasses import dataclass +from typing import Any +from urllib.error import HTTPError, URLError +from urllib.parse import urlparse +from urllib.request import Request, urlopen + +from deepnote_toolkit.config import get_config +from deepnote_toolkit.streamlit_data_apps import ( + _read_streamlit_token_from_context, +) + +OpenUrl = Callable[..., Any] +STREAMLIT_APP_HOST_PATTERN = re.compile( + r"^streamlit-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.", + re.IGNORECASE, +) + + +class CurrentUserApiTokenError(RuntimeError): + """Raised when a hosted app cannot obtain the current viewer's API token.""" + + +@dataclass(frozen=True) +class CurrentUserApiCredentials: + """A short-lived viewer-scoped public API credential.""" + + token: str + api_origin: str + expires_at_seconds: float + + +def current_user_api_token() -> str: + """Return a short-lived public API bearer for the current Streamlit viewer. + + The opaque streamlit-token cookie is exchanged for a viewer-scoped token. + It is never itself used as a public API bearer. The exchange happens on + every call so a long-lived, multi-user process does not retain credentials. + """ + + return current_user_api_credentials().token + + +def current_user_api_credentials( + *, + app_id: str | None = None, + webapp_url: str | None = None, + streamlit_token: str | None = None, + timeout: float = 10, + opener: OpenUrl = urlopen, +) -> CurrentUserApiCredentials: + """Exchange the active viewer cookie for public API credentials. + + The returned API origin must be used with the returned bearer. Hosted clients + should call this for every request, or cache it only within the current + Streamlit session until shortly before expires_at_seconds. + """ + + resolved_app_id = app_id or _read_streamlit_app_id_from_context() + if not resolved_app_id: + raise CurrentUserApiTokenError( + "Could not resolve a Deepnote Streamlit app ID from the request host." + ) + + viewer_token = streamlit_token or _read_streamlit_token_from_context() + if not viewer_token: + raise CurrentUserApiTokenError( + "Could not read the current viewer's streamlit-token cookie." + ) + + resolved_webapp_url = webapp_url or get_config().runtime.webapp_url + if not resolved_webapp_url: + raise CurrentUserApiTokenError( + "DEEPNOTE_WEBAPP_URL is required in a hosted Streamlit app." + ) + resolved_webapp_url = _validated_origin( + resolved_webapp_url, name="DEEPNOTE_WEBAPP_URL" + ) + + request = Request( + (f"{resolved_webapp_url}/api/streamlit-apps/" f"{resolved_app_id}/api-token"), + data=b"", + method="POST", + headers={ + "Accept": "application/json", + "Content-Type": "application/json", + "StreamlitToken": viewer_token, + }, + ) + try: + with opener(request, timeout=timeout) as response: + payload = json.loads(response.read()) + except HTTPError as error: + raise CurrentUserApiTokenError( + f"Current viewer API-token exchange returned HTTP {error.code}." + ) from error + except URLError as error: + raise CurrentUserApiTokenError( + "Could not reach Deepnote to exchange the current viewer's API token." + ) from error + except TimeoutError as error: + raise CurrentUserApiTokenError( + "Current viewer API-token exchange timed out." + ) from error + except (json.JSONDecodeError, UnicodeDecodeError) as error: + raise CurrentUserApiTokenError( + "Current viewer API-token exchange returned invalid JSON." + ) from error + + if not isinstance(payload, Mapping): + raise CurrentUserApiTokenError( + "Current viewer API-token exchange returned a non-object response." + ) + + token = payload.get("token") + api_origin = payload.get("apiOrigin") + expires_at_seconds = payload.get("expiresAtSeconds") + if ( + not isinstance(token, str) + or not token + or not isinstance(api_origin, str) + or not isinstance(expires_at_seconds, (int, float)) + or isinstance(expires_at_seconds, bool) + ): + raise CurrentUserApiTokenError( + "Current viewer API-token exchange response is missing required fields." + ) + + return CurrentUserApiCredentials( + token=token, + api_origin=_validated_origin(api_origin, name="apiOrigin"), + expires_at_seconds=float(expires_at_seconds), + ) + + +def _read_streamlit_app_id_from_context() -> str | None: + """Resolve the app UUID from the external Streamlit request hostname.""" + + try: + import streamlit as st # type: ignore[import-not-found] + except ImportError: + return None + + try: + headers = st.context.headers + except Exception: + return None + + if not headers: + return None + normalized_headers = {str(key).lower(): value for key, value in headers.items()} + for name in ("x-original-host", "host"): + host = normalized_headers.get(name) + if not isinstance(host, str): + continue + match = STREAMLIT_APP_HOST_PATTERN.match(host) + if match: + return match.group(1).lower() + return None + + +def _has_hosted_streamlit_context() -> bool: + """Return whether this request carries either hosted-app identity signal.""" + + return bool( + _read_streamlit_app_id_from_context() or _read_streamlit_token_from_context() + ) + + +def _validated_origin(value: str, *, name: str) -> str: + parsed = urlparse(value) + if ( + parsed.scheme not in {"http", "https"} + or not parsed.netloc + or parsed.username + or parsed.password + or parsed.path not in {"", "/"} + or parsed.params + or parsed.query + or parsed.fragment + ): + raise CurrentUserApiTokenError(f"{name} must be a valid HTTP(S) origin.") + return value.rstrip("/") diff --git a/deepnote_toolkit/streamlit/client.py b/deepnote_toolkit/streamlit/client.py new file mode 100644 index 00000000..9681a8b9 --- /dev/null +++ b/deepnote_toolkit/streamlit/client.py @@ -0,0 +1,335 @@ +"""HTTP client for the unified Deepnote app runner API.""" + +from __future__ import annotations + +import json +import os +import time +from collections.abc import Callable, Iterable, Mapping +from dataclasses import dataclass +from typing import Any +from urllib.error import HTTPError, URLError +from urllib.request import Request, urlopen + +from .auth import ( + CurrentUserApiTokenError, + _has_hosted_streamlit_context, + current_user_api_credentials, +) +from .document import InputBlock, RunResult + +OpenUrl = Callable[..., Any] +TokenProvider = Callable[[], str] +Sleep = Callable[[float], None] + +TERMINAL_RUN_STATUSES = frozenset({"success", "error", "internal_error", "stopped"}) +DEFAULT_API_ORIGIN = "https://api.deepnote.com" + + +class RunnerError(RuntimeError): + """The Deepnote runner was unavailable or rejected a request.""" + + +@dataclass(frozen=True) +class RunnerInfo: + """The target and input contract exposed by a Deepnote runner.""" + + notebook: str + inputs: tuple[InputBlock, ...] + run_target: str + + def accepts_inputs(self, inputs: Iterable[InputBlock]) -> bool: + """Return whether input variable names and block types match this runner.""" + + return _input_contract(inputs) == _input_contract(self.inputs) + + +class DeepnoteRunner: + """One client for a runner configured for Deepnote Cloud or a local kernel.""" + + def __init__( + self, + base_url: str = "http://127.0.0.1:8787", + *, + timeout: float = 600, + opener: OpenUrl = urlopen, + ): + self.base_url = base_url.rstrip("/") + self.timeout = timeout + self._open = opener + + def info(self) -> RunnerInfo: + payload = self._request("GET", "/api/info") + values = payload.get("inputs") + inputs = ( + tuple( + InputBlock.from_api(value) + for value in values + if isinstance(value, Mapping) + ) + if isinstance(values, list) + else () + ) + return RunnerInfo( + notebook=str(payload.get("notebook", "Untitled project")), + inputs=inputs, + run_target=str(payload.get("runTarget", "")), + ) + + def run(self, inputs: Mapping[str, Any]) -> RunResult: + return RunResult(self._request("POST", "/api/run", {"inputs": dict(inputs)})) + + def _request( + self, method: str, path: str, body: Mapping[str, Any] | None = None + ) -> Mapping[str, Any]: + encoded = json.dumps(body).encode() if body is not None else None + request = Request( + f"{self.base_url}{path}", + data=encoded, + method=method, + headers={"Content-Type": "application/json", "Accept": "application/json"}, + ) + try: + with self._open(request, timeout=self.timeout) as response: + payload = json.loads(response.read()) + except HTTPError as error: + detail = error.read().decode(errors="replace") + try: + parsed_detail = json.loads(detail) + message = ( + parsed_detail.get("error", detail) + if isinstance(parsed_detail, Mapping) + else detail + ) + except json.JSONDecodeError: + message = detail + raise RunnerError( + f"Deepnote runner returned HTTP {error.code}: {message}" + ) from error + except URLError as error: + raise RunnerError( + f"Could not reach Deepnote runner at {self.base_url}: {error.reason}" + ) from error + except TimeoutError as error: + raise RunnerError( + f"Deepnote runner at {self.base_url} timed out after {self.timeout:g} seconds" + ) from error + except (json.JSONDecodeError, UnicodeDecodeError) as error: + raise RunnerError( + "Deepnote runner returned an invalid JSON response" + ) from error + if not isinstance(payload, Mapping): + raise RunnerError("Deepnote runner returned a non-object response") + return payload + + +class DeepnoteCloudRunner: + """Run an existing notebook directly through the Deepnote public API. + + A token provider is called for every request, which lets long-lived Streamlit + sessions use short-lived credentials without caching them in this library. + """ + + def __init__( + self, + notebook_id: str, + *, + token: str | None = None, + token_provider: TokenProvider | None = None, + base_url: str = DEFAULT_API_ORIGIN, + timeout: float = 600, + poll_interval: float = 2, + opener: OpenUrl = urlopen, + sleep: Sleep = time.sleep, + ): + if not notebook_id: + raise ValueError("notebook_id is required") + if token is not None and token_provider is not None: + raise ValueError("Pass token or token_provider, not both") + self.notebook_id = notebook_id + self.base_url = base_url.rstrip("/") + self.timeout = timeout + self.poll_interval = poll_interval + self._static_token = token + self._token_provider = token_provider + self._open = opener + self._sleep = sleep + + def info(self) -> RunnerInfo: + payload = self._request("GET", f"/v2/notebooks/{self.notebook_id}") + notebook = payload.get("notebook") + if not isinstance(notebook, Mapping): + raise RunnerError("Deepnote API response did not include a notebook") + raw_inputs = notebook.get("inputs") + inputs = tuple( + InputBlock.from_api( + { + "variableName": value.get("name"), + "type": value.get("type"), + "value": value.get("value"), + "label": value.get("label"), + } + ) + for value in raw_inputs or [] + if isinstance(value, Mapping) and isinstance(value.get("name"), str) + ) + return RunnerInfo( + notebook=str(notebook.get("name", "Untitled notebook")), + inputs=inputs, + run_target="cloud", + ) + + def run(self, inputs: Mapping[str, Any]) -> RunResult: + started = self._run_payload( + self._request( + "POST", + "/v2/runs", + { + "notebookId": self.notebook_id, + "inputs": _normalize_cloud_inputs(inputs), + }, + ) + ) + run_id = _required_run_id(started) + deadline = time.monotonic() + self.timeout + current = started + while str(current.get("status", "")) not in TERMINAL_RUN_STATUSES: + if time.monotonic() >= deadline: + raise RunnerError( + f"Deepnote run {run_id} did not finish within {self.timeout:g} seconds" + ) + self._sleep(self.poll_interval) + current = self._run_payload( + self._request("GET", f"/v2/runs/{run_id}?snapshotDelivery=inline") + ) + + status = str(current.get("status", "")) + snapshot = current.get("snapshot") + snapshot_yaml = current.get("snapshotContent") + if snapshot_yaml is None and isinstance(snapshot, Mapping): + snapshot_yaml = snapshot.get("snapshotContent") + error = current.get("error") + if isinstance(error, Mapping): + error = error.get("message") or json.dumps(error) + return RunResult( + { + "target": "cloud", + "success": status == "success", + "runId": run_id, + "status": status, + "error": str(error) if error is not None else None, + "snapshotYaml": snapshot_yaml, + "viewUrl": current.get("viewUrl"), + } + ) + + def _request( + self, method: str, path: str, body: Mapping[str, Any] | None = None + ) -> Mapping[str, Any]: + encoded = json.dumps(body).encode() if body is not None else None + token, api_origin = self._authentication() + request = Request( + f"{api_origin}{path}", + data=encoded, + method=method, + headers={ + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + "Accept": "application/json", + }, + ) + try: + with self._open(request, timeout=min(self.timeout, 30)) as response: + payload = json.loads(response.read()) + except HTTPError as error: + detail = error.read().decode(errors="replace") + try: + parsed = json.loads(detail) + message = ( + parsed.get("message") or parsed.get("error") or detail + if isinstance(parsed, Mapping) + else detail + ) + except json.JSONDecodeError: + message = detail + raise RunnerError( + f"Deepnote API returned HTTP {error.code}: {message}" + ) from error + except URLError as error: + raise RunnerError( + f"Could not reach the Deepnote API at {api_origin}: {error.reason}" + ) from error + except TimeoutError as error: + raise RunnerError("Deepnote API request timed out") from error + except (json.JSONDecodeError, UnicodeDecodeError) as error: + raise RunnerError( + "Deepnote API returned an invalid JSON response" + ) from error + if not isinstance(payload, Mapping): + raise RunnerError("Deepnote API returned a non-object response") + return payload + + def _authentication(self) -> tuple[str, str]: + if self._token_provider is not None: + token = self._token_provider() + return self._required_token(token), self.base_url + + if self._static_token is not None: + return self._required_token(self._static_token), self.base_url + + # Hosted apps always authenticate as the current viewer. In particular, + # never fall back to a process-wide environment token when this request + # has a hosted Streamlit app hostname. + if _has_hosted_streamlit_context(): + try: + credentials = current_user_api_credentials( + timeout=min(self.timeout, 30), opener=self._open + ) + except CurrentUserApiTokenError as error: + raise RunnerError(str(error)) from error + api_origin = ( + credentials.api_origin + if self.base_url == DEFAULT_API_ORIGIN + else self.base_url + ) + return credentials.token, api_origin + + return self._required_token(os.environ.get("DEEPNOTE_TOKEN")), self.base_url + + @staticmethod + def _required_token(token: str | None) -> str: + if not token: + raise RunnerError("A Deepnote API token is required") + return token + + @staticmethod + def _run_payload(payload: Mapping[str, Any]) -> Mapping[str, Any]: + run = payload.get("run") + return run if isinstance(run, Mapping) else payload + + +def _required_run_id(run: Mapping[str, Any]) -> str: + run_id = run.get("runId") or run.get("id") + if not isinstance(run_id, str) or not run_id: + raise RunnerError("Deepnote API response did not include a run id") + return run_id + + +def _input_contract(inputs: Iterable[InputBlock]) -> tuple[tuple[str, str], ...]: + return tuple( + sorted((input_block.variable_name, input_block.type) for input_block in inputs) + ) + + +def _normalize_cloud_inputs( + inputs: Mapping[str, Any], +) -> dict[str, str | bool | list[str]]: + normalized: dict[str, str | bool | list[str]] = {} + for name, value in inputs.items(): + if isinstance(value, bool): + normalized[name] = value + elif isinstance(value, list): + normalized[name] = [str(item) for item in value] + else: + normalized[name] = str(value) + return normalized diff --git a/deepnote_toolkit/streamlit/document.py b/deepnote_toolkit/streamlit/document.py new file mode 100644 index 00000000..0829b9de --- /dev/null +++ b/deepnote_toolkit/streamlit/document.py @@ -0,0 +1,316 @@ +"""Typed, deliberately small views over `.deepnote` YAML and run responses.""" + +from __future__ import annotations + +import base64 +import binascii +from collections.abc import Mapping, Sequence +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +import yaml + +DATAFRAME_MIME = "application/vnd.deepnote.dataframe.v3+json" +INDEX_COLUMN = "_deepnote_index_column" + + +def join_text(value: Any) -> str: + """Normalize nbformat's string-or-list text values to one string.""" + + if isinstance(value, list): + return "".join(str(part) for part in value) + return "" if value is None else str(value) + + +@dataclass(frozen=True) +class InputBlock: + """The metadata a UI needs to render one Deepnote input block.""" + + variable_name: str + type: str + value: Any + label: str | None = None + options: tuple[str, ...] = () + multiple: bool = False + min: float | int | None = None + max: float | int | None = None + step: float | int | None = None + + @classmethod + def from_block(cls, block: Mapping[str, Any]) -> InputBlock | None: + block_type = str(block.get("type", "")) + metadata = block.get("metadata") + if not block_type.startswith("input-") or not isinstance(metadata, Mapping): + return None + variable_name = metadata.get("deepnote_variable_name") + if not isinstance(variable_name, str) or not variable_name: + return None + options = metadata.get("deepnote_variable_options") + return cls( + variable_name=variable_name, + type=block_type, + label=_optional_string(metadata.get("deepnote_input_label")), + value=metadata.get("deepnote_variable_value"), + options=( + tuple(str(option) for option in options) + if isinstance(options, list) + else () + ), + multiple=metadata.get("deepnote_allow_multiple_values") is True, + min=_optional_number(metadata.get("deepnote_slider_min_value")), + max=_optional_number(metadata.get("deepnote_slider_max_value")), + step=_optional_number(metadata.get("deepnote_slider_step")), + ) + + @classmethod + def from_api(cls, value: Mapping[str, Any]) -> InputBlock: + """Read the camelCase shape returned by `GET /api/info`.""" + + options = value.get("options") + return cls( + variable_name=str(value["variableName"]), + type=str(value["type"]), + label=_optional_string(value.get("label")), + value=value.get("value"), + options=( + tuple(str(option) for option in options) + if isinstance(options, list) + else () + ), + multiple=value.get("multiple") is True, + min=_optional_number(value.get("min")), + max=_optional_number(value.get("max")), + step=_optional_number(value.get("step")), + ) + + +@dataclass(frozen=True) +class DeepnoteDataframe: + """A structured Deepnote dataframe output, independent of pandas.""" + + columns: tuple[Mapping[str, Any], ...] + rows: tuple[Mapping[str, Any], ...] + raw: Mapping[str, Any] + + @classmethod + def from_value(cls, value: Any) -> DeepnoteDataframe | None: + if not isinstance(value, Mapping): + return None + columns = value.get("columns") + rows = value.get("rows") + if not isinstance(columns, list) or not isinstance(rows, list): + return None + if not all(isinstance(column, Mapping) for column in columns): + return None + if not all(isinstance(row, Mapping) for row in rows): + return None + return cls(columns=tuple(columns), rows=tuple(rows), raw=value) + + @property + def data_columns(self) -> tuple[str, ...]: + return tuple( + str(column["name"]) + for column in self.columns + if column.get("name") != INDEX_COLUMN + ) + + def records(self, *, include_index: bool = True) -> list[dict[str, Any]]: + """Return rows ready for `st.dataframe`, optionally omitting Deepnote's index column.""" + + if include_index: + return [dict(row) for row in self.rows] + return [ + {key: value for key, value in row.items() if key != INDEX_COLUMN} + for row in self.rows + ] + + +@dataclass(frozen=True) +class NotebookOutput: + """One nbformat-compatible output emitted by a Deepnote block.""" + + block_id: str + block_type: str | None + raw: Mapping[str, Any] + + @property + def output_type(self) -> str: + return str(self.raw.get("output_type", "")) + + @property + def data(self) -> Mapping[str, Any]: + value = self.raw.get("data") + return value if isinstance(value, Mapping) else {} + + def text(self, mime: str = "text/plain") -> str: + if self.output_type == "stream" and mime == "text/plain": + return join_text(self.raw.get("text")) + return join_text(self.data.get(mime)) + + def image_bytes(self, mime: str = "image/png") -> bytes | None: + value = self.data.get(mime) + if value is None: + return None + encoded = "".join(join_text(value).split()) + try: + return base64.b64decode(encoded, validate=True) + except (ValueError, binascii.Error): + return None + + @property + def dataframe(self) -> DeepnoteDataframe | None: + return DeepnoteDataframe.from_value(self.data.get(DATAFRAME_MIME)) + + +class OutputCollection: + """Shared output queries for a loaded document and a live run result.""" + + outputs: tuple[NotebookOutput, ...] + + def outputs_for_mime(self, mime: str) -> list[NotebookOutput]: + return [output for output in self.outputs if mime in output.data] + + def first_dataframe(self) -> DeepnoteDataframe | None: + for output in self.outputs: + if dataframe := output.dataframe: + return dataframe + return None + + def images(self, mime: str = "image/png") -> list[bytes]: + return [ + image + for output in self.outputs + if (image := output.image_bytes(mime)) is not None + ] + + def text(self, mime: str = "text/plain") -> str: + return "".join(output.text(mime) for output in self.outputs).strip() + + def agent_text(self) -> str: + chunks: list[str] = [] + for output in self.outputs: + if output.block_type != "agent": + continue + if output.output_type == "stream": + chunks.append(output.text()) + else: + chunks.append(output.text("text/markdown") or output.text()) + return "".join(chunks).strip() + + +class DeepnoteDocument(OutputCollection): + """A parsed source or snapshot `.deepnote` file.""" + + def __init__(self, raw: Mapping[str, Any]): + project = raw.get("project") + if not isinstance(project, Mapping) or not isinstance( + project.get("notebooks"), list + ): + raise ValueError("Expected a .deepnote document with project.notebooks") + self.raw = raw + self.project_name = str(project.get("name", "Untitled project")) + self.inputs, self.outputs = _read_blocks(project["notebooks"]) + + @classmethod + def load(cls, path: str | Path) -> DeepnoteDocument: + source = Path(path) + try: + raw = yaml.safe_load(source.read_text(encoding="utf-8")) + except yaml.YAMLError as error: + raise ValueError(f"Could not parse {source}: {error}") from error + if not isinstance(raw, Mapping): + raise ValueError(f"Expected {source} to contain a YAML object") + return cls(raw) + + @classmethod + def parse(cls, content: str) -> DeepnoteDocument: + try: + raw = yaml.safe_load(content) + except yaml.YAMLError as error: + raise ValueError(f"Could not parse .deepnote YAML: {error}") from error + if not isinstance(raw, Mapping): + raise ValueError("Expected .deepnote YAML to contain an object") + return cls(raw) + + +class RunResult(OutputCollection): + """The normalized result of `POST /api/run`, for either cloud or local execution.""" + + def __init__(self, raw: Mapping[str, Any]): + self.raw = raw + self.target = str(raw.get("target", "")) + self.success = raw.get("success") is True + self.run_id = _optional_string(raw.get("runId")) + self.status = _optional_string(raw.get("status")) + self.created = raw.get("created") is True + self.view_url = _optional_string(raw.get("viewUrl")) + self.error = _optional_string(raw.get("error")) + self.snapshot_yaml = _optional_string(raw.get("snapshotYaml")) + self.snapshot = ( + DeepnoteDocument.parse(self.snapshot_yaml) if self.snapshot_yaml else None + ) + if self.snapshot: + self.outputs = self.snapshot.outputs + else: + self.outputs = _outputs_from_run(raw.get("outputs")) + + +def _read_blocks( + notebooks: Sequence[Any], +) -> tuple[tuple[InputBlock, ...], tuple[NotebookOutput, ...]]: + inputs: list[InputBlock] = [] + outputs: list[NotebookOutput] = [] + for notebook in notebooks: + if not isinstance(notebook, Mapping): + continue + blocks = notebook.get("blocks") + if not isinstance(blocks, list): + continue + for block in blocks: + if not isinstance(block, Mapping): + continue + if input_block := InputBlock.from_block(block): + inputs.append(input_block) + block_outputs = block.get("outputs") + if not isinstance(block_outputs, list): + continue + block_id = str(block.get("id", "")) + block_type = _optional_string(block.get("type")) + outputs.extend( + NotebookOutput(block_id=block_id, block_type=block_type, raw=output) + for output in block_outputs + if isinstance(output, Mapping) + ) + return tuple(inputs), tuple(outputs) + + +def _outputs_from_run(value: Any) -> tuple[NotebookOutput, ...]: + if not isinstance(value, list): + return () + outputs: list[NotebookOutput] = [] + for block in value: + if not isinstance(block, Mapping): + continue + block_id = str(block.get("blockId", "")) + raw_outputs = block.get("outputs") + if not isinstance(raw_outputs, list): + continue + outputs.extend( + NotebookOutput(block_id=block_id, block_type=None, raw=output) + for output in raw_outputs + if isinstance(output, Mapping) + ) + return tuple(outputs) + + +def _optional_string(value: Any) -> str | None: + return value if isinstance(value, str) else None + + +def _optional_number(value: Any) -> float | int | None: + return ( + value + if isinstance(value, (float, int)) and not isinstance(value, bool) + else None + ) diff --git a/deepnote_toolkit/streamlit/widgets.py b/deepnote_toolkit/streamlit/widgets.py new file mode 100644 index 00000000..7ceb887a --- /dev/null +++ b/deepnote_toolkit/streamlit/widgets.py @@ -0,0 +1,118 @@ +"""Map Deepnote input blocks to native Streamlit widgets.""" + +from __future__ import annotations + +from collections.abc import Iterable +from datetime import date +from typing import Any + +from .document import InputBlock + + +def render_inputs( + inputs: Iterable[InputBlock], container: Any = None, *, key_prefix: str = "deepnote" +) -> dict[str, Any]: + """Render input blocks and return API-ready values keyed by variable name. + + `container` may be `st`, `st.sidebar`, or a fake with the same widget methods for tests. When it + is omitted, Streamlit is imported lazily so parsing and API clients work without the app extra. + """ + + if container is None: + import streamlit as container + + values: dict[str, Any] = {} + for input_block in inputs: + label = input_block.label or input_block.variable_name.replace("_", " ").title() + key = f"{key_prefix}:{input_block.variable_name}" + values[input_block.variable_name] = _render_one( + container, input_block, label, key + ) + return values + + +def _render_one(container: Any, input_block: InputBlock, label: str, key: str) -> Any: + if input_block.type == "input-checkbox": + return container.checkbox(label, value=_as_bool(input_block.value), key=key) + + if input_block.type == "input-select": + options = list(input_block.options) + if input_block.multiple: + defaults = input_block.value if isinstance(input_block.value, list) else [] + return container.multiselect(label, options, default=defaults, key=key) + index = ( + options.index(str(input_block.value)) + if str(input_block.value) in options + else 0 + ) + return ( + container.selectbox(label, options, index=index, key=key) if options else "" + ) + + if input_block.type == "input-slider": + minimum = input_block.min if input_block.min is not None else 0 + maximum = input_block.max if input_block.max is not None else 100 + step = input_block.step if input_block.step is not None else 1 + value = _as_number(input_block.value, minimum) + if any(isinstance(number, float) for number in (minimum, maximum, value, step)): + minimum, maximum, value, step = ( + float(number) for number in (minimum, maximum, value, step) + ) + return container.slider( + label, min_value=minimum, max_value=maximum, value=value, step=step, key=key + ) + + if input_block.type == "input-date": + return _serialize_date( + container.date_input(label, value=_as_date(input_block.value), key=key) + ) + + if input_block.type == "input-date-range": + raw = input_block.value if isinstance(input_block.value, list) else [] + defaults = tuple(_as_date(value) for value in raw[:2]) + selected = container.date_input(label, value=defaults, key=key) + if isinstance(selected, (list, tuple)): + serialized = [_serialize_date(value) for value in selected] + if len(serialized) == 2: + return serialized + if len(serialized) == 1: + return [serialized[0], serialized[0]] + fallback = [_serialize_date(value) for value in defaults] + return fallback if len(fallback) == 2 else [date.today().isoformat()] * 2 + return [_serialize_date(selected), _serialize_date(selected)] + + if input_block.type == "input-textarea": + return container.text_area(label, value=str(input_block.value or ""), key=key) + + return container.text_input(label, value=str(input_block.value or ""), key=key) + + +def _as_bool(value: Any) -> bool: + if isinstance(value, bool): + return value + return str(value).lower() in {"true", "1"} + + +def _as_number(value: Any, fallback: float | int) -> float | int: + try: + number = float(value) + return ( + number + if isinstance(fallback, float) or not number.is_integer() + else int(number) + ) + except (TypeError, ValueError): + return fallback + + +def _as_date(value: Any) -> date: + if isinstance(value, date): + return value + try: + return date.fromisoformat(str(value)) + except ValueError: + return date.today() + + +def _serialize_date(value: Any) -> str: + return value.isoformat() if hasattr(value, "isoformat") else str(value) diff --git a/docs/streamlit-apps.md b/docs/streamlit-apps.md new file mode 100644 index 00000000..f98adf74 --- /dev/null +++ b/docs/streamlit-apps.md @@ -0,0 +1,77 @@ +# Build Streamlit apps from Deepnote notebooks + +Deepnote Toolkit provides a small typed layer for custom Streamlit apps backed by +`.deepnote` source files and snapshots. + +```python +from pathlib import Path + +import streamlit as st +from deepnote_toolkit.streamlit import ( + DeepnoteCloudRunner, + DeepnoteDocument, + render_inputs, +) + +document = DeepnoteDocument.load(Path("report.deepnote")) +values = render_inputs(document.inputs, st.sidebar) + +if st.button("Run"): + result = DeepnoteCloudRunner("your-notebook-id").run(values) + st.dataframe(result.first_dataframe().records()) +``` + +`DeepnoteDocument` reads typed input definitions and structured notebook outputs. +`render_inputs` maps Deepnote input blocks to native Streamlit widgets. +`DeepnoteCloudRunner` calls the same public notebooks and runs API used by the +Deepnote CLI. `DeepnoteRunner` is available for the local-runner sidecar. + +## Authentication modes + +A hosted Deepnote Streamlit app needs no token configuration. For each API request, +the cloud runner: + +1. reads the current viewer's opaque `streamlit-token` cookie; +2. resolves the app ID from `x-original-host`, falling back to `host`; +3. exchanges the cookie at + `POST /api/streamlit-apps/{appId}/api-token`; and +4. calls the returned `apiOrigin` with the short-lived token as a bearer. + +The opaque cookie is never sent to the public API. Credentials are not cached in +process globals or Streamlit session state, and a hosted request never falls back to +a shared environment token. + +The exchange endpoint must return `token`, `apiOrigin`, and +`expiresAtSeconds`. Deployments must provide `DEEPNOTE_WEBAPP_URL` through the +Toolkit runtime configuration. + +For another public API client, use both values returned by +`current_user_api_credentials()`. `current_user_api_token()` is a token-provider +convenience for clients whose API origin is configured separately. + +For local development, pass a user's API token explicitly or set +`DEEPNOTE_TOKEN`: + +```python +runner = DeepnoteCloudRunner("your-notebook-id", token="your-api-token") +``` + +A callable `token_provider=` can supply a renewable token. It is invoked for every +request. `DeepnoteRunner` can instead call a local `@deepnote/local-runner` +sidecar at `http://127.0.0.1:8787`. + +Static apps only load a committed snapshot with `DeepnoteDocument`; they require +no token or network access. + +## Synchronize at deployment + +Runtime requests only read and run the existing cloud notebook. Synchronize source +in an explicit deployment step: + +```bash +deepnote run report.deepnote --cloud --notebook-id "$DEEPNOTE_NOTEBOOK_ID" --push --dry-run +deepnote run report.deepnote --cloud --notebook-id "$DEEPNOTE_NOTEBOOK_ID" --push --yes +``` + +Use `RunnerInfo.accepts_inputs(document.inputs)` before submitting values to +verify that the deployed notebook still has matching input names and block types. diff --git a/tests/unit/test_deepnote_streamlit_auth.py b/tests/unit/test_deepnote_streamlit_auth.py new file mode 100644 index 00000000..fc6cdd51 --- /dev/null +++ b/tests/unit/test_deepnote_streamlit_auth.py @@ -0,0 +1,209 @@ +from __future__ import annotations + +import io +import json +import sys +from types import SimpleNamespace +from typing import Any +from unittest.mock import patch +from urllib.error import HTTPError + +import pytest + +from deepnote_toolkit.streamlit import ( + CurrentUserApiTokenError, + current_user_api_credentials, + current_user_api_token, +) +from deepnote_toolkit.streamlit.auth import ( + _read_streamlit_app_id_from_context, +) + +APP_ID = "3853c7f5-2048-4b57-946d-6c5592c3317e" + + +class FakeResponse: + def __init__(self, payload: Any): + self.payload = payload + + def __enter__(self) -> "FakeResponse": + return self + + def __exit__(self, *_args: object) -> None: + return None + + def read(self) -> bytes: + return json.dumps(self.payload).encode() + + +def test_reads_app_id_from_original_host_before_host() -> None: + streamlit = SimpleNamespace( + context=SimpleNamespace( + headers={ + "Host": "streamlit-00000000-0000-0000-0000-000000000000.example", + "X-Original-Host": f"streamlit-{APP_ID}.deepnote.com", + } + ) + ) + + with patch.dict(sys.modules, {"streamlit": streamlit}): + assert _read_streamlit_app_id_from_context() == APP_ID + + +def test_reads_app_id_from_host_fallback() -> None: + streamlit = SimpleNamespace( + context=SimpleNamespace( + headers={"host": f"streamlit-{APP_ID}.deepnote.com:443"} + ) + ) + + with patch.dict(sys.modules, {"streamlit": streamlit}): + assert _read_streamlit_app_id_from_context() == APP_ID + + +@pytest.mark.parametrize( + "streamlit", + [ + SimpleNamespace(context=SimpleNamespace(headers={})), + SimpleNamespace(context=SimpleNamespace(headers={"host": "localhost:8501"})), + ], +) +def test_app_id_is_unavailable_outside_hosted_app(streamlit: object) -> None: + with patch.dict(sys.modules, {"streamlit": streamlit}): + assert _read_streamlit_app_id_from_context() is None + + +def test_exchanges_opaque_cookie_for_public_api_credentials() -> None: + captured = {} + + def open_request(request: Any, *, timeout: float) -> FakeResponse: + captured["url"] = request.full_url + captured["method"] = request.method + captured["headers"] = dict(request.header_items()) + captured["body"] = request.data + captured["timeout"] = timeout + return FakeResponse( + { + "token": "viewer-api-token", + "apiOrigin": "https://api.deepnote-staging.com/", + "expiresAtSeconds": 1_800_000_000, + } + ) + + credentials = current_user_api_credentials( + app_id=APP_ID, + webapp_url="https://deepnote-staging.com/", + streamlit_token="opaque-cookie", + timeout=7, + opener=open_request, + ) + + assert captured["url"] == ( + f"https://deepnote-staging.com/api/streamlit-apps/{APP_ID}/api-token" + ) + assert captured["method"] == "POST" + assert captured["body"] == b"" + assert captured["timeout"] == 7 + headers = {key.lower(): value for key, value in captured["headers"].items()} + assert headers["streamlittoken"] == "opaque-cookie" + assert "authorization" not in headers + assert credentials.token == "viewer-api-token" + assert credentials.api_origin == "https://api.deepnote-staging.com" + assert credentials.expires_at_seconds == 1_800_000_000 + + +def test_public_token_provider_exchanges_on_every_call() -> None: + with patch( + "deepnote_toolkit.streamlit.auth.current_user_api_credentials" + ) as exchange: + exchange.side_effect = [ + SimpleNamespace(token="first"), + SimpleNamespace(token="second"), + ] + + assert current_user_api_token() == "first" + assert current_user_api_token() == "second" + + assert exchange.call_count == 2 + + +def test_exchange_requires_hosted_streamlit_context() -> None: + with ( + patch( + "deepnote_toolkit.streamlit.auth._read_streamlit_app_id_from_context", + return_value=None, + ), + pytest.raises(CurrentUserApiTokenError, match="app ID"), + ): + current_user_api_token() + + +def test_exchange_requires_viewer_cookie() -> None: + with ( + patch( + "deepnote_toolkit.streamlit.auth._read_streamlit_app_id_from_context", + return_value=APP_ID, + ), + patch( + "deepnote_toolkit.streamlit.auth._read_streamlit_token_from_context", + return_value=None, + ), + pytest.raises(CurrentUserApiTokenError, match="streamlit-token"), + ): + current_user_api_token() + + +@pytest.mark.parametrize( + "payload", + [ + {}, + {"token": "token"}, + { + "token": "token", + "apiOrigin": "javascript:alert(1)", + "expiresAtSeconds": 123, + }, + { + "token": "token", + "apiOrigin": "https://api.deepnote.com/unexpected", + "expiresAtSeconds": 123, + }, + { + "token": "token", + "apiOrigin": "https://api.deepnote.com?secret=value", + "expiresAtSeconds": 123, + }, + ], +) +def test_exchange_rejects_invalid_response(payload: dict[str, Any]) -> None: + with pytest.raises(CurrentUserApiTokenError): + current_user_api_credentials( + app_id=APP_ID, + webapp_url="https://deepnote.com", + streamlit_token="opaque-cookie", + opener=lambda *_args, **_kwargs: FakeResponse(payload), + ) + + +def test_exchange_error_does_not_expose_response_body() -> None: + secret_response = "must-not-leak" + + def open_request(*_args: Any, **_kwargs: Any) -> FakeResponse: + raise HTTPError( + "https://deepnote.com/api/streamlit-apps/id/api-token", + 401, + "Unauthorized", + {}, + io.BytesIO(json.dumps({"error": secret_response}).encode()), + ) + + with pytest.raises(CurrentUserApiTokenError) as exc_info: + current_user_api_credentials( + app_id=APP_ID, + webapp_url="https://deepnote.com", + streamlit_token="opaque-cookie", + opener=open_request, + ) + + assert "HTTP 401" in str(exc_info.value) + assert secret_response not in str(exc_info.value) diff --git a/tests/unit/test_deepnote_streamlit_client.py b/tests/unit/test_deepnote_streamlit_client.py new file mode 100644 index 00000000..dca49f5b --- /dev/null +++ b/tests/unit/test_deepnote_streamlit_client.py @@ -0,0 +1,337 @@ +import io +import json +from typing import Any +from unittest.mock import MagicMock, patch +from urllib.error import HTTPError, URLError + +import pytest + +from deepnote_toolkit.streamlit import ( + CurrentUserApiCredentials, + CurrentUserApiTokenError, + DeepnoteCloudRunner, + DeepnoteRunner, + InputBlock, + RunnerError, + RunnerInfo, +) + + +class FakeResponse: + def __init__(self, payload: Any): + self.payload = payload + + def __enter__(self) -> "FakeResponse": + return self + + def __exit__(self, *_: object) -> None: + return None + + def read(self) -> bytes: + return json.dumps(self.payload).encode() + + +def test_info_parses_runner_contract() -> None: + calls = [] + + def open_request(request: Any, *, timeout: float) -> FakeResponse: + calls.append((request.full_url, request.method, timeout)) + return FakeResponse( + { + "notebook": "Revenue", + "runTarget": "cloud", + "inputs": [ + {"variableName": "region", "type": "input-select", "value": "All"} + ], + } + ) + + info = DeepnoteRunner("http://runner/", timeout=12, opener=open_request).info() + + assert calls == [("http://runner/api/info", "GET", 12)] + assert info.notebook == "Revenue" + assert info.run_target == "cloud" + assert info.inputs[0].variable_name == "region" + + +def test_runner_info_requires_matching_input_names_and_types() -> None: + info = RunnerInfo( + notebook="Revenue", + inputs=(InputBlock("region", "input-select", "All"),), + run_target="cloud", + ) + + assert info.accepts_inputs([InputBlock("region", "input-select", "Europe")]) + assert not info.accepts_inputs([InputBlock("market", "input-select", "Europe")]) + assert not info.accepts_inputs([InputBlock("region", "input-text", "Europe")]) + + +def test_run_posts_inputs_and_parses_one_result_shape() -> None: + def open_request(request: Any, *, timeout: float) -> FakeResponse: + assert timeout == 600 + assert request.method == "POST" + assert json.loads(request.data) == {"inputs": {"limit": 20}} + return FakeResponse({"target": "local", "success": True, "outputs": []}) + + result = DeepnoteRunner(opener=open_request).run({"limit": 20}) + + assert result.target == "local" + assert result.success is True + + +def test_http_error_surfaces_runner_message() -> None: + def open_request(*_: Any, **__: Any) -> FakeResponse: + raise HTTPError( + "http://runner/api/run", + 500, + "Server error", + {}, + io.BytesIO(b'{"error":"DEEPNOTE_TOKEN is required"}'), + ) + + with pytest.raises(RunnerError, match="DEEPNOTE_TOKEN is required"): + DeepnoteRunner("http://runner", opener=open_request).run({}) + + +def test_connection_error_names_runner_url() -> None: + def open_request(*_: Any, **__: Any) -> FakeResponse: + raise URLError("connection refused") + + with pytest.raises(RunnerError, match="http://runner"): + DeepnoteRunner("http://runner", opener=open_request).info() + + +def test_timeout_names_runner_url_and_duration() -> None: + def open_request(*_: Any, **__: Any) -> FakeResponse: + raise TimeoutError + + with pytest.raises(RunnerError, match="http://runner.*12 seconds"): + DeepnoteRunner("http://runner", timeout=12, opener=open_request).info() + + +def test_cloud_info_reads_public_notebook_contract() -> None: + def open_request(request: Any, *, timeout: float) -> FakeResponse: + assert request.full_url == "https://api.deepnote.com/v2/notebooks/notebook-1" + assert request.headers["Authorization"] == "Bearer token-1" + assert timeout == 30 + return FakeResponse( + { + "notebook": { + "name": "Revenue", + "inputs": [ + { + "name": "region", + "type": "input-select", + "value": "All", + "label": "Region", + } + ], + } + } + ) + + info = DeepnoteCloudRunner( + "notebook-1", token="token-1", opener=open_request + ).info() + + assert info.notebook == "Revenue" + assert info.run_target == "cloud" + assert info.inputs[0].variable_name == "region" + + +def test_cloud_run_posts_inputs_polls_and_parses_inline_snapshot() -> None: + calls = [] + responses = iter( + [ + {"run": {"runId": "run-1", "status": "pending"}}, + {"run": {"runId": "run-1", "status": "running"}}, + { + "run": { + "runId": "run-1", + "status": "success", + "snapshot": { + "snapshotContent": "project:\n name: Result\n notebooks:\n - blocks: []\n" + }, + } + }, + ] + ) + + def open_request(request: Any, *, timeout: float) -> FakeResponse: + calls.append( + ( + request.full_url, + request.method, + request.headers["Authorization"], + request.data, + timeout, + ) + ) + return FakeResponse(next(responses)) + + tokens = iter(["token-1", "token-2", "token-3"]) + sleeps = [] + result = DeepnoteCloudRunner( + "notebook-1", + token_provider=lambda: next(tokens), + opener=open_request, + sleep=sleeps.append, + poll_interval=0.25, + ).run({"limit": 20, "enabled": True, "regions": ["EU"]}) + + assert json.loads(calls[0][3]) == { + "notebookId": "notebook-1", + "inputs": {"limit": "20", "enabled": True, "regions": ["EU"]}, + } + assert calls[1][0].endswith("/v2/runs/run-1?snapshotDelivery=inline") + assert [call[2] for call in calls] == [ + "Bearer token-1", + "Bearer token-2", + "Bearer token-3", + ] + assert sleeps == [0.25, 0.25] + assert result.success is True + assert result.snapshot is not None + assert result.snapshot.project_name == "Result" + + +def test_cloud_run_surfaces_terminal_error() -> None: + def open_request(_request: Any, *, timeout: float) -> FakeResponse: + assert timeout == 30 + return FakeResponse( + { + "run": { + "id": "run-1", + "status": "error", + "error": {"message": "bad input"}, + } + } + ) + + result = DeepnoteCloudRunner("notebook-1", token="token", opener=open_request).run( + {} + ) + + assert result.success is False + assert result.error == "bad input" + + +def test_hosted_cloud_runner_exchanges_per_request_and_uses_api_origin( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("DEEPNOTE_TOKEN", "must-not-be-used") + api_calls = [] + responses = iter( + [ + {"run": {"runId": "run-1", "status": "pending"}}, + {"run": {"runId": "run-1", "status": "success"}}, + ] + ) + + def open_request(request: Any, *, timeout: float) -> FakeResponse: + api_calls.append( + ( + request.full_url, + request.headers["Authorization"], + timeout, + ) + ) + return FakeResponse(next(responses)) + + credentials = [ + CurrentUserApiCredentials( + token="viewer-token-1", + api_origin="https://api.deepnote-staging.com", + expires_at_seconds=1_800_000_000, + ), + CurrentUserApiCredentials( + token="viewer-token-2", + api_origin="https://api.deepnote-staging.com", + expires_at_seconds=1_800_000_001, + ), + ] + with ( + patch( + "deepnote_toolkit.streamlit.client._has_hosted_streamlit_context", + return_value=True, + ), + patch( + "deepnote_toolkit.streamlit.client.current_user_api_credentials", + side_effect=credentials, + ) as exchange, + ): + result = DeepnoteCloudRunner( + "notebook-1", + opener=open_request, + sleep=lambda _delay: None, + ).run({}) + + assert result.success is True + assert exchange.call_count == 2 + assert api_calls == [ + ( + "https://api.deepnote-staging.com/v2/runs", + "Bearer viewer-token-1", + 30, + ), + ( + "https://api.deepnote-staging.com/v2/runs/run-1" "?snapshotDelivery=inline", + "Bearer viewer-token-2", + 30, + ), + ] + + +def test_hosted_runner_never_falls_back_to_environment_token( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("DEEPNOTE_TOKEN", "shared-token") + opener = MagicMock() + with ( + patch( + "deepnote_toolkit.streamlit.client._has_hosted_streamlit_context", + return_value=True, + ), + patch( + "deepnote_toolkit.streamlit.client.current_user_api_credentials", + side_effect=CurrentUserApiTokenError("viewer token unavailable"), + ), + pytest.raises(RunnerError, match="viewer token unavailable"), + ): + DeepnoteCloudRunner("notebook-1", opener=opener).info() + + opener.assert_not_called() + + +def test_local_cloud_runner_uses_environment_token( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("DEEPNOTE_TOKEN", "local-token") + + def open_request(request: Any, *, timeout: float) -> FakeResponse: + assert request.headers["Authorization"] == "Bearer local-token" + assert timeout == 30 + return FakeResponse({"notebook": {"name": "Revenue", "inputs": []}}) + + with patch( + "deepnote_toolkit.streamlit.client._has_hosted_streamlit_context", + return_value=False, + ): + info = DeepnoteCloudRunner("notebook-1", opener=open_request).info() + + assert info.notebook == "Revenue" + + +def test_cloud_runner_requires_one_token_source( + monkeypatch: pytest.MonkeyPatch, +) -> None: + with pytest.raises(ValueError, match="not both"): + DeepnoteCloudRunner("notebook-1", token="token", token_provider=lambda: "other") + + monkeypatch.delenv("DEEPNOTE_TOKEN", raising=False) + with pytest.raises(RunnerError, match="token is required"): + DeepnoteCloudRunner( + "notebook-1", + token="", + opener=lambda *_args, **_kwargs: FakeResponse({}), + ).info() diff --git a/tests/unit/test_deepnote_streamlit_document.py b/tests/unit/test_deepnote_streamlit_document.py new file mode 100644 index 00000000..191b0e3f --- /dev/null +++ b/tests/unit/test_deepnote_streamlit_document.py @@ -0,0 +1,166 @@ +from pathlib import Path + +import pytest + +from deepnote_toolkit.streamlit import ( + DATAFRAME_MIME, + DeepnoteDocument, + InputBlock, + RunResult, + join_text, +) + +SNAPSHOT_YAML = """ +project: + name: Sales performance + notebooks: + - blocks: + - id: region-input + type: input-select + metadata: + deepnote_variable_name: region + deepnote_input_label: Region + deepnote_variable_value: Europe + deepnote_variable_options: [All, Europe] + - id: table + type: code + outputs: + - output_type: execute_result + data: + application/vnd.deepnote.dataframe.v3+json: + columns: + - name: _deepnote_index_column + - name: Revenue + rows: + - _deepnote_index_column: Europe + Revenue: 42 + - id: agent + type: agent + outputs: + - output_type: display_data + data: + text/markdown: "**Done**" +""" + + +def test_loads_inputs_and_structured_outputs(tmp_path: Path) -> None: + path = tmp_path / "sales.snapshot.deepnote" + path.write_text(SNAPSHOT_YAML, encoding="utf-8") + + snapshot = DeepnoteDocument.load(path) + + assert snapshot.project_name == "Sales performance" + assert snapshot.inputs == ( + InputBlock( + "region", + "input-select", + "Europe", + label="Region", + options=("All", "Europe"), + ), + ) + dataframe = snapshot.first_dataframe() + assert dataframe is not None + assert dataframe.data_columns == ("Revenue",) + assert dataframe.records(include_index=False) == [{"Revenue": 42}] + assert snapshot.agent_text() == "**Done**" + + +def test_reads_input_metadata_from_file_and_api_shapes() -> None: + file_input = InputBlock.from_block( + { + "type": "input-slider", + "metadata": { + "deepnote_variable_name": "limit", + "deepnote_input_label": "Row limit", + "deepnote_variable_value": "20", + "deepnote_slider_min_value": 10, + "deepnote_slider_max_value": 100, + "deepnote_slider_step": 10, + }, + } + ) + api_input = InputBlock.from_api( + { + "variableName": "countries", + "type": "input-select", + "label": "Countries", + "value": ["Panama"], + "options": ["Panama", "Colombia"], + "multiple": True, + } + ) + + assert file_input == InputBlock( + variable_name="limit", + type="input-slider", + label="Row limit", + value="20", + min=10, + max=100, + step=10, + ) + assert api_input.options == ("Panama", "Colombia") + assert api_input.multiple is True + + +def test_run_result_prefers_snapshot_outputs_and_preserves_cloud_fields() -> None: + result = RunResult( + { + "target": "cloud", + "success": True, + "runId": "run-1", + "status": "success", + "viewUrl": "https://deepnote.com/project/example", + "snapshotYaml": SNAPSHOT_YAML, + "outputs": [], + } + ) + + assert result.success is True + assert result.target == "cloud" + assert result.run_id == "run-1" + assert result.agent_text() == "**Done**" + + +def test_run_result_falls_back_to_inline_outputs_without_snapshot() -> None: + result = RunResult( + { + "target": "local", + "success": True, + "outputs": [ + { + "blockId": "code-1", + "outputs": [ + { + "output_type": "execute_result", + "data": { + DATAFRAME_MIME: { + "columns": [{"name": "value"}], + "rows": [{"value": 42}], + } + }, + } + ], + } + ], + } + ) + + dataframe = result.first_dataframe() + assert dataframe is not None + assert dataframe.records() == [{"value": 42}] + + +@pytest.mark.parametrize( + ("value", "expected"), + [(["hello", " ", "world"], "hello world"), ("hello", "hello"), (None, "")], +) +def test_join_text(value: object, expected: str) -> None: + assert join_text(value) == expected + + +@pytest.mark.parametrize("content", ["hello: world", "[]", ""]) +def test_rejects_non_deepnote_yaml(content: str) -> None: + with pytest.raises(ValueError): + DeepnoteDocument.parse(content) diff --git a/tests/unit/test_deepnote_streamlit_widgets.py b/tests/unit/test_deepnote_streamlit_widgets.py new file mode 100644 index 00000000..1b3f105f --- /dev/null +++ b/tests/unit/test_deepnote_streamlit_widgets.py @@ -0,0 +1,98 @@ +from datetime import date +from typing import Any + +from deepnote_toolkit.streamlit import InputBlock, render_inputs + + +class FakeContainer: + def checkbox(self, _label: str, **kwargs: Any) -> Any: + return kwargs["value"] + + def multiselect(self, _label: str, _options: list[str], **kwargs: Any) -> Any: + return kwargs["default"] + + def selectbox(self, _label: str, options: list[str], **kwargs: Any) -> Any: + return options[kwargs["index"]] + + def slider(self, _label: str, **kwargs: Any) -> Any: + return kwargs["value"] + + def date_input(self, _label: str, **kwargs: Any) -> Any: + return kwargs["value"] + + def text_area(self, _label: str, **kwargs: Any) -> Any: + return kwargs["value"] + + def text_input(self, _label: str, **kwargs: Any) -> Any: + return kwargs["value"] + + +def test_render_inputs_maps_all_deepnote_input_types_to_api_values() -> None: + inputs = [ + InputBlock("name", "input-text", "Ada"), + InputBlock("notes", "input-textarea", "Hello"), + InputBlock("enabled", "input-checkbox", True), + InputBlock("region", "input-select", "Europe", options=("All", "Europe")), + InputBlock( + "regions", + "input-select", + ["Europe"], + options=("All", "Europe"), + multiple=True, + ), + InputBlock("limit", "input-slider", "20", min=10, max=100, step=10), + InputBlock("as_of", "input-date", date(2026, 8, 17)), + InputBlock("period", "input-date-range", [date(2026, 8, 1), date(2026, 8, 17)]), + ] + + assert render_inputs(inputs, FakeContainer()) == { + "name": "Ada", + "notes": "Hello", + "enabled": True, + "region": "Europe", + "regions": ["Europe"], + "limit": 20, + "as_of": "2026-08-17", + "period": ["2026-08-01", "2026-08-17"], + } + + +def test_incomplete_date_range_is_still_valid_for_runner_contract() -> None: + class IncompleteDateContainer(FakeContainer): + def date_input(self, _label: str, **_kwargs: Any) -> Any: + return (date(2026, 8, 17),) + + values = render_inputs( + [ + InputBlock( + "period", "input-date-range", [date(2026, 8, 1), date(2026, 8, 17)] + ) + ], + IncompleteDateContainer(), + ) + + assert values == {"period": ["2026-08-17", "2026-08-17"]} + + +def test_slider_preserves_fractional_default_with_integer_bounds() -> None: + class SliderContainer(FakeContainer): + slider_kwargs: dict[str, Any] + + def slider(self, _label: str, **kwargs: Any) -> Any: + self.slider_kwargs = kwargs + return kwargs["value"] + + container = SliderContainer() + values = render_inputs( + [InputBlock("threshold", "input-slider", "20.5", min=10, max=30, step=0.5)], + container, + ) + + assert values == {"threshold": 20.5} + assert container.slider_kwargs == { + "min_value": 10.0, + "max_value": 30.0, + "value": 20.5, + "step": 0.5, + "key": "deepnote:threshold", + } From c0015ecd32ae50a67764421b846a2a2a60bd5cca Mon Sep 17 00:00:00 2001 From: James Hobbs Date: Wed, 26 Aug 2026 15:10:29 -0500 Subject: [PATCH 2/7] fix(streamlit): type lazy optional import --- deepnote_toolkit/streamlit/widgets.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/deepnote_toolkit/streamlit/widgets.py b/deepnote_toolkit/streamlit/widgets.py index 7ceb887a..c9c44217 100644 --- a/deepnote_toolkit/streamlit/widgets.py +++ b/deepnote_toolkit/streamlit/widgets.py @@ -19,7 +19,9 @@ def render_inputs( """ if container is None: - import streamlit as container + import streamlit as st # type: ignore[import-not-found] + + container = st values: dict[str, Any] = {} for input_block in inputs: From c07d5c22b567198cfeba8852fff08dd16b1b6451 Mon Sep 17 00:00:00 2001 From: James Hobbs Date: Wed, 26 Aug 2026 15:15:58 -0500 Subject: [PATCH 3/7] fix(streamlit): tolerate malformed output metadata --- deepnote_toolkit/streamlit/document.py | 13 +++--- .../unit/test_deepnote_streamlit_document.py | 45 +++++++++++++++++++ 2 files changed, 53 insertions(+), 5 deletions(-) diff --git a/deepnote_toolkit/streamlit/document.py b/deepnote_toolkit/streamlit/document.py index 0829b9de..af6225d7 100644 --- a/deepnote_toolkit/streamlit/document.py +++ b/deepnote_toolkit/streamlit/document.py @@ -110,9 +110,9 @@ def from_value(cls, value: Any) -> DeepnoteDataframe | None: @property def data_columns(self) -> tuple[str, ...]: return tuple( - str(column["name"]) + str(column.get("name")) for column in self.columns - if column.get("name") != INDEX_COLUMN + if column.get("name") not in (None, INDEX_COLUMN) ) def records(self, *, include_index: bool = True) -> list[dict[str, Any]]: @@ -247,9 +247,12 @@ def __init__(self, raw: Mapping[str, Any]): self.view_url = _optional_string(raw.get("viewUrl")) self.error = _optional_string(raw.get("error")) self.snapshot_yaml = _optional_string(raw.get("snapshotYaml")) - self.snapshot = ( - DeepnoteDocument.parse(self.snapshot_yaml) if self.snapshot_yaml else None - ) + self.snapshot = None + if self.snapshot_yaml: + try: + self.snapshot = DeepnoteDocument.parse(self.snapshot_yaml) + except ValueError: + pass if self.snapshot: self.outputs = self.snapshot.outputs else: diff --git a/tests/unit/test_deepnote_streamlit_document.py b/tests/unit/test_deepnote_streamlit_document.py index 191b0e3f..2511b548 100644 --- a/tests/unit/test_deepnote_streamlit_document.py +++ b/tests/unit/test_deepnote_streamlit_document.py @@ -66,6 +66,27 @@ def test_loads_inputs_and_structured_outputs(tmp_path: Path) -> None: assert snapshot.agent_text() == "**Done**" +def test_dataframe_ignores_columns_without_names() -> None: + dataframe = DeepnoteDocument.parse( + """ +project: + notebooks: + - blocks: + - id: table + type: code + outputs: + - output_type: execute_result + data: + application/vnd.deepnote.dataframe.v3+json: + columns: [{}, {name: value}] + rows: [{value: 42}] +""" + ).first_dataframe() + + assert dataframe is not None + assert dataframe.data_columns == ("value",) + + def test_reads_input_metadata_from_file_and_api_shapes() -> None: file_input = InputBlock.from_block( { @@ -152,6 +173,30 @@ def test_run_result_falls_back_to_inline_outputs_without_snapshot() -> None: assert dataframe.records() == [{"value": 42}] +def test_run_result_falls_back_to_inline_outputs_for_malformed_snapshot() -> None: + result = RunResult( + { + "target": "cloud", + "success": True, + "snapshotYaml": "not: a deepnote snapshot", + "outputs": [ + { + "blockId": "code-1", + "outputs": [ + { + "output_type": "stream", + "text": "fallback output", + } + ], + } + ], + } + ) + + assert result.snapshot is None + assert result.text() == "fallback output" + + @pytest.mark.parametrize( ("value", "expected"), [(["hello", " ", "world"], "hello world"), ("hello", "hello"), (None, "")], From 3988b05d847585568831d6dadd8ebd961ecc4ec6 Mon Sep 17 00:00:00 2001 From: James Hobbs Date: Wed, 26 Aug 2026 15:15:58 -0500 Subject: [PATCH 4/7] fix(streamlit): sanitize multiselect defaults --- deepnote_toolkit/streamlit/widgets.py | 9 ++++++++- tests/unit/test_deepnote_streamlit_widgets.py | 17 +++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/deepnote_toolkit/streamlit/widgets.py b/deepnote_toolkit/streamlit/widgets.py index c9c44217..8f9f04d8 100644 --- a/deepnote_toolkit/streamlit/widgets.py +++ b/deepnote_toolkit/streamlit/widgets.py @@ -40,7 +40,14 @@ def _render_one(container: Any, input_block: InputBlock, label: str, key: str) - if input_block.type == "input-select": options = list(input_block.options) if input_block.multiple: - defaults = input_block.value if isinstance(input_block.value, list) else [] + raw_defaults = ( + input_block.value if isinstance(input_block.value, list) else [] + ) + defaults = [ + normalized + for value in raw_defaults + if (normalized := str(value)) in options + ] return container.multiselect(label, options, default=defaults, key=key) index = ( options.index(str(input_block.value)) diff --git a/tests/unit/test_deepnote_streamlit_widgets.py b/tests/unit/test_deepnote_streamlit_widgets.py index 1b3f105f..4699efd6 100644 --- a/tests/unit/test_deepnote_streamlit_widgets.py +++ b/tests/unit/test_deepnote_streamlit_widgets.py @@ -96,3 +96,20 @@ def slider(self, _label: str, **kwargs: Any) -> Any: "step": 0.5, "key": "deepnote:threshold", } + + +def test_multiselect_normalizes_and_filters_stale_defaults() -> None: + values = render_inputs( + [ + InputBlock( + "regions", + "input-select", + [1, "Europe", "Missing"], + options=("1", "Europe"), + multiple=True, + ) + ], + FakeContainer(), + ) + + assert values == {"regions": ["1", "Europe"]} From 667cefe634f3044f7aafd161b448824cf4c5f992 Mon Sep 17 00:00:00 2001 From: James Hobbs Date: Wed, 26 Aug 2026 15:15:59 -0500 Subject: [PATCH 5/7] refactor(streamlit): expose viewer token reader --- deepnote_toolkit/streamlit/auth.py | 6 +++--- deepnote_toolkit/streamlit_data_apps.py | 8 +++++++- tests/unit/test_deepnote_streamlit_auth.py | 2 +- 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/deepnote_toolkit/streamlit/auth.py b/deepnote_toolkit/streamlit/auth.py index c5aabdcf..36e2ed5d 100644 --- a/deepnote_toolkit/streamlit/auth.py +++ b/deepnote_toolkit/streamlit/auth.py @@ -13,7 +13,7 @@ from deepnote_toolkit.config import get_config from deepnote_toolkit.streamlit_data_apps import ( - _read_streamlit_token_from_context, + read_streamlit_token_from_context, ) OpenUrl = Callable[..., Any] @@ -68,7 +68,7 @@ def current_user_api_credentials( "Could not resolve a Deepnote Streamlit app ID from the request host." ) - viewer_token = streamlit_token or _read_streamlit_token_from_context() + viewer_token = streamlit_token or read_streamlit_token_from_context() if not viewer_token: raise CurrentUserApiTokenError( "Could not read the current viewer's streamlit-token cookie." @@ -169,7 +169,7 @@ def _has_hosted_streamlit_context() -> bool: """Return whether this request carries either hosted-app identity signal.""" return bool( - _read_streamlit_app_id_from_context() or _read_streamlit_token_from_context() + _read_streamlit_app_id_from_context() or read_streamlit_token_from_context() ) diff --git a/deepnote_toolkit/streamlit_data_apps.py b/deepnote_toolkit/streamlit_data_apps.py index 99fadeb3..fb87b6d4 100644 --- a/deepnote_toolkit/streamlit_data_apps.py +++ b/deepnote_toolkit/streamlit_data_apps.py @@ -61,7 +61,7 @@ def __init__( self.integration_name = integration_name -def _read_streamlit_token_from_context() -> Optional[str]: +def read_streamlit_token_from_context() -> Optional[str]: """Read the ``streamlit-token`` cookie from the active Streamlit context. Returns ``None`` if Streamlit is not installed, no script run is active, or the cookie @@ -87,6 +87,12 @@ def _read_streamlit_token_from_context() -> Optional[str]: return token +def _read_streamlit_token_from_context() -> Optional[str]: + """Backward-compatible private alias for the public cookie helper.""" + + return read_streamlit_token_from_context() + + def get_federated_auth_token( integration_id: str, *, diff --git a/tests/unit/test_deepnote_streamlit_auth.py b/tests/unit/test_deepnote_streamlit_auth.py index fc6cdd51..bd12caed 100644 --- a/tests/unit/test_deepnote_streamlit_auth.py +++ b/tests/unit/test_deepnote_streamlit_auth.py @@ -145,7 +145,7 @@ def test_exchange_requires_viewer_cookie() -> None: return_value=APP_ID, ), patch( - "deepnote_toolkit.streamlit.auth._read_streamlit_token_from_context", + "deepnote_toolkit.streamlit.auth.read_streamlit_token_from_context", return_value=None, ), pytest.raises(CurrentUserApiTokenError, match="streamlit-token"), From 554768050fce5878c30fe09224cbe7396e9acb12 Mon Sep 17 00:00:00 2001 From: James Hobbs Date: Wed, 26 Aug 2026 15:21:36 -0500 Subject: [PATCH 6/7] style(streamlit): apply pinned Black formatting --- tests/unit/test_deepnote_streamlit_document.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/tests/unit/test_deepnote_streamlit_document.py b/tests/unit/test_deepnote_streamlit_document.py index 2511b548..895c0be9 100644 --- a/tests/unit/test_deepnote_streamlit_document.py +++ b/tests/unit/test_deepnote_streamlit_document.py @@ -67,8 +67,7 @@ def test_loads_inputs_and_structured_outputs(tmp_path: Path) -> None: def test_dataframe_ignores_columns_without_names() -> None: - dataframe = DeepnoteDocument.parse( - """ + dataframe = DeepnoteDocument.parse(""" project: notebooks: - blocks: @@ -80,8 +79,7 @@ def test_dataframe_ignores_columns_without_names() -> None: application/vnd.deepnote.dataframe.v3+json: columns: [{}, {name: value}] rows: [{value: 42}] -""" - ).first_dataframe() +""").first_dataframe() assert dataframe is not None assert dataframe.data_columns == ("value",) From ff164ba14c84bcf558c5a3444e93fbaa4b803d2d Mon Sep 17 00:00:00 2001 From: James Hobbs Date: Wed, 26 Aug 2026 15:55:05 -0500 Subject: [PATCH 7/7] fix(streamlit): consume sanitized run outputs --- deepnote_toolkit/streamlit/client.py | 2 + deepnote_toolkit/streamlit/document.py | 31 ++++++++- docs/streamlit-apps.md | 6 ++ tests/unit/test_deepnote_streamlit_client.py | 70 ++++++++++++++++++++ 4 files changed, 108 insertions(+), 1 deletion(-) diff --git a/deepnote_toolkit/streamlit/client.py b/deepnote_toolkit/streamlit/client.py index 9681a8b9..7e4db3c2 100644 --- a/deepnote_toolkit/streamlit/client.py +++ b/deepnote_toolkit/streamlit/client.py @@ -186,6 +186,7 @@ def run(self, inputs: Mapping[str, Any]) -> RunResult: "/v2/runs", { "notebookId": self.notebook_id, + "detached": True, "inputs": _normalize_cloud_inputs(inputs), }, ) @@ -219,6 +220,7 @@ def run(self, inputs: Mapping[str, Any]) -> RunResult: "status": status, "error": str(error) if error is not None else None, "snapshotYaml": snapshot_yaml, + "snapshotBlocks": current.get("snapshotBlocks"), "viewUrl": current.get("viewUrl"), } ) diff --git a/deepnote_toolkit/streamlit/document.py b/deepnote_toolkit/streamlit/document.py index af6225d7..3c0b9c59 100644 --- a/deepnote_toolkit/streamlit/document.py +++ b/deepnote_toolkit/streamlit/document.py @@ -256,7 +256,12 @@ def __init__(self, raw: Mapping[str, Any]): if self.snapshot: self.outputs = self.snapshot.outputs else: - self.outputs = _outputs_from_run(raw.get("outputs")) + snapshot_blocks = raw.get("snapshotBlocks") + self.outputs = ( + _outputs_from_snapshot_blocks(snapshot_blocks) + if isinstance(snapshot_blocks, list) + else _outputs_from_run(raw.get("outputs")) + ) def _read_blocks( @@ -307,6 +312,30 @@ def _outputs_from_run(value: Any) -> tuple[NotebookOutput, ...]: return tuple(outputs) +def _outputs_from_snapshot_blocks(value: Any) -> tuple[NotebookOutput, ...]: + if not isinstance(value, list): + return () + outputs: list[NotebookOutput] = [] + for block in value: + if not isinstance(block, Mapping): + continue + block_id = str(block.get("id", "")) + block_type = _optional_string(block.get("type")) + raw_outputs = block.get("outputs") + if not isinstance(raw_outputs, list): + continue + outputs.extend( + NotebookOutput( + block_id=block_id, + block_type=block_type, + raw=output, + ) + for output in raw_outputs + if isinstance(output, Mapping) + ) + return tuple(outputs) + + def _optional_string(value: Any) -> str | None: return value if isinstance(value, str) else None diff --git a/docs/streamlit-apps.md b/docs/streamlit-apps.md index f98adf74..56f6ddcd 100644 --- a/docs/streamlit-apps.md +++ b/docs/streamlit-apps.md @@ -37,6 +37,12 @@ the cloud runner: `POST /api/streamlit-apps/{appId}/api-token`; and 4. calls the returned `apiOrigin` with the short-lived token as a bearer. +Cloud runs explicitly request `detached: true`, keeping viewer-triggered work out +of the shared project session. Hosted app tokens receive sanitized +`snapshotBlocks` containing the executed notebook's outputs, not the raw +project snapshot. API-key clients remain compatible with inline +`snapshotContent` responses. + The opaque cookie is never sent to the public API. Credentials are not cached in process globals or Streamlit session state, and a hosted request never falls back to a shared environment token. diff --git a/tests/unit/test_deepnote_streamlit_client.py b/tests/unit/test_deepnote_streamlit_client.py index dca49f5b..1a07bb2f 100644 --- a/tests/unit/test_deepnote_streamlit_client.py +++ b/tests/unit/test_deepnote_streamlit_client.py @@ -181,6 +181,7 @@ def open_request(request: Any, *, timeout: float) -> FakeResponse: assert json.loads(calls[0][3]) == { "notebookId": "notebook-1", + "detached": True, "inputs": {"limit": "20", "enabled": True, "regions": ["EU"]}, } assert calls[1][0].endswith("/v2/runs/run-1?snapshotDelivery=inline") @@ -195,6 +196,75 @@ def open_request(request: Any, *, timeout: float) -> FakeResponse: assert result.snapshot.project_name == "Result" +def test_cloud_run_reads_sanitized_snapshot_blocks_without_raw_snapshot() -> None: + responses = iter( + [ + {"run": {"runId": "run-1", "status": "pending"}}, + { + "run": { + "runId": "run-1", + "status": "success", + "snapshotBlocks": [ + { + "id": "code-1", + "type": "code", + "outputs": [ + { + "output_type": "execute_result", + "data": { + "application/vnd.deepnote.dataframe.v3+json": { + "columns": [{"name": "revenue"}], + "rows": [{"revenue": 42}], + } + }, + } + ], + "metadata": {"deepnote_table_state": {}}, + }, + { + "id": "agent-1", + "type": "agent", + "outputs": [ + { + "output_type": "display_data", + "data": {"text/markdown": "**Done**"}, + } + ], + "metadata": {}, + }, + ], + } + }, + ] + ) + + def open_request(request: Any, *, timeout: float) -> FakeResponse: + assert timeout == 30 + if request.method == "POST": + assert json.loads(request.data) == { + "notebookId": "notebook-1", + "detached": True, + "inputs": {"region": "EU"}, + } + return FakeResponse(next(responses)) + + result = DeepnoteCloudRunner( + "notebook-1", + token="token", + opener=open_request, + sleep=lambda _delay: None, + ).run({"region": "EU"}) + + assert result.snapshot is None + assert result.snapshot_yaml is None + assert [output.block_id for output in result.outputs] == ["code-1", "agent-1"] + assert [output.block_type for output in result.outputs] == ["code", "agent"] + dataframe = result.first_dataframe() + assert dataframe is not None + assert dataframe.records() == [{"revenue": 42}] + assert result.agent_text() == "**Done**" + + def test_cloud_run_surfaces_terminal_error() -> None: def open_request(_request: Any, *, timeout: float) -> FakeResponse: assert timeout == 30