diff --git a/.gitignore b/.gitignore index 5d61dde..eee8e22 100644 --- a/.gitignore +++ b/.gitignore @@ -208,3 +208,4 @@ marimo/_lsp/ __marimo__/ docs/site/* +.claude/settings.local.json diff --git a/pywry/docs/docs/components/chat/index.md b/pywry/docs/docs/components/chat/index.md index 7055983..5070b3a 100644 --- a/pywry/docs/docs/components/chat/index.md +++ b/pywry/docs/docs/components/chat/index.md @@ -1,22 +1,72 @@ # Chat -PyWry includes a first-class chat UI that can run in native windows, notebook widgets, and browser-rendered deployments. The chat stack has two layers: +PyWry ships a complete chat UI component that works in native desktop windows, Jupyter notebooks, and browser tabs. It handles the entire conversation lifecycle — rendering messages, streaming responses token-by-token, managing multiple conversation threads, and displaying rich content like code blocks, charts, and data tables inline. -- `build_chat_html()` for low-level rendering of the chat shell. -- `ChatManager` for the production path: thread management, event wiring, streaming, stop-generation, slash commands, settings, and input requests. +The chat system is built on the **Agent Client Protocol (ACP)**, an open standard that defines how AI coding agents communicate with client applications. You do not need to know anything about ACP to use PyWry chat — the protocol details are handled internally. What it means in practice is that the same chat component can talk to any ACP-compatible agent (like Claude Code or Gemini CLI) as easily as it talks to the OpenAI or Anthropic APIs. -If you are building an interactive assistant, use `ChatManager` unless you explicitly need to assemble the raw chat HTML yourself. +## Architecture Overview -For the complete API surface, see the [Chat API](../../reference/chat.md), [ChatManager API](../../reference/chat-manager.md), and [Chat Providers API](../../integrations/chat/chat-providers.md). +The chat system has two layers: -## Minimal ChatManager Setup +1. **`ChatManager`** — the high-level orchestrator that most developers should use. It handles thread management, event wiring, streaming, cancellation, slash commands, settings menus, and all the plumbing between your AI backend and the chat UI. + +2. **`build_chat_html()`** — the low-level HTML builder that produces the raw chat DOM structure. Use this only if you are assembling a completely custom chat experience and want to handle all events yourself. + +## How It Works + +When a user types a message in the chat input and presses send: + +1. The frontend emits a `chat:user-message` event with the text. +2. `ChatManager` receives the event, stores the message in the thread history, and starts a background thread. +3. The background thread calls your **handler function** (or **provider**) with the conversation history. +4. Your handler returns or yields response chunks — plain strings for text, or typed objects for rich content. +5. `ChatManager` dispatches each chunk to the frontend as it arrives, which renders it in real time. +6. When the handler finishes, the assistant message is finalized and stored in thread history. + +The user can click **Stop** at any time to cancel generation. Your handler receives this signal through `ctx.cancel_event`. + +## Getting Started + +### Install + +```bash +pip install pywry +``` + +For AI provider support, install the optional extras: + +```bash +pip install 'pywry[openai]' # OpenAI +pip install 'pywry[anthropic]' # Anthropic +pip install 'pywry[magentic]' # Magentic (100+ providers) +pip install 'pywry[acp]' # External ACP agents +pip install 'pywry[all]' # Everything +``` + +### Minimal Example + +This creates a chat window with a simple echo handler: ```python -from pywry import Div, HtmlContent, PyWry, Toolbar -from pywry.chat_manager import ChatManager +from pywry import HtmlContent, PyWry +from pywry.chat.manager import ChatManager def handler(messages, ctx): + """Called every time the user sends a message. + + Parameters + ---------- + messages : list[dict] + The full conversation history for the active thread. + Each dict has 'role' ('user' or 'assistant') and 'text'. + ctx : ChatContext + Context object with thread_id, settings, cancel_event, etc. + + Returns or yields + ----------------- + str or SessionUpdate objects — see below. + """ user_text = messages[-1]["text"] return f"You said: {user_text}" @@ -24,12 +74,11 @@ def handler(messages, ctx): app = PyWry(title="Chat Demo") chat = ChatManager( handler=handler, - welcome_message="Welcome to **PyWry Chat**.", - system_prompt="You are a concise assistant.", + welcome_message="Hello! Type a message to get started.", ) widget = app.show( - HtmlContent(html="

Assistant

Ask something in the chat panel.

"), + HtmlContent(html="

My App

"), toolbars=[chat.toolbar(position="right")], callbacks=chat.callbacks(), ) @@ -38,245 +87,307 @@ chat.bind(widget) app.block() ``` -`ChatManager` expects three pieces to be connected together: +Three things must be wired together: -1. `chat.toolbar()` to render the chat panel. -2. `chat.callbacks()` to wire the `chat:*` frontend events. -3. `chat.bind(widget)` after `app.show(...)` so the manager can emit updates back to the active widget. +1. **`chat.toolbar()`** — returns a collapsible sidebar panel containing the chat UI. Pass it to `app.show(toolbars=[...])`. +2. **`chat.callbacks()`** — returns a dict mapping `chat:*` event names to handler methods. Pass it to `app.show(callbacks=...)`. +3. **`chat.bind(widget)`** — tells the manager which widget to send events back to. Call this after `app.show()` returns. -## Handler Shapes +## Writing Handlers -The handler passed to `ChatManager` is the core integration point. PyWry supports all of these forms: +The handler function is where your AI logic lives. It receives the conversation history and a context object, and produces the assistant's response. -- Sync function returning `str` -- Async function returning `str` -- Sync generator yielding `str` chunks -- Async generator yielding `str` chunks -- Sync or async generator yielding rich `ChatResponse` objects +### Return a String -### One-shot response +The simplest handler returns a complete string. The entire response appears at once. ```python def handler(messages, ctx): - question = messages[-1]["text"] - return f"Answering: {question}" + return "Here is my answer." ``` -### Streaming response +### Yield Strings (Streaming) + +For a streaming experience where text appears word-by-word, yield string chunks from a generator: ```python import time def handler(messages, ctx): - text = "Streaming responses work token by token in the chat UI." - for word in text.split(): + words = "This streams one word at a time.".split() + for word in words: if ctx.cancel_event.is_set(): - return + return # User clicked Stop yield word + " " - time.sleep(0.03) + time.sleep(0.05) ``` -### Rich response objects +Always check `ctx.cancel_event.is_set()` between chunks. This is how the Stop button works — it sets the event, and your handler should exit promptly. + +### Yield Rich Objects + +Beyond plain text, handlers can yield typed objects that render as structured UI elements: ```python -from pywry import StatusResponse, ThinkingResponse, TodoItem, TodoUpdateResponse +from pywry.chat.updates import PlanUpdate, StatusUpdate, ThinkingUpdate +from pywry.chat.session import PlanEntry def handler(messages, ctx): - yield StatusResponse(text="Searching project files...") - yield TodoUpdateResponse( - items=[ - TodoItem(id=1, title="Analyze request", status="completed"), - TodoItem(id=2, title="Generate answer", status="in-progress"), - ] - ) - yield ThinkingResponse(text="Comparing the available implementation paths...\n") - yield "Here is the final answer." + # Show a transient status message (disappears when next content arrives) + yield StatusUpdate(text="Searching documentation...") + + # Show collapsible thinking/reasoning (not stored in history) + yield ThinkingUpdate(text="Evaluating three possible approaches...\n") + + # Show a task plan with progress tracking + yield PlanUpdate(entries=[ + PlanEntry(content="Search docs", priority="high", status="completed"), + PlanEntry(content="Synthesize answer", priority="high", status="in_progress"), + ]) + + # Stream the actual answer + yield "Based on the documentation, the answer is..." ``` -## Conversation State +These objects are called **session updates** and follow the ACP specification. The available types are: + +| Type | What It Does | +|------|-------------| +| `StatusUpdate` | Shows a transient inline status (e.g. "Searching...") | +| `ThinkingUpdate` | Shows collapsible reasoning text (not saved to history) | +| `PlanUpdate` | Shows a task list with priority and status for each entry | +| `ToolCallUpdate` | Shows a tool invocation with name, kind, and lifecycle status | +| `CitationUpdate` | Shows a source reference link | +| `ArtifactUpdate` | Shows a rich content block (code, chart, table — see Artifacts below) | +| `PermissionRequestUpdate` | Shows an inline approval card for tool execution | +| `CommandsUpdate` | Dynamically registers slash commands | +| `ConfigOptionUpdate` | Pushes settings options from the agent | +| `ModeUpdate` | Switches the agent's operational mode | -`ChatManager` handles thread state internally: +You can mix these freely with plain text strings in any order. -- Creates a default thread on startup -- Tracks active thread selection -- Supports thread create, switch, rename, and delete events -- Keeps message history per thread -- Exposes `active_thread_id`, `threads`, and `settings` as read-only views +### Async Handlers -Use `send_message()` when you need to push a programmatic assistant message into the active thread or a specific thread. +All handler shapes work as `async` functions or async generators too: ```python -chat.send_message("Background task completed.") +async def handler(messages, ctx): + async for chunk in my_async_llm_stream(messages): + if ctx.cancel_event.is_set(): + return + yield chunk ``` -## Slash Commands +## Using a Provider Instead of a Handler -Slash commands appear in the command palette inside the chat input. Register custom commands with `SlashCommandDef` and optionally handle them through `on_slash_command`. +If you want to connect to an actual LLM API, you can pass a **provider** instead of writing a handler function. Providers implement the ACP session interface and handle message formatting, streaming, and cancellation internally. ```python -from pywry import SlashCommandDef +from pywry.chat.manager import ChatManager +from pywry.chat.providers.openai import OpenAIProvider +provider = OpenAIProvider(api_key="sk-...") +chat = ChatManager( + provider=provider, + system_prompt="You are a helpful coding assistant.", +) +``` -def on_slash(command, args, thread_id): - if command == "/time": - chat.send_message("Current time: **12:34:56**", thread_id) +Available providers: + +| Provider | Backend | Install | +|----------|---------|---------| +| `OpenAIProvider` | OpenAI API | `pip install 'pywry[openai]'` | +| `AnthropicProvider` | Anthropic API | `pip install 'pywry[anthropic]'` | +| `MagenticProvider` | Any magentic-supported LLM | `pip install 'pywry[magentic]'` | +| `CallbackProvider` | Your own Python callable | (included) | +| `StdioProvider` | External ACP agent via subprocess | `pip install 'pywry[acp]'` | + +The `StdioProvider` is special — it spawns an external program (like `claude` or `gemini`) as a subprocess and communicates over stdin/stdout using JSON-RPC. This means you can connect PyWry's chat UI to any ACP-compatible agent without writing any adapter code. + +See [Chat Artifacts And Providers](../../integrations/chat/index.md) for detailed provider documentation. + +## Conversation Threads + +`ChatManager` supports multiple conversation threads. The UI includes a thread picker dropdown in the header bar where users can create, switch between, rename, and delete threads. + +Each thread has its own independent message history. The manager tracks: + +- The active thread ID +- Thread titles +- Per-thread message lists + +You can access these programmatically: + +```python +chat.active_thread_id # Currently selected thread +chat.threads # Dict of thread_id → message list +chat.settings # Current settings values +chat.send_message("Hi!") # Inject a message into the active thread +``` + +## Slash Commands + +Slash commands appear in a palette when the user types `/` in the input bar. Register them at construction time: + +```python +from pywry.chat.models import ACPCommand chat = ChatManager( handler=handler, slash_commands=[ - SlashCommandDef(name="/time", description="Show the current time"), - SlashCommandDef(name="/clearcache", description="Clear cached results"), + ACPCommand(name="/time", description="Show the current time"), + ACPCommand(name="/clear", description="Clear the conversation"), ], - on_slash_command=on_slash, + on_slash_command=my_slash_handler, ) + + +def my_slash_handler(command, args, thread_id): + if command == "/time": + import time + chat.send_message(f"It is {time.strftime('%H:%M:%S')}", thread_id) ``` -PyWry also ships built-in commands at the lower-level `ChatConfig` layer, including `/clear`, `/export`, `/model`, and `/system`. +The `/clear` command is always available by default — it clears the current thread's history. ## Settings Menu -Use `SettingsItem` to populate the gear-menu dropdown. These values are stored by the manager and emitted back through `on_settings_change`. +The gear icon in the chat header opens a settings dropdown. Populate it with `SettingsItem` entries: ```python -from pywry import SettingsItem +from pywry.chat.manager import SettingsItem def on_settings_change(key, value): - print(f"{key} changed to {value}") + if key == "model": + chat.send_message(f"Switched to **{value}**") + elif key == "temp": + chat.send_message(f"Temperature set to **{value}**") chat = ChatManager( handler=handler, settings=[ - SettingsItem( - id="model", - label="Model", - type="select", - value="gpt-4o-mini", - options=["gpt-4o-mini", "gpt-4.1", "claude-sonnet-4"], - ), - SettingsItem( - id="temperature", - label="Temperature", - type="range", - value=0.7, - min=0, - max=2, - step=0.1, - ), + SettingsItem(id="model", label="Model", type="select", + value="gpt-4", options=["gpt-4", "gpt-4o", "claude-sonnet"]), + SettingsItem(id="temp", label="Temperature", type="range", + value=0.7, min=0, max=2, step=0.1), + SettingsItem(id="stream", label="Streaming", type="toggle", value=True), ], on_settings_change=on_settings_change, ) ``` -## Cooperative Cancellation - -The stop button triggers `chat:stop-generation`, and `ChatManager` exposes that to your handler through `ctx.cancel_event`. - -Your handler should check `ctx.cancel_event.is_set()` while streaming so long generations terminate quickly and cleanly. +Setting values are available in your handler via `ctx.settings`: ```python def handler(messages, ctx): - for chunk in very_long_generation(): - if ctx.cancel_event.is_set(): - return - yield chunk + model = ctx.settings.get("model", "gpt-4") + temp = ctx.settings.get("temp", 0.7) + # Use these to configure your LLM call ``` -At the lower level, `GenerationHandle` tracks the active task and provides cancellation state for provider-backed streaming flows. +## File Attachments And Context Mentions -## Input Requests +The chat input supports two ways to include extra context: -Chat flows can pause and ask the user for confirmation or structured input by yielding `InputRequiredResponse`. The handler can then continue by calling `ctx.wait_for_input()`. +**File attachments** — users drag-and-drop or click the paperclip button to attach files: ```python -from pywry import InputRequiredResponse - - -def handler(messages, ctx): - yield "I need confirmation before I continue." - yield InputRequiredResponse( - prompt="Proceed with deployment?", - input_type="buttons", - ) - answer = ctx.wait_for_input() - if not answer or answer.lower().startswith("n"): - yield "Deployment cancelled." - return - yield "Deployment approved. Continuing now." +chat = ChatManager( + handler=handler, + enable_file_attach=True, + file_accept_types=[".csv", ".json", ".py"], # Required +) ``` -Supported flows include: - -- Button confirmation dialogs -- Radio/select choices -- Free-text or filename input - -See the chat demo example for a complete input-request flow. - -## Context Mentions And File Attachments - -`ChatManager` can expose extra context sources to the user: - -- `enable_context=True` enables `@` mentions for registered live widget sources. -- `register_context_source(component_id, name)` makes a widget target selectable. -- `enable_file_attach=True` enables file uploads. -- `file_accept_types` is required when file attachment is enabled. -- `context_allowed_roots` restricts attachment reads to specific directories. +**Widget mentions** — users type `@` to reference live dashboard components: ```python chat = ChatManager( handler=handler, enable_context=True, - enable_file_attach=True, - file_accept_types=[".csv", ".json", ".xlsx"], - context_allowed_roots=["./data", "./reports"], ) - chat.register_context_source("sales-grid", "Sales Data") ``` -When attachments are present, `ChatManager.CONTEXT_TOOL` can be passed into an LLM tool schema so the model can request the full contents of an attached item on demand. +When attachments are present, your handler receives them in `ctx.attachments`: -## Eager Versus Lazy Assets +```python +def handler(messages, ctx): + if ctx.attachments: + yield StatusUpdate(text=f"Processing {len(ctx.attachments)} attachments...") + for att in ctx.attachments: + content = ctx.get_attachment(att.name) + yield f"**{att.name}** ({att.type}): {len(content)} chars\n\n" + yield "Here is my analysis of the attached data." +``` -The chat UI can render AG Grid and Plotly artifacts inline. You can choose between: +## Artifacts -- Eager asset loading with `include_plotly=True` and `include_aggrid=True` -- Lazy asset injection when the first matching artifact is emitted +Artifacts are rich content blocks that render inline in the chat transcript. Unlike streamed text, they appear as standalone visual elements — code editors, charts, tables, etc. -Eager loading is simpler for predictable assistant workflows. Lazy loading reduces initial page weight. +To emit an artifact, yield it from your handler wrapped in an `ArtifactUpdate`, or yield it directly (the manager auto-wraps `_ArtifactBase` subclasses): -## Lower-Level HTML Assembly +```python +from pywry.chat.artifacts import CodeArtifact, PlotlyArtifact, TableArtifact, TradingViewArtifact -If you need to embed the raw chat shell yourself, use `build_chat_html()`. +# Code with syntax highlighting +yield CodeArtifact( + title="fibonacci.py", + language="python", + content="def fib(n):\n if n <= 1:\n return n\n return fib(n - 1) + fib(n - 2)", +) -```python -from pywry import build_chat_html +# Interactive Plotly chart +yield PlotlyArtifact(title="Revenue", figure={"data": [{"type": "bar", "x": [1,2], "y": [3,4]}]}) -html = build_chat_html( - show_sidebar=True, - show_settings=True, - enable_context=True, - enable_file_attach=True, - file_accept_types=[".md", ".py", ".json"], - container_id="assistant-chat", +# AG Grid table +yield TableArtifact(title="Users", data=[{"name": "Alice", "age": 30}]) + +# TradingView financial chart +from pywry.chat.artifacts import TradingViewSeries +yield TradingViewArtifact( + title="AAPL", + series=[TradingViewSeries(type="candlestick", data=[ + {"time": "2024-01-02", "open": 185, "high": 186, "low": 184, "close": 185.5}, + ])], ) ``` -This returns only the chat HTML structure. You are then responsible for wiring the matching frontend and backend event flow. +Available artifact types: `CodeArtifact`, `MarkdownArtifact`, `HtmlArtifact`, `TableArtifact`, `PlotlyArtifact`, `ImageArtifact`, `JsonArtifact`, `TradingViewArtifact`. + +The frontend libraries for `TableArtifact` (AG Grid), `PlotlyArtifact` (Plotly.js), and `TradingViewArtifact` (lightweight-charts) are loaded automatically the first time an artifact of that type is emitted. You can also preload them by passing `include_plotly=True` or `include_aggrid=True` to the `ChatManager` constructor. + +## Notebook Mode + +When running inside a Jupyter notebook with `anywidget` installed (`pip install 'pywry[notebook]'`), the chat automatically renders as a native notebook widget — no HTTP server, no IFrame. The `PyWryChatWidget` bundles the chat JavaScript in its ESM module and loads artifact libraries (Plotly, AG Grid, TradingView) through traitlet synchronization when needed. + +This happens automatically. The same code works in native windows, notebooks, and browser deployments with no changes. + +## RBAC + +When PyWry's authentication system is enabled (deploy mode), all chat operations are gated by role-based access control: + +- **Viewers** can read but cannot send messages +- **Editors** can send messages and interact normally +- **Admins** can additionally approve file write operations from ACP agents + +See [Chat Artifacts And Providers](../../integrations/chat/index.md) for the full RBAC permission mapping. ## Examples -- `pywry/examples/pywry_demo_chat.py` demonstrates `ChatManager`, slash commands, settings, todo updates, thinking output, and `InputRequiredResponse`. -- `pywry/examples/pywry_demo_chat_artifacts.py` demonstrates all supported artifact types. +Working examples in the `examples/` directory: + +- **`pywry_demo_chat.py`** — ChatManager with slash commands, settings, plan updates, thinking output, and streaming +- **`pywry_demo_chat_artifacts.py`** — all artifact types including TradingView charts +- **`pywry_demo_chat_magentic.py`** — magentic provider integration with tool calls ## Next Steps -- [Chat Artifacts And Providers](../../integrations/chat/index.md) -- [Chat API](../../reference/chat.md) -- [ChatManager API](../../reference/chat-manager.md) -- [Chat Providers API](../../integrations/chat/chat-providers.md) +- [Chat Artifacts And Providers](../../integrations/chat/index.md) — detailed artifact and provider documentation +- [Chat Providers API](../../integrations/chat/chat-providers.md) — API reference for all providers diff --git a/pywry/docs/docs/components/modal/index.md b/pywry/docs/docs/components/modal/index.md index 9a38244..adc576a 100644 --- a/pywry/docs/docs/components/modal/index.md +++ b/pywry/docs/docs/components/modal/index.md @@ -131,7 +131,7 @@ modal = Modal( ) def on_dismissed(data, event_type, label): - print("User dismissed the confirmation dialog") + app.emit("pywry:set-content", {"id": "status", "text": "Action cancelled"}, label) app.show(content, modals=[modal], callbacks={"app:confirm-dismissed": on_dismissed}) ``` diff --git a/pywry/docs/docs/features.md b/pywry/docs/docs/features.md index 94b31c0..175f2a0 100644 --- a/pywry/docs/docs/features.md +++ b/pywry/docs/docs/features.md @@ -30,7 +30,7 @@ One API, three output targets — PyWry automatically selects the right one: | **[Configuration](guides/configuration.md)** | TOML files, env vars, layered precedence | | **[Hot Reload](guides/hot-reload.md)** | Live CSS/JS updates during development | | **[Deploy Mode](guides/deploy-mode.md)** | Redis backend for horizontal scaling | -| **[Tauri Plugins](integrations/tauri-plugins.md)** | 19 bundled plugins — clipboard, notifications, HTTP, and more | +| **[Tauri Plugins](integrations/pytauri/tauri-plugins.md)** | 19 bundled plugins — clipboard, notifications, HTTP, and more | ## Platform Support diff --git a/pywry/docs/docs/getting-started/quickstart.md b/pywry/docs/docs/getting-started/quickstart.md index 77e0551..7831ec2 100644 --- a/pywry/docs/docs/getting-started/quickstart.md +++ b/pywry/docs/docs/getting-started/quickstart.md @@ -89,8 +89,6 @@ app = PyWry() def on_button_click(data, event_type, label): """Called when the button is clicked.""" - print(f"Button clicked! Data: {data}") - # Update the page content app.emit("pywry:set-content", {"id": "greeting", "text": "Button was clicked!"}, label) html = """ diff --git a/pywry/docs/docs/getting-started/why-pywry.md b/pywry/docs/docs/getting-started/why-pywry.md index 9813d83..2051f78 100644 --- a/pywry/docs/docs/getting-started/why-pywry.md +++ b/pywry/docs/docs/getting-started/why-pywry.md @@ -1,81 +1,106 @@ # Why PyWry -PyWry is an open-source rendering engine for building lightweight, cross-platform interfaces using Python. It solves a specific problem: **how to build beautiful, modern data applications in Python without being forced into an opinionated web framework or a heavy native GUI toolkit.** +PyWry is an open-source rendering engine for building cross-platform interfaces using Python. It solves a specific problem: **how to build modern data applications in Python without being forced into an opinionated web framework or a heavy native GUI toolkit.** -PyWry renders standard HTML, CSS, and JavaScript inside battle-tested OS webviews (WebView2 on Windows, WebKit on macOS/Linux). Your team can use web skills they already have — no proprietary widget toolkit to learn. If it works in a browser, it works in PyWry. +PyWry renders standard HTML, CSS, and JavaScript inside OS-native webviews (WebView2 on Windows, WebKit on macOS/Linux) via [PyTauri](https://pytauri.github.io/pytauri/). Your team can use web skills they already have — no proprietary widget toolkit to learn. If it works in a browser, it works in PyWry. -There are many ways to render web content from Python — Electron, Dash, Streamlit, NiceGUI, Gradio, Flet, or plain FastAPI. So why choose PyWry? +## Write Once, Render Anywhere -### The "Goldilocks" Framework +PyWry's defining feature is that the same code renders in three environments without modification: -Python developers often find themselves choosing between uncomfortable extremes: +| Environment | Transport | How It Renders | +|---|---|---| +| Desktop terminal | PyTauri subprocess | Native OS webview window | +| Jupyter / VS Code / Colab | Anywidget traitlets | Notebook cell widget (no server) | +| Headless / SSH / Deploy | FastAPI + WebSocket | Browser tab via IFrame | -- **Native GUI Toolkits (PyQt/Tkinter)**: Steep learning curves, custom styling systems, and they don't look modern without massive effort. -- **Web-to-Desktop (Electron)**: Forces Python developers into the JavaScript/Node.js ecosystem and ships with hundreds of megabytes of Chromium bloat. -- **Data Dashboards (Streamlit/Gradio)**: Excellent for rapid deployment in a browser, but highly opinionated, difficult to deeply customize, and hard to package as a true desktop executable. +A Plotly chart, an AG Grid table, a TradingView financial chart, or a full chat interface — all render identically across these three paths. The same `on()`/`emit()` event protocol works in every environment, so components you build are portable by default. -PyWry targets the sweet spot: **Write your logic in Python, build your UI with modern web technologies, and deploy it anywhere**—including as a native, lightweight executable. +This pipeline is designed for data teams: prototype in a Jupyter notebook, share as a browser-based FastAPI application, and package as a standalone desktop executable with `pywry[freeze]` — all from the same Python code. -### The Jupyter → Web → Desktop Pipeline +## Built-In Integrations -PyWry's most potent feature is its **"Build Once, Render Anywhere"** pipeline. Most frameworks support Web + Desktop, but PyWry is uniquely optimized for data science and full-stack environments. +PyWry ships with production-ready integrations that implement industry-standard interfaces where they exist, so your code stays portable and your skills transfer. -You can instantly render a Plotly chart or AgGrid table directly inside a **Jupyter Notebook** cell. When you're ready to share your work, you use the exact same code to deploy a browser-based FastAPI application. When you want to hand an internal tool to a business user, you use `pywry[freeze]` to compile that *same code* into a standalone `.exe` or `.app`—dropping the notebook or server entirely. +### Plotly Charts -### Lightweight Native Windows +Interactive charts with automatic dark/light theming, pre-wired click/hover/selection/zoom events, programmatic updates, custom mode bar buttons that fire Python callbacks, and per-theme template overrides. Accepts standard Plotly `Figure` objects and figure dicts — the same data format used across the Plotly ecosystem. -PyWry uses the **OS-native webview** (WebView2, WebKit) via [PyTauri](https://github.com/pytauri/pytauri) instead of bundling a full browser engine like Electron. This results in apps that add only a few megabytes of overhead and open in under a second. There's no server to spin up and no browser to launch. +### AG Grid Tables -### One API, three targets +Sortable, filterable, editable data tables with automatic DataFrame conversion, cell editing callbacks, row selection events, and pagination. Configures through standard AG Grid `ColDef` and `GridOptions` structures — the same column definitions and grid options documented in the [AG Grid docs](https://www.ag-grid.com/javascript-data-grid/) work directly in PyWry. -Write your interface once. PyWry automatically renders it in the right place without changing your code: +### TradingView Financial Charts -| Environment | Rendering Path | -|---|---| -| Desktop terminal | Native OS window via PyTauri | -| Jupyter / VS Code / Colab | anywidget or inline IFrame | -| Headless / SSH / Deploy | Browser tab via FastAPI + WebSocket | +Full [TradingView Lightweight Charts](https://tradingview.github.io/lightweight-charts/) integration supporting three data modes: +- **Static** — pass a DataFrame or list of OHLCV dicts for one-shot rendering +- **Datafeed** — implement the `DatafeedProvider` interface for async on-demand bar loading, symbol resolution, and real-time subscriptions (follows TradingView's [Datafeed API](https://www.tradingview.com/charting-library-docs/latest/connecting_data/Datafeed-API/) contract) +- **UDF** — `UDFAdapter` wraps any `DatafeedProvider` as a [Universal Data Feed](https://www.tradingview.com/charting-library-docs/latest/connecting_data/UDF/) HTTP endpoint compatible with TradingView's server-side data protocol -### Built for data workflows +Also includes drawing tools, technical indicators, persistent chart layouts (file or Redis storage), and session/timezone management. -PyWry comes with built-in integrations tailored for data workflows: +### AI Chat (ACP) -- **Plotly charts** with pre-wired event callbacks (click, select, hover, zoom). -- **AG Grid tables** with automatic DataFrame conversion and grid events. -- **Toolbar system** with 18 declarative Pydantic input components across 7 layout positions to easily add headers, sidebars, and overlays. -- **Two-way events** between Python and JavaScript, with no boilerplate. +The chat system implements the [Agent Client Protocol (ACP)](https://agentclientprotocol.com) — an open standard for AI agent communication using JSON-RPC 2.0. This means: +- **Provider interface** follows ACP's session lifecycle: `initialize` → `new_session` → `prompt` → `cancel` +- **Session updates** use ACP's typed notification system: `agent_message`, `tool_call`, `plan`, `available_commands`, `config_option`, `current_mode` +- **Content blocks** use ACP's content model: `text`, `image`, `audio`, `resource`, `resource_link` +- **StdioProvider** connects to any ACP-compatible agent (Claude Code, Gemini CLI) over stdio JSON-RPC without writing adapter code -### Production-ready +Built-in providers for OpenAI, Anthropic, Magentic (100+ backends), [Deep Agents](https://docs.langchain.com/oss/python/deepagents/overview) (LangChain's agent harness with filesystem tools, planning, and subagents), and user-supplied callables adapt their respective APIs to the ACP session interface. Rich inline artifacts (code, markdown, tables, Plotly charts, TradingView charts, images, JSON trees) render directly in the chat transcript. -PyWry scales from prototyping to multi-user deployments: +### MCP Server -- **Deploy Mode** with an optional Redis backend for horizontal scaling. -- **OAuth2** authentication system for both native and deploy modes with enterprise RBAC. -- **Security built-in**: Token authentication, CSRF protection, and CSP headers out of the box. +A [Model Context Protocol](https://modelcontextprotocol.io/) server built on [FastMCP](https://github.com/jlowin/fastmcp) with 25+ tools that lets AI coding agents create and control PyWry widgets, send chat messages, manage chart data, and build interactive dashboards programmatically. MCP is the standard protocol used by Claude Code, Cursor, Windsurf, and other AI coding tools for tool integration. -### Cross-platform +### Toolbar System -PyWry runs on Windows, macOS, and Linux. The same code produces native windows on all three platforms, notebook widgets in any Jupyter environment, and browser-based interfaces anywhere Python runs. +18 declarative Pydantic input components (buttons, selects, toggles, sliders, text inputs, date pickers, search bars, secret inputs, radio groups, tab groups, marquees, and more) across 7 layout positions, all with automatic event wiring. +## Lightweight Native Windows -## Why not something else +PyWry uses the OS-native webview via PyTauri instead of bundling a full browser engine like Electron. Apps add only a few megabytes of overhead and open in under a second. The PyTauri subprocess provides access to 19 Tauri plugins for native OS capabilities — clipboard, file dialogs, notifications, global shortcuts, system tray icons, and more. -| Alternative | Trade-off | -|---|---| -| **NiceGUI** | Server + browser required natively; highly capable but lacks the single-codebase Jupyter → Desktop executable pipeline of PyWry. | -| **Electron** | 150 MB+ runtime per app, requires Node.js/JavaScript context, difficult integration for native Python execution. | -| **Dash / Streamlit / Gradio** | Opinionated UIs, browser-only deployment, not easily packagable into offline standalone executables. | -| **Flet (Flutter/Python)** | Cannot use standard web libraries (React, Tailwind, AG Grid) as it relies entirely on Flutter's custom canvas rendering. | -| **PyQt / Tkinter / wxPython** | Proprietary widget toolkits, requires learning custom desktop layout engines, lacks web interactivity features. | -| **Plain FastAPI + HTML** | No native OS windows, no notebook support, requires manual WebSocket and event wiring. | +## Unified Event Protocol + +All three rendering transports implement the same bidirectional event protocol: + +- **Python → JavaScript**: `widget.emit("app:update", {"count": 42})` updates the UI +- **JavaScript → Python**: `pywry.emit("app:click", {x: 100})` fires a Python callback + +This means every component — whether it's a Plotly chart, an AG Grid table, a TradingView chart, a chat panel, or a custom HTML element — uses the same `on()`/`emit()` pattern. Build a component once and it works in native windows, notebooks, and browser tabs. + +## Production Ready -PyWry sits in a unique position: native-quality lightweight desktop rendering, interactive Jupyter notebook support, and browser deployment, all from one Python API. +PyWry scales from a single-user notebook to multi-user deployments: + +- **Three state backends**: in-memory (ephemeral), SQLite with encryption at rest (local persistent), and Redis (multi-worker distributed) — the same interfaces, queries, and RBAC work on all three +- **SQLite audit trail**: tool call traces, generated artifacts, token usage stats, resource references, and skill activations persisted to an encrypted local database +- **Deploy Mode** with a Redis backend for horizontal scaling across multiple Uvicorn workers +- **OAuth2 authentication** with pluggable providers (Google, GitHub, Microsoft, generic OIDC) for both native and deploy modes +- **Role-based access control** with viewer/editor/admin roles enforced across all ACP chat operations, file system access, and terminal control +- **Security built-in**: per-widget token authentication, origin validation, CSP headers, secret input values never rendered in HTML, and SQLite databases encrypted at rest via SQLCipher + +## Cross Platform + +PyWry runs on Windows, macOS, and Linux. The same code produces native windows on all three platforms, notebook widgets in any Jupyter environment, and browser-based interfaces anywhere Python runs. The PyTauri binary ships as a vendored wheel — no Rust toolchain or system dependencies required. + +## Why Not Something Else + +| Alternative | What PyWry Adds | +|---|---| +| **Electron** | 150MB+ runtime, requires Node.js. PyWry uses the OS webview — a few MB, pure Python. | +| **Dash / Streamlit / Gradio** | Browser-only, opinionated layouts, no desktop executables. PyWry renders in notebooks, browsers, and native windows from one codebase. | +| **NiceGUI** | Server + browser required for native rendering. PyWry renders directly in the OS webview with no server for desktop mode. | +| **Flet** | Flutter canvas rendering — cannot use standard web libraries (Plotly, AG Grid, TradingView). PyWry renders any HTML/CSS/JS. | +| **PyQt / Tkinter** | Proprietary widget toolkits with custom layout engines. PyWry uses standard web technologies. | +| **Plain FastAPI** | No native windows, no notebook rendering, no event system, no component library. PyWry provides all of these. | -## Next steps +None of these alternatives offer the combination of native desktop rendering, Jupyter notebook widgets, browser deployment, integrated AI chat with ACP protocol support, TradingView financial charting, and MCP agent tooling — all from one Python API with one event protocol. -Ready to try it? +## Next Steps - [**Installation**](installation.md) — Install PyWry and platform dependencies - [**Quick Start**](quickstart.md) — Build your first interface in 5 minutes diff --git a/pywry/docs/docs/guides/app-show.md b/pywry/docs/docs/guides/app-show.md index 90c1b6f..ae6f379 100644 --- a/pywry/docs/docs/guides/app-show.md +++ b/pywry/docs/docs/guides/app-show.md @@ -86,10 +86,11 @@ A dictionary mapping event names to Python callback functions. These are registe ```python def on_click(data, event_type, label): - print(f"Clicked: {data}") + selected_point = data.get("points", [{}])[0] + app.emit("pywry:set-content", {"id": "info", "text": f"x={selected_point.get('x')}"}, label) def on_save(data, event_type, label): - print("Saving...") + app.emit("pywry:download", {"filename": "data.json", "content": "{}"}, label) app.show(html, callbacks={ "plotly:click": on_click, diff --git a/pywry/docs/docs/guides/browser-mode.md b/pywry/docs/docs/guides/browser-mode.md index c567a63..44514e2 100644 --- a/pywry/docs/docs/guides/browser-mode.md +++ b/pywry/docs/docs/guides/browser-mode.md @@ -107,8 +107,8 @@ h2 = app.show("

Table

", label="table") # Two browser tabs open: # http://127.0.0.1:8765/widget/chart # http://127.0.0.1:8765/widget/table -print(h1.url) # Full URL for the chart widget -print(h2.url) # Full URL for the table widget +chart_url = h1.url # e.g. http://127.0.0.1:8765/widget/chart +table_url = h2.url # e.g. http://127.0.0.1:8765/widget/table app.block() ``` diff --git a/pywry/docs/docs/guides/builder-options.md b/pywry/docs/docs/guides/builder-options.md index cc69047..dcd1f45 100644 --- a/pywry/docs/docs/guides/builder-options.md +++ b/pywry/docs/docs/guides/builder-options.md @@ -157,12 +157,12 @@ The `builder_kwargs()` method returns a dict of only the non-default builder fie from pywry.models import WindowConfig config = WindowConfig(transparent=True, user_agent="test/1.0") -print(config.builder_kwargs()) -# {'transparent': True, 'user_agent': 'test/1.0'} +kwargs = config.builder_kwargs() +# kwargs == {'transparent': True, 'user_agent': 'test/1.0'} config2 = WindowConfig() # all defaults -print(config2.builder_kwargs()) -# {} +kwargs2 = config2.builder_kwargs() +# kwargs2 == {} — only non-default values are included ``` This is used internally by the runtime to avoid sending unnecessary data over IPC. diff --git a/pywry/docs/docs/guides/configuration.md b/pywry/docs/docs/guides/configuration.md index 29b3060..e5c54ac 100644 --- a/pywry/docs/docs/guides/configuration.md +++ b/pywry/docs/docs/guides/configuration.md @@ -66,7 +66,8 @@ auto_start = true websocket_require_token = true [deploy] -state_backend = "memory" # or "redis" +state_backend = "memory" # "memory", "sqlite", or "redis" +sqlite_path = "~/.config/pywry/pywry.db" redis_url = "redis://localhost:6379/0" ``` @@ -194,6 +195,6 @@ pywry init ## Next Steps - **[Configuration Reference](../reference/config.md)** — Complete `PyWrySettings` API -- **[Tauri Plugins](../integrations/tauri-plugins.md)** — Enable clipboard, notifications, HTTP & more +- **[Tauri Plugins](../integrations/pytauri/tauri-plugins.md)** — Enable clipboard, notifications, HTTP & more - **[Deploy Mode](deploy-mode.md)** — Production server configuration - **[Browser Mode](browser-mode.md)** — Server settings for browser mode diff --git a/pywry/docs/docs/guides/deploy-mode.md b/pywry/docs/docs/guides/deploy-mode.md index e1c3d02..7cd9304 100644 --- a/pywry/docs/docs/guides/deploy-mode.md +++ b/pywry/docs/docs/guides/deploy-mode.md @@ -128,7 +128,8 @@ Deploy mode is configured through environment variables (prefix `PYWRY_SERVER__` | Setting | Default | Environment variable | Description | |:---|:---|:---|:---| -| State backend | `memory` | `PYWRY_DEPLOY__STATE_BACKEND` | `memory` or `redis` | +| State backend | `memory` | `PYWRY_DEPLOY__STATE_BACKEND` | `memory`, `sqlite`, or `redis` | +| SQLite path | `~/.config/pywry/pywry.db` | `PYWRY_DEPLOY__SQLITE_PATH` | Database file path (when backend is `sqlite`) | | Redis URL | `redis://localhost:6379/0` | `PYWRY_DEPLOY__REDIS_URL` | Redis connection string | | Redis prefix | `pywry` | `PYWRY_DEPLOY__REDIS_PREFIX` | Key namespace in Redis | | Redis pool size | `10` | `PYWRY_DEPLOY__REDIS_POOL_SIZE` | Connection pool size (1–100) | @@ -174,9 +175,9 @@ Redis key structure: `{prefix}:widget:{widget_id}` (hash), `{prefix}:widgets:act ```python from pywry.state import is_deploy_mode, get_state_backend, get_worker_id -print(f"Deploy mode: {is_deploy_mode()}") -print(f"Backend: {get_state_backend().value}") # "memory" or "redis" -print(f"Worker: {get_worker_id()}") +deploy_active = is_deploy_mode() # True when PYWRY_DEPLOY__ENABLED=true +backend = get_state_backend().value # "memory", "redis", or "sqlite" +worker_id = get_worker_id() # Unique per-process identifier ``` Deploy mode is active when any of these are true: diff --git a/pywry/docs/docs/guides/javascript-bridge.md b/pywry/docs/docs/guides/javascript-bridge.md index 7ae4bc5..2b86f8d 100644 --- a/pywry/docs/docs/guides/javascript-bridge.md +++ b/pywry/docs/docs/guides/javascript-bridge.md @@ -33,7 +33,8 @@ In Python, register a callback for the event: ```python def on_save(data, event_type, label): - print(f"Saving ID {data['id']} from window {label}") + record_id = data["id"] + app.emit("pywry:set-content", {"id": "status", "text": f"Saved {record_id}"}, label) handle = app.show(html, callbacks={"app:save": on_save}) ``` diff --git a/pywry/docs/docs/guides/menus.md b/pywry/docs/docs/guides/menus.md index 7abe64e..92f2617 100644 --- a/pywry/docs/docs/guides/menus.md +++ b/pywry/docs/docs/guides/menus.md @@ -35,11 +35,11 @@ app = PyWry() # ── Define handlers FIRST ──────────────────────────────────────── def on_new(data, event_type, label): - print("Creating new file…") + app.show(HtmlContent(html="

Untitled

"), title="New File") def on_open(data, event_type, label): - print("Opening file…") + app.emit("pywry:alert", {"message": "Open file dialog triggered"}, label) def on_quit(data, event_type, label): @@ -89,7 +89,7 @@ A normal clickable menu item. **`handler` is required.** from pywry import MenuItemConfig def on_save(data, event_type, label): - print("Saving…") + app.emit("app:save", {"path": "current.json"}, label) item = MenuItemConfig( id="save", # Unique ID — sent in menu:click events @@ -121,7 +121,7 @@ A toggle item with a check mark. **`handler` is required.** from pywry import CheckMenuItemConfig def on_bold(data, event_type, label): - print("Bold toggled") + app.emit("editor:toggle-bold", {"checked": data.get("checked", False)}, label) item = CheckMenuItemConfig( id="bold", @@ -149,7 +149,7 @@ A menu item with an icon (RGBA bytes or native OS icon). **`handler` is required from pywry import IconMenuItemConfig def on_doc(data, event_type, label): - print("Document clicked") + app.emit("editor:format", {"style": data.get("id", "plain")}, label) # With RGBA bytes item = IconMenuItemConfig( @@ -223,10 +223,10 @@ A nested container that holds other menu items. from pywry import SubmenuConfig, MenuItemConfig def on_zoom_in(data, event_type, label): - print("Zoom in") + app.emit("view:zoom", {"direction": "in"}, label) def on_zoom_out(data, event_type, label): - print("Zoom out") + app.emit("view:zoom", {"direction": "out"}, label) view_menu = SubmenuConfig( id="view", @@ -297,13 +297,13 @@ All mutations happen live — the native menu updates immediately. ```python def on_export(data, event_type, label): - print("Exporting…") + app.emit("app:export", {"format": "csv"}, label) def on_import(data, event_type, label): - print("Importing…") + app.emit("app:import", {"format": "csv"}, label) def on_save_as(data, event_type, label): - print("Save as…") + app.emit("app:save-as", {"path": ""}, label) # Add items (handler required on new items) menu.append(MenuItemConfig(id="export", text="Export", handler=on_export)) @@ -428,15 +428,15 @@ app = PyWry() # ── Handlers ────────────────────────────────────────────────────── def on_new(data, event_type, label): - print("Creating new file…") + app.show(HtmlContent(html="

Untitled

"), title="New File") def on_open(data, event_type, label): - print("Opening file…") + app.emit("pywry:alert", {"message": "Open file dialog triggered"}, label) def on_save(data, event_type, label): - print("Saving…") + app.emit("app:save", {"path": "current.json"}, label) def on_quit(data, event_type, label): @@ -444,23 +444,23 @@ def on_quit(data, event_type, label): def on_sidebar(data, event_type, label): - print("Sidebar toggled") + app.emit("view:toggle-sidebar", {}, label) def on_statusbar(data, event_type, label): - print("Status bar toggled") + app.emit("view:toggle-statusbar", {}, label) def on_zoom_in(data, event_type, label): - print("Zoom in") + app.emit("view:zoom", {"direction": "in"}, label) def on_zoom_out(data, event_type, label): - print("Zoom out") + app.emit("view:zoom", {"direction": "out"}, label) def on_zoom_reset(data, event_type, label): - print("Zoom reset") + app.emit("view:zoom", {"direction": "reset"}, label) # ── Menu structure ──────────────────────────────────────────────── diff --git a/pywry/docs/docs/integrations/multi-widget.md b/pywry/docs/docs/guides/multi-widget.md similarity index 96% rename from pywry/docs/docs/integrations/multi-widget.md rename to pywry/docs/docs/guides/multi-widget.md index f3ea996..80a12c1 100644 --- a/pywry/docs/docs/integrations/multi-widget.md +++ b/pywry/docs/docs/guides/multi-widget.md @@ -142,7 +142,7 @@ def on_export(_data, _event_type, _label): widget.emit("pywry:download", {"content": df.to_csv(index=False), "filename": "data.csv", "mimeType": "text/csv"}) ``` -See the [Event System guide](../guides/events.md) for the full list of system events (`pywry:set-content`, `pywry:download`, `plotly:update-figure`, `grid:update-data`, etc.). +See the [Event System guide](events.md) for the full list of system events (`pywry:set-content`, `pywry:download`, `plotly:update-figure`, `grid:update-data`, etc.). --- @@ -156,7 +156,7 @@ See [`examples/pywry_demo_multi_widget.py`](https://github.com/deeleeramone/PyWr - [Toolbar System](../components/toolbar/index.md) — all toolbar component types and their APIs - [Modals](../components/modal/index.md) — modal overlay components -- [Event System](../guides/events.md) — event registration and dispatch +- [Event System](events.md) — event registration and dispatch - [Theming & CSS](../components/theming.md) — `--pywry-*` variables and theme switching - [HtmlContent](../components/htmlcontent/index.md) — CSS files, script files, inline CSS, JSON data - [Content Assembly](../components/htmlcontent/content-assembly.md) — what PyWry injects into the document diff --git a/pywry/docs/docs/guides/oauth2.md b/pywry/docs/docs/guides/oauth2.md index c69d2ad..9cc836c 100644 --- a/pywry/docs/docs/guides/oauth2.md +++ b/pywry/docs/docs/guides/oauth2.md @@ -176,12 +176,14 @@ app = PyWry() try: result = app.login() # blocks — see "User experience" below + access_token = result.access_token + # Proceed with authenticated app.show(...) using access_token except AuthFlowTimeout: - print("User took too long to authenticate") + app.show("

Login timed out

Please restart the app and try again.

") except AuthFlowCancelled: - print("User closed the login window") + app.show("

Login cancelled

You can retry from the menu.

") except AuthenticationError as e: - print(f"Authentication failed: {e}") + app.show(f"

Login failed

{e}
") ``` After a successful login: diff --git a/pywry/docs/docs/guides/state-and-auth.md b/pywry/docs/docs/guides/state-and-auth.md index c081503..53a07c2 100644 --- a/pywry/docs/docs/guides/state-and-auth.md +++ b/pywry/docs/docs/guides/state-and-auth.md @@ -14,7 +14,7 @@ The state layer is made of four pluggable stores and a callback registry: | **SessionStore** | User sessions, roles, and permissions | | **CallbackRegistry** | Python callback dispatch (always local) | -Each store has two implementations — `Memory*` for single-process use and `Redis*` for multi-worker deployments. A factory layer auto-selects the right one based on configuration. +Each store has three implementations — `Memory*` for ephemeral single-process use, `Sqlite*` for persistent local storage with encryption, and `Redis*` for multi-worker deployments. A factory layer auto-selects the right one based on configuration. ## State Backends @@ -29,6 +29,27 @@ store = get_widget_store() # MemoryWidgetStore sessions = get_session_store() # MemorySessionStore ``` +### SQLite (local persistent) + +Persists all state to an encrypted SQLite database file. Data survives process restarts without requiring an external server. The database is encrypted at rest using SQLCipher when available, with keys managed through the OS keyring. + +```bash +export PYWRY_DEPLOY__STATE_BACKEND=sqlite +export PYWRY_DEPLOY__SQLITE_PATH=~/.config/pywry/pywry.db +``` + +The SQLite backend includes a `ChatStore` with audit trail extensions not available in the Memory or Redis backends: + +- **Tool call logging** — every tool invocation with arguments, result, timing, and error status +- **Artifact logging** — generated code blocks, charts, tables, and other artifacts +- **Token usage tracking** — prompt tokens, completion tokens, total tokens, and cost per message +- **Resource references** — URIs, MIME types, and sizes of files the agent read or produced +- **Skill activations** — which skills were loaded during a conversation +- **Full-text search** — search across all message content with `search_messages()` +- **Cost aggregation** — `get_usage_stats()` and `get_total_cost()` across threads or widgets + +On first initialization, the SQLite backend auto-creates a default admin session (`session_id="local"`, `user_id="admin"`, `roles=["admin"]`) and seeds the standard role permission table. This means RBAC works identically to deploy mode — the same `check_permission()` calls, the same role hierarchy — with one permanent admin user. + ### Redis (production) Enables horizontal scaling across multiple workers/processes. Widgets registered by one worker are visible to all others. Events published on one worker are received by subscribers on every worker. @@ -56,7 +77,8 @@ All settings are controlled via `DeploySettings` and read from environment varia | Variable | Default | Description | |:---|:---|:---| -| `STATE_BACKEND` | `memory` | `memory` or `redis` | +| `STATE_BACKEND` | `memory` | `memory`, `sqlite`, or `redis` | +| `SQLITE_PATH` | `~/.config/pywry/pywry.db` | Path to SQLite database file | | `REDIS_URL` | `redis://localhost:6379/0` | Redis connection URL | | `REDIS_PREFIX` | `pywry` | Key namespace prefix | | `REDIS_POOL_SIZE` | `10` | Connection pool size (1–100) | diff --git a/pywry/docs/docs/guides/window-management.md b/pywry/docs/docs/guides/window-management.md index 0939988..c3a2c3d 100644 --- a/pywry/docs/docs/guides/window-management.md +++ b/pywry/docs/docs/guides/window-management.md @@ -108,7 +108,7 @@ handle = app.show(content, label="dashboard") # Auto-generated (UUID) handle = app.show(content) -print(handle.label) # e.g., "a3f1c2d4-..." +window_label = handle.label # e.g., "a3f1c2d4-..." — used to route events to this window ``` Labels are used to: diff --git a/pywry/docs/docs/integrations/aggrid/index.md b/pywry/docs/docs/integrations/aggrid/index.md index 22f7df6..466d857 100644 --- a/pywry/docs/docs/integrations/aggrid/index.md +++ b/pywry/docs/docs/integrations/aggrid/index.md @@ -1,10 +1,22 @@ -# AG Grid Tables +# AG Grid -PyWry provides first-class AG Grid support — pass a Pandas DataFrame to `show_dataframe()` and get sortable, filterable, editable data tables with pre-wired events. +PyWry integrates [AG Grid](https://www.ag-grid.com/) — a high-performance JavaScript data grid — to render interactive tables with sorting, filtering, column resizing, row selection, cell editing, and pagination. The integration handles all data serialization, event bridging, and theme synchronization automatically. -For the complete column and grid configuration API, see the [Grid Reference](grid.md). For all grid events and payloads, see the [Event Reference](../../reference/events/grid.md). +AG Grid runs entirely in the browser. PyWry's role is to serialize your Python data (DataFrames, dicts, lists) into AG Grid's JSON format, inject the AG Grid library into the page, wire up events so user interactions flow back to Python, and keep the grid's theme in sync with PyWry's dark/light mode. -## Basic Usage +## How It Works + +1. You pass a DataFrame (or list of dicts) to `show_dataframe()` or `TableArtifact` +2. PyWry calls `normalize_data()` which converts the data to `{rowData, columns, columnTypes}` — the format AG Grid expects +3. The AG Grid JavaScript library (~200KB gzipped) is injected into the page +4. `aggrid-defaults.js` registers event listeners on the grid instance that call `pywry.emit()` when the user clicks, selects, or edits cells +5. Your Python callbacks receive these events through the same `on()`/`emit()` protocol used by all PyWry components + +The grid renders in all three environments — native windows, notebooks (anywidget or IFrame), and browser tabs — using the same code. + +## Displaying a Grid + +### From a DataFrame ```python import pandas as pd @@ -13,72 +25,173 @@ from pywry import PyWry app = PyWry() df = pd.DataFrame({ - "Name": ["Alice", "Bob", "Charlie"], - "Age": [25, 30, 35], - "City": ["NYC", "LA", "Chicago"], + "Symbol": ["AAPL", "MSFT", "GOOGL", "AMZN"], + "Price": [189.84, 425.22, 176.49, 185.07], + "Change": [1.23, -0.45, 0.89, -2.10], + "Volume": [52_340_000, 18_920_000, 21_150_000, 45_670_000], }) -# Display the grid handle = app.show_dataframe(df) ``` +### From a List of Dicts + +```python +data = [ + {"name": "Alice", "role": "Engineer", "level": 3}, + {"name": "Bob", "role": "Designer", "level": 2}, +] + +handle = app.show_dataframe(data) +``` + +### Inside a Chat Response + +```python +from pywry.chat.artifacts import TableArtifact + +def handler(messages, ctx): + yield TableArtifact( + title="Portfolio", + data=portfolio_df, + height="320px", + ) +``` + +The AG Grid library is loaded lazily — it's only injected when the first grid is rendered. + +## Data Normalization + +`normalize_data()` accepts several input formats and converts them all to AG Grid's expected structure: + +| Input | Example | Result | +|-------|---------|--------| +| pandas DataFrame | `pd.DataFrame({"a": [1, 2]})` | Columns from DataFrame columns, types auto-detected | +| List of dicts | `[{"a": 1}, {"a": 2}]` | Columns from dict keys, types inferred from values | +| Dict of lists | `{"a": [1, 2], "b": [3, 4]}` | Columns from dict keys | +| Single dict | `{"a": 1, "b": 2}` | Rendered as a two-column key/value table | + +The normalizer also detects column types (`number`, `text`, `date`, `boolean`) and applies appropriate formatting defaults. + ## Column Configuration -Use `ColDef` for detailed column configuration: +`ColDef` controls how individual columns render and behave: ```python from pywry.grid import ColDef columns = [ - ColDef(field="name", header_name="Full Name", sortable=True, filter=True), - ColDef(field="age", header_name="Age", width=100, cell_data_type="number"), - ColDef(field="salary", value_formatter="'$' + value.toLocaleString()"), - ColDef(field="active", editable=True, cell_renderer="agCheckboxCellRenderer"), + ColDef( + field="symbol", + header_name="Ticker", + sortable=True, + filter=True, + pinned="left", + width=100, + ), + ColDef( + field="price", + header_name="Price", + cell_data_type="number", + value_formatter="'$' + value.toFixed(2)", + ), + ColDef( + field="change", + header_name="Change", + cell_data_type="number", + cell_style={"color": "params.value >= 0 ? '#a6e3a1' : '#f38ba8'"}, + ), + ColDef( + field="volume", + header_name="Volume", + value_formatter="value.toLocaleString()", + ), + ColDef( + field="active", + header_name="Active", + editable=True, + cell_renderer="agCheckboxCellRenderer", + ), ] handle = app.show_dataframe(df, column_defs=columns) ``` -For the full list of `ColDef` properties, see the [Grid Reference](grid.md). +Key `ColDef` fields: + +| Field | Type | Effect | +|-------|------|--------| +| `field` | `str` | Column key in the data | +| `header_name` | `str` | Display name in the header | +| `sortable` | `bool` | Allow clicking header to sort | +| `filter` | `bool` or `str` | Enable column filter (`True` for auto, or `"agTextColumnFilter"`, `"agNumberColumnFilter"`, etc.) | +| `editable` | `bool` | Allow inline cell editing | +| `width` | `int` | Fixed column width in pixels | +| `pinned` | `str` | Pin column to `"left"` or `"right"` | +| `cell_data_type` | `str` | `"number"`, `"text"`, `"date"`, `"boolean"` | +| `value_formatter` | `str` | JavaScript expression for display formatting | +| `cell_style` | `dict` | Conditional CSS styles | +| `cell_renderer` | `str` | AG Grid cell renderer component name | + +For the complete list, see the [Grid Reference](grid.md). ## Grid Options -Use `GridOptions` for global grid configuration: +`GridOptions` controls grid-level behavior: ```python -from pywry.grid import GridOptions, RowSelection +from pywry.grid import GridOptions options = GridOptions( pagination=True, pagination_page_size=25, row_selection={"mode": "multiRow", "enableClickSelection": True}, animate_rows=True, + suppress_column_virtualisation=True, ) handle = app.show_dataframe(df, grid_options=options) ``` -For the full list of `GridOptions` properties, see the [Grid Reference](grid.md). +Key `GridOptions` fields: + +| Field | Type | Effect | +|-------|------|--------| +| `pagination` | `bool` | Enable pagination | +| `pagination_page_size` | `int` | Rows per page | +| `row_selection` | `dict` | Selection mode configuration | +| `animate_rows` | `bool` | Animate row additions/removals | +| `default_col_def` | `dict` | Default properties for all columns | +| `suppress_column_virtualisation` | `bool` | Render all columns (not just visible ones) | ## Grid Events -AgGrid emits events for user interactions: +AG Grid interactions produce events that your Python callbacks receive through the standard `on()`/`emit()` protocol: ```python def on_row_selected(data, event_type, label): - rows = data.get("rows", []) - app.emit("pywry:alert", {"message": f"Selected {len(rows)} rows"}, label) + selected_rows = data.get("rows", []) + symbols = [r["Symbol"] for r in selected_rows] + app.emit("pywry:set-content", { + "id": "selection", + "text": f"Selected: {', '.join(symbols)}", + }, label) def on_cell_click(data, event_type, label): + col = data["colId"] + value = data["value"] + row_index = data["rowIndex"] app.emit("pywry:set-content", { - "id": "status", - "text": f"{data['colId']} = {data['value']}" + "id": "detail", + "text": f"Row {row_index}: {col} = {value}", }, label) def on_cell_edit(data, event_type, label): - app.emit("pywry:alert", { - "message": f"Edited {data['colId']}: {data['oldValue']} → {data['newValue']}" - }, label) + col = data["colId"] + old_val = data["oldValue"] + new_val = data["newValue"] + row_data = data["data"] + save_edit_to_database(row_data, col, new_val) handle = app.show_dataframe( df, @@ -90,28 +203,55 @@ handle = app.show_dataframe( ) ``` -For the complete list of grid events and payload structures, see the [Event Reference](../../reference/events/grid.md). +Available grid events: + +| Event | Payload Fields | When It Fires | +|-------|---------------|---------------| +| `grid:cell-click` | `colId`, `value`, `rowIndex`, `data` | User clicks a cell | +| `grid:cell-double-click` | `colId`, `value`, `rowIndex`, `data` | User double-clicks a cell | +| `grid:cell-edit` | `colId`, `oldValue`, `newValue`, `data` | User finishes editing a cell | +| `grid:row-selected` | `rows` (list of selected row dicts) | Row selection changes | +| `grid:sort-changed` | `columns` (list of sort state dicts) | User changes sort order | +| `grid:filter-changed` | `filterModel` (AG Grid filter model dict) | User changes column filters | + +For complete payload structures, see the [Event Reference](../../reference/events/grid.md). ## Updating Grid Data -### Replace All Data +After the grid is displayed, update its data from Python: ```python -new_df = pd.DataFrame({...}) -handle.emit("grid:update-data", {"data": new_df.to_dict("records")}) +new_data = fetch_latest_prices() +handle.emit("grid:update-data", {"data": new_data}) ``` +The grid re-renders with the new data while preserving sort, filter, and selection state. + ## Themes -Available AG Grid themes: +AG Grid themes match PyWry's dark/light mode automatically: ```python -handle = app.show_dataframe(df, aggrid_theme="alpine") # default +handle = app.show_dataframe(df, aggrid_theme="alpine") # default handle = app.show_dataframe(df, aggrid_theme="balham") +handle = app.show_dataframe(df, aggrid_theme="quartz") handle = app.show_dataframe(df, aggrid_theme="material") ``` -Themes automatically adapt to PyWry's light/dark mode. +When the user switches PyWry's theme (via `pywry:update-theme`), the grid's CSS class is updated automatically — `ag-theme-alpine-dark` ↔ `ag-theme-alpine`. + +## Embedding in Multi-Widget Pages + +To place a grid alongside other components (charts, toolbars, etc.), generate the grid HTML directly: + +```python +from pywry.grid import build_grid_config, build_grid_html + +config = build_grid_config(df, grid_id="portfolio-grid", row_selection=True) +grid_html = build_grid_html(config) +``` + +Then compose it with `Div` and other components. The `grid_id` parameter lets you target the specific grid with events when multiple grids share a page. See [Multi-Widget Composition](../../guides/multi-widget.md) for the full pattern. ## With Toolbars @@ -121,18 +261,19 @@ from pywry import Toolbar, Button, TextInput toolbar = Toolbar( position="top", items=[ - TextInput(event="grid:search", label="Search", placeholder="Filter..."), + TextInput(event="grid:search", label="Search", placeholder="Filter rows..."), Button(event="grid:export", label="Export CSV"), ], ) def on_search(data, event_type, label): - query = data.get("value", "") - # Filter logic here + query = data.get("value", "").lower() + filtered = df[df.apply(lambda r: query in str(r.values).lower(), axis=1)] + handle.emit("grid:update-data", {"data": filtered.to_dict("records")}) def on_export(data, event_type, label): handle.emit("pywry:download", { - "filename": "data.csv", + "filename": "portfolio.csv", "content": df.to_csv(index=False), "mimeType": "text/csv", }) @@ -149,7 +290,7 @@ handle = app.show_dataframe( ## Next Steps -- **[Grid Reference](grid.md)** — Full `ColDef`, `GridOptions` API +- **[Grid Reference](grid.md)** — Complete `ColDef`, `ColGroupDef`, `DefaultColDef`, `GridOptions` API - **[Event Reference](../../reference/events/grid.md)** — All grid event payloads -- **[Toolbar System](../../components/toolbar/index.md)** — Building interactive controls -- **[Theming & CSS](../../components/theming.md)** — Styling the grid +- **[Multi-Widget Composition](../../guides/multi-widget.md)** — Embedding grids in dashboards +- **[Theming & CSS](../../components/theming.md)** — Styling and theme variables diff --git a/pywry/docs/docs/integrations/anywidget.md b/pywry/docs/docs/integrations/anywidget.md index 8533463..ba675d9 100644 --- a/pywry/docs/docs/integrations/anywidget.md +++ b/pywry/docs/docs/integrations/anywidget.md @@ -1,159 +1,269 @@ -# Anywidget & Widget Protocol +# Anywidget Transport -PyWry supports three rendering paths — native desktop windows, anywidget-based Jupyter widgets, and IFrame + FastAPI server. All three implement the same `BaseWidget` protocol, so your application code works identically regardless of environment. +PyWry's event system uses a unified protocol — `on()`, `emit()`, `update()`, `display()` — that works identically across native windows, IFrame+WebSocket, and anywidget. This page explains how that protocol is implemented over the anywidget transport, so you can build reusable components or introduce new integrations that work seamlessly in all three environments. -For the protocol and widget API reference, see [`BaseWidget`](../reference/widget-protocol.md), [`PyWryWidget`](../reference/widget.md), and [`InlineWidget`](../reference/inline-widget.md). +For the IFrame+WebSocket transport, see [IFrame + WebSocket Transport](../inline-widget/index.md). -## Rendering Path Auto-Detection +## The Unified Protocol -`PyWry.show()` automatically selects the best rendering path: +Every PyWry widget — regardless of rendering path — implements `BaseWidget`: -``` -Script / Terminal ──→ Native OS window (PyTauri subprocess) -Notebook + anywidget ──→ PyWryWidget (traitlet sync, no server) -Notebook + Plotly/Grid ──→ InlineWidget (FastAPI + IFrame) -Notebook without anywidget ──→ InlineWidget (FastAPI fallback) -Browser / SSH / headless ──→ InlineWidget (opens system browser) +```python +class BaseWidget(Protocol): + def on(self, event_type: str, callback: Callable[[dict, str, str], Any]) -> BaseWidget: ... + def emit(self, event_type: str, data: dict[str, Any]) -> None: ... + def update(self, html: str) -> None: ... + def display(self) -> None: ... ``` -No configuration needed — the right path is chosen at `show()` time. +A reusable component only calls these four methods. It never knows whether it's running in a native window, a notebook widget, or a browser tab. The transport handles everything else. -## The BaseWidget Protocol +## How Anywidget Implements the Protocol -Every rendering backend implements this protocol: +In anywidget mode, `PyWryWidget` extends `anywidget.AnyWidget` and implements `BaseWidget` by mapping each method to traitlet synchronization: -```python -from pywry.widget_protocol import BaseWidget +| BaseWidget Method | Anywidget Implementation | +|-------------------|--------------------------| +| `emit(type, data)` | Serialize `{type, data, ts}` to JSON → set `_py_event` traitlet → `send_state()` | +| `on(type, callback)` | Store callback in `_handlers[type]` dict → `_handle_js_event` observer dispatches | +| `update(html)` | Set `content` traitlet → JS `model.on('change:content')` re-renders | +| `display()` | Call `IPython.display.display(self)` | -def use_widget(widget: BaseWidget): - # Register a JS → Python event handler - widget.on("app:click", lambda data, event_type, label: print(data)) +### Traitlets - # Send a Python → JS event - widget.emit("app:update", {"key": "value"}) +Six traitlets carry all state between Python and JavaScript: - # Replace the widget HTML - widget.update("

New Content

") +| Traitlet | Direction | Purpose | +|----------|-----------|---------| +| `content` | Python → JS | HTML markup to render | +| `theme` | Bidirectional | `"dark"` or `"light"` | +| `width` | Python → JS | CSS width | +| `height` | Python → JS | CSS height | +| `_js_event` | JS → Python | Serialized event from browser | +| `_py_event` | Python → JS | Serialized event from Python | - # Show the widget in the current context - widget.display() -``` +### Event Wire Format -| Method | Description | -|--------|-------------| -| `on(event_type, callback)` | Register callback for JS → Python events. Callback receives `(data, event_type, label)`. Returns self for chaining. | -| `emit(event_type, data)` | Send Python → JS event with a JSON-serializable payload. | -| `update(html)` | Replace the widget's HTML content. | -| `display()` | Show the widget (native window, notebook cell, or browser tab). | +Both `_js_event` and `_py_event` carry JSON strings: -## Level 1: PyWryWidget (anywidget) +```json +{"type": "namespace:event-name", "data": {"key": "value"}, "ts": "unique-id"} +``` -The best notebook experience. Uses anywidget's traitlet sync — no server needed, instant bidirectional communication through the Jupyter kernel. +The `ts` field ensures every event is a unique traitlet value. Jupyter only syncs on change — identical consecutive events would be dropped without unique timestamps. -**Requirements:** `pip install anywidget traitlets` +### JS → Python Path -```python -from pywry import PyWry +``` +pywry.emit("form:submit", {name: "x"}) + → JSON.stringify({type: "form:submit", data: {name: "x"}, ts: Date.now()}) + → model.set("_js_event", json_string) + → model.save_changes() + → Jupyter kernel syncs traitlet + → Python observer _handle_js_event fires + → json.loads(change["new"]) + → callback(data, "form:submit", widget_label) +``` -app = PyWry() -widget = app.show("

Hello from anywidget!

") +### Python → JS Path -# Events work identically -widget.on("app:ready", lambda d, e, l: print("Widget ready")) -widget.emit("app:update", {"count": 42}) +``` +widget.emit("pywry:set-content", {"id": "status", "text": "Done"}) + → json.dumps({"type": ..., "data": ..., "ts": uuid.hex}) + → self._py_event = json_string + → self.send_state("_py_event") + → Jupyter kernel syncs traitlet + → JS model.on("change:_py_event") fires + → JSON.parse(model.get("_py_event")) + → pywry._fire(type, data) + → registered on() listeners execute ``` -**How it works:** - -1. Python creates a `PyWryWidget` (extends `anywidget.AnyWidget`) -2. An ESM module is bundled as the widget frontend -3. Traitlets (`content`, `theme`, `_js_event`, `_py_event`) sync bidirectionally via Jupyter comms -4. `widget.emit()` → sets `_py_event` traitlet → JS receives change → dispatches to JS listeners -5. JS `pywry.emit()` → sets `_js_event` traitlet → Python `_handle_js_event()` → dispatches to callbacks +## The ESM Render Function + +The widget frontend is an ESM module with a `render({model, el})` function. This function must: + +1. Create a `.pywry-widget` container div inside `el` +2. Render `model.get("content")` as innerHTML +3. Create a local `pywry` bridge object with `emit()`, `on()`, and `_fire()` +4. Also set `window.pywry` for HTML `onclick` handlers to access +5. Listen for `change:_py_event` and dispatch to `pywry._fire()` +6. Listen for `change:content` and re-render +7. Listen for `change:theme` and update CSS classes + +The `pywry` bridge in the ESM implements the JavaScript side of the protocol: + +```javascript +const pywry = { + _handlers: {}, + emit: function(type, data) { + // Write to _js_event traitlet → triggers Python observer + model.set('_js_event', JSON.stringify({type, data: data || {}, ts: Date.now()})); + model.save_changes(); + // Also dispatch locally so JS listeners fire immediately + this._fire(type, data || {}); + }, + on: function(type, callback) { + if (!this._handlers[type]) this._handlers[type] = []; + this._handlers[type].push(callback); + }, + _fire: function(type, data) { + (this._handlers[type] || []).forEach(function(h) { h(data); }); + } +}; +``` -**When it's used:** Notebook environment + anywidget installed + no Plotly/AG Grid/TradingView content. +## Building a Reusable Component -## Level 2: InlineWidget (IFrame + FastAPI) +A reusable component is a Python class that takes a `BaseWidget` and registers event handlers. Because it only calls `on()` and `emit()`, it works on all three rendering paths without modification. -Used for Plotly, AG Grid, and TradingView content in notebooks, or when anywidget isn't installed. Starts a local FastAPI server and renders via an IFrame. +### Python Side: State Mixin Pattern -**Requirements:** `pip install fastapi uvicorn` +PyWry's built-in components (`GridStateMixin`, `PlotlyStateMixin`, `ChatStateMixin`, `ToolbarStateMixin`) all follow the same pattern — they inherit from `EmittingWidget` and call `self.emit()`: ```python -from pywry import PyWry +from pywry.state_mixins import EmittingWidget -app = PyWry() -# Plotly/Grid/TradingView automatically use InlineWidget -handle = app.show_plotly(fig) -handle = app.show_dataframe(df) -handle = app.show_tvchart(ohlcv_data) -``` +class CounterMixin(EmittingWidget): + """Adds a counter widget that syncs between Python and JavaScript.""" -**How it works:** + def increment(self, amount: int = 1): + self.emit("counter:increment", {"amount": amount}) -1. A singleton FastAPI server starts in a background thread (one per kernel) -2. Each widget gets a URL (`/widget/{widget_id}`) and a WebSocket (`/ws/{widget_id}`) -3. An IFrame in the notebook cell points to the widget URL -4. `widget.emit()` → enqueues event → WebSocket send loop pushes to browser -5. JS `pywry.emit()` → sends over WebSocket → FastAPI handler dispatches to Python callbacks + def reset(self): + self.emit("counter:reset", {}) -**Multiple widgets share one server** — efficient for dashboards with many components. + def set_value(self, value: int): + self.emit("counter:set", {"value": value}) +``` -**Browser-only mode:** +Any widget class that mixes this in and provides `emit()` gets counter functionality: ```python -widget = app.show("

Dashboard

") -widget.open_in_browser() # Opens system browser instead of notebook +class MyWidget(PyWryWidget, CounterMixin): + pass + +widget = MyWidget(content=counter_html) +widget.increment(5) # Works in notebooks (anywidget traitlets) +widget.reset() # Works in browser (WebSocket) + # Works in native windows (Tauri IPC) ``` -## Level 3: NativeWindowHandle (Desktop) +### JavaScript Side: Event Handlers -Used in scripts and terminals. The PyTauri subprocess manages native OS webview windows. +The JavaScript side registers listeners through `pywry.on()` — this works identically in all rendering paths because every transport creates the same `pywry` bridge object: -```python -from pywry import PyWry +```javascript +// This code works in ESM (anywidget), ws-bridge.js (IFrame), and bridge.js (native) +pywry.on('counter:increment', function(data) { + var el = document.getElementById('counter-value'); + var current = parseInt(el.textContent) || 0; + el.textContent = current + data.amount; +}); + +pywry.on('counter:reset', function() { + document.getElementById('counter-value').textContent = '0'; +}); + +pywry.on('counter:set', function(data) { + document.getElementById('counter-value').textContent = data.value; +}); + +// User clicks emit events back to Python — same pywry.emit() everywhere +document.getElementById('inc-btn').onclick = function() { + pywry.emit('counter:clicked', {action: 'increment'}); +}; +``` + +### Wiring It Together -app = PyWry(title="My App", width=800, height=600) -handle = app.show("

Native Window

") +To use the component with `ChatManager`, `app.show()`, or any other entry point: + +```python +from pywry import HtmlContent, PyWry -# Same API as notebook widgets -handle.on("app:click", lambda d, e, l: print("Clicked!", d)) -handle.emit("app:update", {"status": "ready"}) +app = PyWry() -# Additional native-only features -handle.close() -handle.hide() -handle.eval_js("document.title = 'Updated'") -print(handle.label) # Window label +counter_html = """ +
+

0

+ + +
+ +""" + +def on_counter_click(data, event_type, label): + if data["action"] == "increment": + app.emit("counter:increment", {"amount": 1}, label) + elif data["action"] == "reset": + app.emit("counter:reset", {}, label) + +widget = app.show( + HtmlContent(html=counter_html), + callbacks={"counter:clicked": on_counter_click}, +) ``` -**How it works:** JSON-over-stdin/stdout IPC to the PyTauri Rust subprocess. The subprocess manages the OS webview (WKWebView on macOS, WebView2 on Windows, WebKitGTK on Linux). +This works in native windows, notebooks with anywidget, notebooks with IFrame fallback, and browser mode — the same HTML, the same callbacks, the same `pywry.emit()`/`pywry.on()` contract. + +## Specialized Widget Subclasses + +When a component needs its own bundled JavaScript library (like Plotly, AG Grid, or TradingView), it defines a widget subclass with a custom `_esm`: + +| Subclass | Mixin | Bundled Library | Extra Traitlets | +|----------|-------|-----------------|-----------------| +| `PyWryWidget` | `EmittingWidget` | Base bridge only | — | +| `PyWryPlotlyWidget` | `PlotlyStateMixin` | Plotly.js | `figure_json`, `chart_id` | +| `PyWryAgGridWidget` | `GridStateMixin` | AG Grid | `grid_config`, `grid_id`, `aggrid_theme` | +| `PyWryChatWidget` | `ChatStateMixin` | Chat handlers | `_asset_js`, `_asset_css` | +| `PyWryTVChartWidget` | `TVChartStateMixin` | Lightweight-charts | `chart_config`, `chart_id` | -## Writing Portable Code +Each subclass overrides `_esm` with an ESM module that includes both the library code and the domain-specific event handlers. The extra traitlets carry domain state (chart data, grid config, etc.) alongside the standard `content`/`theme`/`_js_event`/`_py_event` protocol. -Since all three backends share the `BaseWidget` protocol, write code against the protocol: +### Lazy Asset Loading + +`PyWryChatWidget` uses two additional traitlets — `_asset_js` and `_asset_css` — for on-demand library loading. When `ChatManager` first encounters a `PlotlyArtifact`, it pushes the Plotly library source through `_asset_js`: ```python -def setup_dashboard(widget): - """Works with any widget type.""" - widget.on("app:ready", lambda d, e, l: print("Ready in", l)) - widget.on("app:click", handle_click) - widget.emit("app:config", {"theme": "dark"}) - -# Works everywhere -handle = app.show(my_html) -setup_dashboard(handle) +# ChatManager detects anywidget and uses trait instead of HTTP +self._widget.set_trait("_asset_js", plotly_source_code) +``` + +The ESM listens for the trait change and injects the code: + +```javascript +model.on("change:_asset_js", function() { + var js = model.get("_asset_js"); + if (js) { + var script = document.createElement("script"); + script.textContent = js; + document.head.appendChild(script); + } +}); ``` -## Fallback Behavior +This replaces the `chat:load-assets` HTTP-based injection used in the IFrame transport, keeping the protocol uniform while adapting to the transport's capabilities. + +## Transport Comparison -If `anywidget` is not installed, `PyWryWidget` becomes a stub that shows an error message with install instructions. The `InlineWidget` fallback handles all notebook rendering in that case. +| Aspect | Anywidget | IFrame+WebSocket | Native Window | +|--------|-----------|------------------|---------------| +| `pywry.emit()` | Traitlet `_js_event` | WebSocket send | Tauri IPC `pyInvoke` | +| `pywry.on()` | Local handler dict | Local handler dict | Local handler dict | +| Python `emit()` | Traitlet `_py_event` | Async queue → WS send | Tauri event emit | +| Python `on()` | Traitlet observer | Callback dict lookup | Callback dict lookup | +| Asset loading | Bundled in `_esm` or `_asset_js` trait | HTTP ` + + {plotly.js, ag-grid.js, etc. if needed} + {toolbar handler scripts if toolbars present} + + +
+ {your HTML content} +
+ + +``` + +The `ws-bridge.js` template has three placeholders replaced at serve time: + +- `__WIDGET_ID__` → the widget's UUID +- `__WS_AUTH_TOKEN__` → per-widget authentication token (or `null`) +- `__PYWRY_DEBUG__` → `true` or `false` + +## WebSocket Protocol + +### Connection and Authentication + +On page load, `ws-bridge.js` opens a WebSocket: + +``` +ws://localhost:8765/ws/{widget_id} +``` + +If token auth is enabled (default), the token is sent in the `Sec-WebSocket-Protocol` header as `pywry.token.{token}`. The server validates the token before accepting the connection. Invalid tokens receive close code `4001`. + +After two consecutive auth failures, the browser automatically reloads the page to get a fresh token. + +### Event Wire Format + +Both directions use the same JSON structure: + +**JS → Python:** +```json +{"type": "app:click", "data": {"x": 100}, "widgetId": "abc123", "ts": 1234567890} +``` + +**Python → JS:** +```json +{"type": "pywry:set-content", "data": {"id": "status", "text": "Done"}, "ts": "a1b2c3"} +``` + +### JS → Python Path + +``` +pywry.emit("form:submit", {name: "Alice"}) + → JSON.stringify({type, data, widgetId, ts}) + → WebSocket.send(json_string) + → FastAPI websocket_endpoint receives message + → _route_ws_message(widget_id, msg) + → lookup callbacks in _state.widgets[widget_id] + → _state.callback_queue.put((callback, data, event_type, widget_id)) + → callback processor thread dequeues and executes + → callback(data, "form:submit", widget_id) +``` + +### Python → JS Path + +``` +widget.emit("pywry:set-content", {"id": "status", "text": "Done"}) + → serialize {type, data, ts} + → asyncio.run_coroutine_threadsafe(queue.put(event), server_loop) + → _ws_sender_loop pulls from event_queues[widget_id] + → websocket.send_json(event) + → ws-bridge.js receives message + → pywry._fire(type, data) + → registered on() listeners execute +``` + +### Reconnection + +If the WebSocket drops, `ws-bridge.js` reconnects with exponential backoff (1s → 2s → 4s → max 10s). During disconnection, `pywry.emit()` calls queue in `_msgQueue` and flush on reconnect. + +### Page Unload + +When the user closes the tab or navigates away: + +1. Secret input values are cleared from the DOM +2. A `pywry:disconnect` event is sent over WebSocket +3. `navigator.sendBeacon` posts to `/disconnect/{widget_id}` as fallback +4. Server fires `pywry:disconnect` callback if registered and cleans up state + +## The `pywry` Bridge Object + +`ws-bridge.js` creates `window.pywry` with the same interface as the anywidget ESM bridge: + +| Method | Description | +|--------|-------------| +| `emit(type, data)` | Send event to Python over WebSocket | +| `on(type, callback)` | Register listener for events from Python | +| `_fire(type, data)` | Dispatch locally to `on()` listeners | +| `result(data)` | Shorthand for `emit("pywry:result", data)` | +| `send(data)` | Shorthand for `emit("pywry:message", data)` | + +The bridge also pre-registers handlers for all built-in `pywry:*` events — CSS injection, content updates, theme switching, downloads, alerts, navigation. These are the same events handled by the anywidget ESM. + +## Building a Reusable Component + +The same component code works on both transports because both create the same `pywry` bridge. A component needs: + +### Python: A State Mixin + +```python +from pywry.state_mixins import EmittingWidget + + +class ProgressMixin(EmittingWidget): + """Adds a progress bar that syncs between Python and JavaScript.""" + + def set_progress(self, value: float, label: str = ""): + self.emit("progress:update", {"value": value, "label": label}) + + def complete(self): + self.emit("progress:complete", {}) +``` + +This mixin works with any widget that implements `emit()` — `PyWryWidget`, `InlineWidget`, or `NativeWindowHandle`. + +### JavaScript: Event Listeners + +```javascript +pywry.on('progress:update', function(data) { + var bar = document.getElementById('progress-bar'); + bar.style.width = data.value + '%'; + var label = document.getElementById('progress-label'); + if (label) label.textContent = data.label || (data.value + '%'); +}); + +pywry.on('progress:complete', function() { + var bar = document.getElementById('progress-bar'); + bar.style.width = '100%'; + bar.style.backgroundColor = '#a6e3a1'; +}); +``` + +This JavaScript runs identically in: + +- **Anywidget ESM** — the local `pywry` object writes to traitlets +- **IFrame ws-bridge.js** — the local `pywry` object writes to WebSocket +- **Native bridge.js** — the local `pywry` object writes to Tauri IPC + +### HTML Content + +```python +progress_html = """ +
+
+
+
+
+
+""" + +widget = app.show(HtmlContent(html=progress_html)) +widget.set_progress(0) # Works on anywidget +widget.set_progress(50) # Works on IFrame+WebSocket +widget.complete() # Works on native window +``` + +## Multiple Widgets + +Each widget gets its own WebSocket connection and event queue. Events are routed by `widget_id` — there is no crosstalk between widgets: + +```python +chart = app.show_plotly(fig) +table = app.show_dataframe(df) + +chart.on("plotly:click", handle_chart_click) +table.on("grid:cell-click", handle_cell_click) + +chart.emit("plotly:update-layout", {"layout": {"title": "Updated"}}) +# Only the chart widget receives this — table is unaffected +``` + +## Security + +| Mechanism | How It Works | +|-----------|-------------| +| **Per-widget token** | Generated at creation, injected into HTML, sent via `Sec-WebSocket-Protocol` header, validated before accepting WebSocket | +| **Origin validation** | Optional `websocket_allowed_origins` list checked on WebSocket upgrade | +| **Auto-refresh** | Two consecutive auth failures trigger page reload for fresh token | +| **Secret clearing** | `beforeunload` event clears revealed password/secret input values from DOM | + +## Deploy Mode (Redis Backend) + +In production with multiple Uvicorn workers: + +- Widget HTML and tokens are stored in Redis instead of `_state.widgets` +- Callbacks register in a shared callback registry +- Event queues remain per-process (WebSocket connections are worker-local) +- Widget registration uses HTTP POST to ensure the correct worker handles it + +The developer-facing API is unchanged. The same `widget.on()` and `widget.emit()` calls work regardless of whether state is in-memory or in Redis. + +## Transport Comparison + +| Aspect | IFrame+WebSocket | Anywidget | Native Window | +|--------|------------------|-----------|---------------| +| `pywry.emit()` | WebSocket send | Traitlet `_js_event` | Tauri IPC `pyInvoke` | +| `pywry.on()` | Local handler dict | Local handler dict | Local handler dict | +| Python `emit()` | Async queue → WS send | Traitlet `_py_event` | Tauri event emit | +| Python `on()` | Callback dict lookup | Traitlet observer | Callback dict lookup | +| Asset loading | HTTP ` -""" - -app.show(html, include_plotly=True) -``` - ## Theming -Charts automatically adapt to PyWry's theme: - -```python -from pywry import PyWry, ThemeMode +Charts automatically adapt to PyWry's dark/light mode. PyWry applies the built-in `plotly_dark` or `plotly_white` template based on the active theme. -app = PyWry(theme=ThemeMode.LIGHT) # or ThemeMode.DARK - -fig = px.scatter(x=[1, 2, 3], y=[1, 4, 9]) -app.show_plotly(fig) # Uses appropriate Plotly template -``` - -To change theme dynamically: +To switch dynamically: ```python handle.emit("pywry:update-theme", {"theme": "light"}) ``` +The chart re-renders with the appropriate Plotly template. + ### Custom Per-Theme Templates -By default, PyWry applies the built-in `plotly_dark` or `plotly_white` template based on the current theme. To customize chart colors *per theme* while preserving automatic switching, use `template_dark` and `template_light` on `PlotlyConfig`: +Override specific layout properties while keeping automatic theme switching: ```python -from pywry import PlotlyConfig - config = PlotlyConfig( template_dark={ "layout": { "paper_bgcolor": "#1a1a2e", "plot_bgcolor": "#16213e", "font": {"color": "#e0e0e0"}, + "colorway": ["#89b4fa", "#a6e3a1", "#f9e2af", "#f38ba8"], } }, template_light={ "layout": { "paper_bgcolor": "#ffffff", - "plot_bgcolor": "#f0f0f0", + "plot_bgcolor": "#f8f9fa", "font": {"color": "#222222"}, + "colorway": ["#1971c2", "#2f9e44", "#e8590c", "#c2255c"], } }, ) @@ -233,21 +274,75 @@ config = PlotlyConfig( handle = app.show_plotly(fig, config=config) ``` -**How it works:** +Your overrides are deep-merged on top of the built-in base template. Values you set take precedence; everything else is inherited. Both templates are stored on the chart and automatically selected when the theme toggles. -- Your overrides are **deep-merged** on top of the built-in base template (`plotly_dark` or `plotly_white`). -- **User values always win** on conflict. Anything you don't set is inherited from the base. -- Both templates are stored on the chart and automatically selected when the theme toggles. -- Arrays (e.g., colorways) are replaced entirely, not element-merged. +Set only one side (e.g. `template_dark` alone) and the other theme uses the unmodified base. -You can also set only one side — e.g., `template_dark` alone — and the other theme will use the unmodified base. +## Embedding in Multi-Widget Pages -!!! tip - Use `template_dark` / `template_light` instead of setting `fig.update_layout(template=...)` directly. The latter gets overwritten on theme switch; the former survives toggles. +To place a chart alongside other components, generate the chart HTML directly: + +```python +import json +from pywry.templates import build_plotly_init_script + +chart_html = build_plotly_init_script( + figure=json.loads(fig.to_json()), + chart_id="revenue-chart", +) +``` + +Then compose with `Div` and pass `include_plotly=True` to `app.show()`. The `chart_id` lets you target the specific chart when multiple charts share a page: + +```python +handle.emit("plotly:update-figure", {"figure": new_fig_dict, "chartId": "revenue-chart"}) +``` + +See [Multi-Widget Composition](../../guides/multi-widget.md) for the full pattern. + +## With Toolbars + +```python +from pywry import Toolbar, Button, Select, Option + +toolbar = Toolbar( + position="top", + items=[ + Select( + event="chart:metric", + label="Metric", + options=[ + Option(label="GDP per Capita", value="gdpPercap"), + Option(label="Population", value="pop"), + Option(label="Life Expectancy", value="lifeExp"), + ], + selected="gdpPercap", + ), + Button(event="chart:reset", label="Reset Zoom"), + ], +) + +def on_metric_change(data, event_type, label): + metric = data["value"] + new_fig = px.scatter(df, x=metric, y="lifeExp", color="continent") + handle.emit("plotly:update-figure", {"figure": new_fig.to_dict()}) + +def on_reset(data, event_type, label): + handle.emit("plotly:reset-zoom", {}) + +handle = app.show_plotly( + fig, + toolbars=[toolbar], + callbacks={ + "chart:metric": on_metric_change, + "chart:reset": on_reset, + }, +) +``` ## Next Steps - **[`PlotlyConfig` Reference](plotly-config.md)** — All configuration options - **[Event Reference](../../reference/events/plotly.md)** — Plotly event payloads -- **[Toolbar System](../../components/toolbar/index.md)** — Adding controls to your charts +- **[Multi-Widget Composition](../../guides/multi-widget.md)** — Embedding charts in dashboards - **[Theming & CSS](../../components/theming.md)** — Visual customization diff --git a/pywry/docs/docs/integrations/pytauri/index.md b/pywry/docs/docs/integrations/pytauri/index.md new file mode 100644 index 0000000..93f564b --- /dev/null +++ b/pywry/docs/docs/integrations/pytauri/index.md @@ -0,0 +1,411 @@ +# PyTauri Transport + +PyWry's event system uses a unified protocol — `on()`, `emit()`, `update()`, `display()` — that works identically across PyTauri, IFrame+WebSocket, and anywidget. This page explains how that protocol is implemented over the PyTauri transport, so you can build reusable components that work seamlessly in all three environments. + +For the other transports, see [Anywidget Transport](../anywidget/index.md) and [IFrame + WebSocket Transport](../inline-widget/index.md). + +## Architecture + +PyTauri runs a Rust subprocess that manages OS webview windows. Python communicates with this subprocess over stdin/stdout JSON IPC. + +```mermaid +flowchart LR + subgraph python["Python Process"] + RT["runtime.py
send_command()"] + CB["callbacks.py
dispatch()"] + end + + subgraph tauri["PyTauri Subprocess"] + MAIN["__main__.py
dispatch_command()"] + subgraph engine["Tauri Engine"] + subgraph wv["Window w-abc → WebView"] + BR["bridge.js"] + SE["system-events.js"] + HTML["your HTML"] + end + end + end + + RT -- "stdin JSON
{action, label, event, payload}" --> MAIN + MAIN -- "stdout JSON
{success: true}" --> RT + wv -- "pywry_event IPC
{label, type, data}" --> MAIN + MAIN -- "stdout event JSON" --> CB +``` + +Each window runs the same `bridge.js` and `system-events.js` scripts that the other transports use, providing the same `window.pywry` bridge object. + +## How NativeWindowHandle Implements the Protocol + +| BaseWidget Method | Native Implementation | +|-------------------|----------------------| +| `emit(type, data)` | `runtime.emit_event(label, type, data)` → stdin JSON `{action:"emit"}` → Tauri emits `pywry:event` to the window → `bridge.js` `_trigger(type, data)` dispatches to JS listeners | +| `on(type, callback)` | `callbacks.get_registry().register(label, type, callback)` → when JS calls `pywry.emit()`, Tauri invokes `pywry_event` IPC → `handle_pywry_event` dispatches via callback registry | +| `update(html)` | `lifecycle.set_content(label, html)` → builds new HTML page → replaces window content via Tauri | +| `display()` | No-op — native windows are visible immediately on creation | + +### Additional Native Methods + +`NativeWindowHandle` provides methods beyond `BaseWidget` that are only available in native mode: + +| Method | Description | +|--------|-------------| +| `eval_js(script)` | Execute arbitrary JavaScript in the window | +| `close()` | Destroy the window | +| `hide()` / `show_window()` | Toggle visibility without destroying | +| `proxy` | Returns a `WindowProxy` for full Tauri WebviewWindow API access | + +The `WindowProxy` exposes the complete Tauri window control surface — maximize, minimize, fullscreen, set title, set size, set position, set background color, set always-on-top, open devtools, set zoom level, navigate to URL, and more. These are native OS operations that have no equivalent in the notebook transports. + +## IPC Message Protocol + +### Python → Subprocess (stdin) + +Python sends JSON commands to the subprocess via stdin. Each command is a single JSON object on one line: + +```json +{"action": "emit", "label": "w-abc123", "event": "pywry:set-content", "payload": {"id": "status", "text": "Done"}} +``` + +| Action | Fields | Effect | +|--------|--------|--------| +| `create` | `label`, `url`, `html`, `title`, `width`, `height`, `theme` | Create a new window | +| `emit` | `label`, `event`, `payload` | Emit event to window's JavaScript | +| `eval_js` | `label`, `script` | Execute JavaScript in window | +| `close` | `label` | Close and destroy window | +| `hide` | `label` | Hide window | +| `show` | `label` | Show hidden window | +| `set_content` | `label`, `html` | Replace window HTML | +| `set_theme` | `label`, `theme` | Switch dark/light theme | + +The subprocess responds with `{"success": true}` or `{"success": false, "error": "..."}` on stdout. + +### Subprocess → Python (stdout) + +When JavaScript calls `pywry.emit()` in a window, the event flows: + +1. `bridge.js` calls `window.__TAURI__.pytauri.pyInvoke('pywry_event', payload)` +2. Tauri routes the IPC call to `handle_pywry_event(label, event_data)` in the subprocess +3. `handle_pywry_event` dispatches to the subprocess callback registry +4. The event is also written to stdout as JSON for the parent process +5. The parent process's reader thread picks it up and dispatches via `callbacks.get_registry()` + +The stdout event format: + +```json +{"type": "event", "label": "w-abc123", "event_type": "app:click", "data": {"x": 100}} +``` + +### Request-Response Correlation + +For blocking operations (like `eval_js` that needs a return value), the command includes a `request_id`. The subprocess echoes this ID in the response, and `send_command_with_response()` matches them: + +```python +cmd = {"action": "eval_js", "label": "w-abc", "script": "document.title", "request_id": "req_001"} +# stdin → subprocess executes → stdout response includes request_id +response = {"success": True, "result": "My Window", "request_id": "req_001"} +``` + +For fire-and-forget events (high-frequency streaming), `emit_event_fire()` sends the command without waiting for a response, draining stale responses to prevent queue buildup. + +## The `pywry` Bridge in Native Windows + +Native windows load `bridge.js` from `frontend/src/bridge.js` during page initialization. This creates the same `window.pywry` object as the other transports: + +| Method | Native Implementation | +|--------|----------------------| +| `pywry.emit(type, data)` | Calls `window.__TAURI__.pytauri.pyInvoke('pywry_event', {label, event_type, data})` — Tauri IPC to Rust subprocess | +| `pywry.on(type, callback)` | Stores in local `_handlers` dict | +| `pywry._trigger(type, data)` | Dispatches to local `_handlers` + wildcard handlers | +| `pywry.dispatch(type, data)` | Alias for `_trigger` | +| `pywry.result(data)` | Calls `pyInvoke('pywry_result', {data, window_label})` | + +When Python calls `handle.emit("app:update", data)`, the subprocess emits a Tauri event named `pywry:event` to the target window. The `event-bridge.js` script listens for this: + +```javascript +window.__TAURI__.event.listen('pywry:event', function(event) { + var eventType = event.payload.event_type; + var data = event.payload.data; + window.pywry._trigger(eventType, data); +}); +``` + +This triggers the same `_trigger()` dispatch as the other transports, so `pywry.on()` listeners work identically. + +## Building Components That Work Everywhere + +A reusable component uses the `BaseWidget` protocol and never calls transport-specific APIs. The same Python mixin + JavaScript event handlers work in all three environments: + +```python +from pywry.state_mixins import EmittingWidget + + +class NotificationMixin(EmittingWidget): + def notify(self, title: str, body: str, level: str = "info"): + self.emit("pywry:alert", { + "message": body, + "title": title, + "type": level, + }) + + def confirm(self, question: str, callback_event: str): + self.emit("pywry:alert", { + "message": question, + "type": "confirm", + "callback_event": callback_event, + }) +``` + +This mixin calls `self.emit()`, which resolves to: + +- **Native**: `runtime.emit_event()` → stdin JSON → Tauri event → `bridge.js` `_trigger()` +- **Anywidget**: `_py_event` traitlet → Jupyter sync → ESM `pywry._fire()` +- **IFrame**: `event_queues[widget_id].put()` → WebSocket send → `ws-bridge.js` `_fire()` + +The JavaScript toast handler is pre-registered in all three bridges, so `pywry:alert` works everywhere. + +## PyTauri and Plugins + +The native transport runs on [PyTauri](https://pytauri.github.io/pytauri/), which is distributed as a vendored wheel (`pytauri-wheel`). PyTauri provides: + +- OS-native webview windows (WKWebView on macOS, WebView2 on Windows, WebKitGTK on Linux) +- Tauri's plugin system for native capabilities +- JSON-over-stdin/stdout IPC between Python and the Rust subprocess + +### Enabling Tauri Plugins + +Tauri plugins extend native windows with OS-level capabilities — clipboard access, native dialogs, filesystem operations, notifications, HTTP client, global shortcuts, and more. Enable them via configuration: + +```python +from pywry import PyWry, PyWrySettings + +app = PyWry(settings=PyWrySettings( + tauri_plugins=["dialog", "clipboard_manager", "notification"], +)) +``` + +Once enabled, the plugin's JavaScript API is available through `window.__TAURI__` in the window: + +```javascript +// Native file dialog +const { open } = window.__TAURI__.dialog; +const path = await open({ multiple: false }); + +// Clipboard +const { writeText } = window.__TAURI__.clipboardManager; +await writeText("Copied from PyWry"); +``` + +Plugins are only available in native mode — they have no effect in anywidget or IFrame transports. Components that use plugins should check for availability: + +```javascript +if (window.__TAURI__ && window.__TAURI__.dialog) { + // Native: use OS dialog + const path = await window.__TAURI__.dialog.open(); + pywry.emit('file:selected', {path: path}); +} else { + // Notebook/browser: use HTML file input + document.getElementById('file-input').click(); +} +``` + +See the [Tauri Plugins reference](tauri-plugins.md) for the full list of 19 available plugins, capability configuration, and detailed examples. + +### Plugin Security (Capabilities) + +Tauri uses a capability system to control which APIs a window can call. PyWry grants `:default` permissions for all bundled plugins. For fine-grained control: + +```python +settings = PyWrySettings( + tauri_plugins=["shell", "fs"], + extra_capabilities=["shell:allow-execute", "fs:allow-read-file"], +) +``` + +## Native-Only Features + +The PyTauri transport provides OS-level capabilities that have no equivalent in the notebook or browser transports. These features require the PyTauri subprocess and only work when `app.show()` renders a native desktop window. + +### Native Menus + +Native application menus (File, Edit, View, Help) render in the OS menu bar on macOS and in the window title bar on Windows and Linux. Menus are built from `MenuConfig`, `MenuItemConfig`, `CheckMenuItemConfig`, and `SubmenuConfig` objects, each with a Python callback: + +```python +from pywry import PyWry, MenuConfig, MenuItemConfig, SubmenuConfig, PredefinedMenuItemConfig, PredefinedMenuItemKind + +app = PyWry() + +def on_new(data, event_type, label): + app.show("

Untitled

", title="New File") + +def on_save(data, event_type, label): + app.emit("app:save", {"path": "current.json"}, label) + +def on_quit(data, event_type, label): + app.destroy() + +menu = MenuConfig( + id="app-menu", + items=[ + SubmenuConfig(text="File", items=[ + MenuItemConfig(id="new", text="New", handler=on_new, accelerator="CmdOrCtrl+N"), + MenuItemConfig(id="save", text="Save", handler=on_save, accelerator="CmdOrCtrl+S"), + PredefinedMenuItemConfig(item=PredefinedMenuItemKind.SEPARATOR), + MenuItemConfig(id="quit", text="Quit", handler=on_quit, accelerator="CmdOrCtrl+Q"), + ]), + ], +) + +handle = app.show("

Editor

", menu=menu) +``` + +Menu items fire their `handler` callback when clicked. Keyboard accelerators (`CmdOrCtrl+S`, etc.) work globally while the window has focus. + +`CheckMenuItemConfig` creates toggle items with a checkmark state. The callback receives `{"checked": true/false}` in the event data. + +See [Native Menus](../../guides/menus.md) for the full menu system documentation. + +### System Tray + +`TrayProxy` creates an icon in the OS system tray (notification area on Windows, menu bar on macOS). The tray icon can show a tooltip, a context menu, and respond to click events: + +```python +from pywry import TrayProxy, MenuConfig, MenuItemConfig + +def on_show(data, event_type, label): + handle.show_window() + +def on_quit(data, event_type, label): + app.destroy() + +tray = TrayProxy.create( + tray_id="my-tray", + tooltip="My App", + menu=MenuConfig( + id="tray-menu", + items=[ + MenuItemConfig(id="show", text="Show Window", handler=on_show), + MenuItemConfig(id="quit", text="Quit", handler=on_quit), + ], + ), +) +``` + +The tray icon persists even when all windows are hidden, making it useful for background applications that need to remain accessible. + +See [System Tray](../../guides/tray.md) for the full tray API. + +### Window Control + +`NativeWindowHandle` provides direct control over the OS window through the `WindowProxy` API. These operations have no equivalent in notebook or browser environments: + +```python +handle = app.show("

Dashboard

", title="My App") + +handle.set_title("Updated Title") +handle.set_size(1200, 800) +handle.center() +handle.maximize() +handle.minimize() +handle.set_focus() + +handle.hide() +handle.show_window() +handle.close() +``` + +The full `WindowProxy` (accessed via `handle.proxy`) exposes every Tauri `WebviewWindow` method: + +| Category | Methods | +|----------|---------| +| **State** | `is_maximized`, `is_minimized`, `is_fullscreen`, `is_focused`, `is_visible`, `is_decorated` | +| **Actions** | `maximize()`, `unmaximize()`, `minimize()`, `unminimize()`, `set_fullscreen()`, `center()` | +| **Size** | `set_size()`, `set_min_size()`, `set_max_size()`, `inner_size`, `outer_size` | +| **Position** | `set_position()`, `inner_position`, `outer_position` | +| **Appearance** | `set_title()`, `set_decorations()`, `set_background_color()`, `set_always_on_top()`, `set_content_protected()` | +| **Webview** | `eval_js()`, `navigate()`, `reload()`, `open_devtools()`, `close_devtools()`, `set_zoom()`, `zoom` | + +### JavaScript Execution + +`eval_js()` runs arbitrary JavaScript in the window's webview. This is useful for DOM queries, dynamic updates, and debugging: + +```python +handle.eval_js("document.getElementById('counter').textContent = '42'") +handle.eval_js("document.title = 'Updated from Python'") +``` + +### Multi-Window Communication + +In native mode, each `app.show()` call creates an independent OS window with its own label. Python code can target events to specific windows using the `label` parameter on `app.emit()`: + +```python +chart_handle = app.show(chart_html, title="Chart") +table_handle = app.show(table_html, title="Data") + +def on_row_selected(data, event_type, label): + selected = data["rows"] + filtered_fig = build_chart(selected) + app.emit("plotly:update-figure", {"figure": filtered_fig}, chart_handle.label) + +table_handle.on("grid:row-selected", on_row_selected) +``` + +Window events are routed by label — each window receives only the events targeted at it. The callback registry maps `(label, event_type)` pairs to callbacks, so the same event name can have different handlers in different windows. + +### Window Modes + +PyWry offers three strategies for managing native windows: + +| Mode | Behavior | +|------|----------| +| `SingleWindowMode` | One window at a time. Calling `show()` again replaces the content in the existing window. | +| `NewWindowMode` | Each `show()` creates a new window. Multiple windows can be open simultaneously. | +| `MultiWindowMode` | Like `NewWindowMode` but with coordinated lifecycle — closing the primary window closes all secondary windows. | + +```python +from pywry import PyWry +from pywry.window_manager import NewWindowMode + +app = PyWry(mode=NewWindowMode()) + +h1 = app.show("

Window 1

", title="First") +h2 = app.show("

Window 2

", title="Second") +``` + +See [Window Modes](../../guides/window-management.md) for details on each mode. + +### Hot Reload + +In native mode, PyWry can watch CSS and JavaScript files for changes and push updates to the window without a full page reload: + +```python +from pywry import PyWry, HtmlContent + +app = PyWry(hot_reload=True) + +content = HtmlContent( + html="

Dashboard

", + css_files=["styles/dashboard.css"], + script_files=["scripts/chart.js"], +) + +handle = app.show(content) +``` + +When `dashboard.css` changes on disk, PyWry injects the updated CSS via `pywry:inject-css` without reloading the page. Script file changes trigger a full page refresh with scroll position preservation. + +See [Hot Reload](../../guides/hot-reload.md) for configuration details. + +## Transport Comparison + +| Aspect | Native Window | Anywidget | IFrame+WebSocket | +|--------|---------------|-----------|------------------| +| `pywry.emit()` | Tauri IPC `pyInvoke` | Traitlet `_js_event` | WebSocket send | +| `pywry.on()` | Local handler dict | Local handler dict | Local handler dict | +| Python `emit()` | stdin JSON → Tauri event | Traitlet `_py_event` | Async queue → WS | +| Python `on()` | Callback registry | Traitlet observer | Callback dict | +| Asset loading | Bundled in page HTML | Bundled in `_esm` | HTTP ` """ @@ -1430,9 +886,7 @@ async def websocket_endpoint( # pylint: disable=too-many-branches,too-many-stat token = None accepted_subprotocol = None if server_settings.websocket_require_token: - # Check for token in Sec-WebSocket-Protocol header - # Client sends: new WebSocket(url, ['pywry.token.XXX']) - # Server receives in sec-websocket-protocol header + # Token is sent via Sec-WebSocket-Protocol header sec_websocket_protocol = websocket.headers.get("sec-websocket-protocol", "") if sec_websocket_protocol.startswith("pywry.token."): token = sec_websocket_protocol.replace("pywry.token.", "", 1) @@ -2544,7 +1998,7 @@ def update_figure( else: final_config = {} - # NOTE: For InlineWidget, we send an update event for the partial plot update. + # Send an update event for the partial plot update. # This keeps the rest of the page (including toolbar) intact. # If we wanted to replace the toolbar, we'd need to reload the whole HTML. # For full replacement, see update_html. @@ -2979,7 +2433,7 @@ def show( # pylint: disable=too-many-arguments,too-many-branches,too-many-state # Generate widget token FIRST - this will be stored with the widget widget_token = _generate_widget_token(widget_id) - # Build full HTML - bridge MUST be in head so window.pywry exists before user scripts run + # Bridge goes in head so window.pywry exists before user scripts run # Note: wrap_content_with_toolbars already wraps content in pywry-content div html = f""" @@ -3223,7 +2677,7 @@ def generate_plotly_html( delete plotlyConfig.templateDark; delete plotlyConfig.templateLight; - // Extract single legacy template from layout + // Extract single template from layout let userTemplate = null; const templates = window.PYWRY_PLOTLY_TEMPLATES || {{}}; const themeTemplate = '{"plotly_dark" if theme == "dark" else "plotly_white"}'; diff --git a/pywry/pywry/mcp/builders.py b/pywry/pywry/mcp/builders.py index 32316f4..609cca6 100644 --- a/pywry/pywry/mcp/builders.py +++ b/pywry/pywry/mcp/builders.py @@ -358,9 +358,8 @@ def build_chat_config(cfg: dict[str, Any]) -> Any: ChatConfig Built chat configuration. """ - from pywry.chat import ChatConfig, SlashCommand + from pywry.chat import ChatConfig - cmds_data = cfg.get("slash_commands") kwargs: dict[str, Any] = { "system_prompt": cfg.get("system_prompt", ""), "model": cfg.get("model", "gpt-4"), @@ -368,16 +367,7 @@ def build_chat_config(cfg: dict[str, Any]) -> Any: "max_tokens": cfg.get("max_tokens", 4096), "streaming": cfg.get("streaming", True), "persist": cfg.get("persist", False), - "provider": cfg.get("provider"), } - if cmds_data: - kwargs["slash_commands"] = [ - SlashCommand( - name=c["name"], - description=c.get("description", ""), - ) - for c in cmds_data - ] return ChatConfig(**kwargs) diff --git a/pywry/pywry/mcp/handlers.py b/pywry/pywry/mcp/handlers.py index f8d5225..f4ca101 100644 --- a/pywry/pywry/mcp/handlers.py +++ b/pywry/pywry/mcp/handlers.py @@ -691,7 +691,7 @@ def _handle_list_resources(_ctx: HandlerContext) -> HandlerResult: def _handle_create_chat_widget(ctx: HandlerContext) -> HandlerResult: - from ..chat import ChatThread, _default_slash_commands, build_chat_html + from ..chat import ChatThread, build_chat_html from .builders import build_chat_widget_config, build_toolbars as _build_toolbars app = get_app() @@ -729,15 +729,11 @@ def _handle_create_chat_widget(ctx: HandlerContext) -> HandlerResult: _chat_thread_store.setdefault(widget_id, {})[thread_id] = default_thread _chat_message_store.setdefault(widget_id, {})[thread_id] = [] - # Register default slash commands - for cmd in _default_slash_commands(): - widget.emit( - "chat:register-command", - { - "name": cmd.name, - "description": cmd.description, - }, - ) + # Register default slash command + widget.emit( + "chat:register-command", + {"name": "/clear", "description": "Clear the conversation"}, + ) # Register custom slash commands if widget_config.chat_config.slash_commands: diff --git a/pywry/pywry/mcp/skills/__init__.py b/pywry/pywry/mcp/skills/__init__.py index 1accf8a..89140d6 100644 --- a/pywry/pywry/mcp/skills/__init__.py +++ b/pywry/pywry/mcp/skills/__init__.py @@ -140,7 +140,7 @@ def list_skills() -> list[dict[str, str]]: def get_all_skills() -> dict[str, dict[str, str]]: - """Get all skills with full guidance (for backward compatibility). + """Get all skills with full guidance. Returns ------- diff --git a/pywry/pywry/mcp/skills/chat/SKILL.md b/pywry/pywry/mcp/skills/chat/SKILL.md index f3e822b..422169c 100644 --- a/pywry/pywry/mcp/skills/chat/SKILL.md +++ b/pywry/pywry/mcp/skills/chat/SKILL.md @@ -171,11 +171,10 @@ Requires `anthropic` package and `ANTHROPIC_API_KEY` environment variable. ### Custom Callback ```python -from pywry.chat_providers import CallbackProvider +from pywry.chat.providers.callback import CallbackProvider provider = CallbackProvider( - generate_fn=my_generate, # (messages, config) → str | ChatMessage - stream_fn=my_stream, # (messages, config, cancel_event) → AsyncIterator[str] + prompt_fn=my_prompt, # (session_id, content_blocks, cancel_event) → AsyncIterator[SessionUpdate] ) ``` diff --git a/pywry/pywry/scripts.py b/pywry/pywry/scripts.py index 49de749..958141d 100644 --- a/pywry/pywry/scripts.py +++ b/pywry/pywry/scripts.py @@ -1,9 +1,12 @@ -"""JavaScript bridge scripts for PyWry.""" +"""JavaScript bridge scripts for PyWry. -# pylint: disable=C0302 +All JavaScript is loaded from dedicated files in ``frontend/src/``. +No inline JS is defined in this module. +""" from __future__ import annotations +from functools import lru_cache from pathlib import Path from .assets import get_toast_notifications_js @@ -12,1218 +15,71 @@ _SRC_DIR = Path(__file__).parent / "frontend" / "src" -def _get_tooltip_manager_js() -> str: - """Load the tooltip manager JavaScript from the single source file.""" - tooltip_file = _SRC_DIR / "tooltip-manager.js" - if tooltip_file.exists(): - return tooltip_file.read_text(encoding="utf-8") - return "" - - -PYWRY_BRIDGE_JS = """ -(function() { - 'use strict'; - - // Create or extend window.pywry - DO NOT replace to preserve existing handlers - if (!window.pywry) { - window.pywry = { - theme: 'dark', - _handlers: {} - }; - } - - // Ensure _handlers exists - if (!window.pywry._handlers) { - window.pywry._handlers = {}; - } - - // Add/update methods on existing object (preserves registered handlers) - window.pywry.result = function(data) { - const payload = { - data: data, - window_label: window.__PYWRY_LABEL__ || 'unknown' - }; - if (window.__TAURI__ && window.__TAURI__.pytauri && window.__TAURI__.pytauri.pyInvoke) { - window.__TAURI__.pytauri.pyInvoke('pywry_result', payload); - } - }; - - window.pywry.openFile = function(path) { - if (window.__TAURI__ && window.__TAURI__.pytauri && window.__TAURI__.pytauri.pyInvoke) { - window.__TAURI__.pytauri.pyInvoke('open_file', { path: path }); - } - }; - - window.pywry.devtools = function() { - if (window.__TAURI__ && window.__TAURI__.webview) { - console.log('DevTools requested'); - } - }; - - window.pywry.emit = function(eventType, data) { - // Validate event type format (matches Python pattern in models.py) - // Pattern: namespace:event-name with optional :suffix - // Allows: letters, numbers, underscores, hyphens (case-insensitive) - if (eventType !== '*' && !/^[a-zA-Z][a-zA-Z0-9]*:[a-zA-Z][a-zA-Z0-9_-]*(:[a-zA-Z0-9_-]+)?$/.test(eventType)) { - console.error('Invalid event type:', eventType, 'Must match namespace:event-name pattern'); - return; - } - - // Intercept modal events and handle them locally (client-side) - if (eventType && eventType.startsWith('modal:')) { - var parts = eventType.split(':'); - if (parts.length >= 3 && window.pywry && window.pywry.modal) { - var action = parts[1]; - var modalId = parts.slice(2).join(':'); - if (action === 'open') { - window.pywry.modal.open(modalId); - return; - } else if (action === 'close') { - window.pywry.modal.close(modalId); - return; - } else if (action === 'toggle') { - window.pywry.modal.toggle(modalId); - return; - } - } - } - - const payload = { - label: window.__PYWRY_LABEL__ || 'main', - event_type: eventType, - data: data || {} - }; - if (window.__TAURI__ && window.__TAURI__.pytauri && window.__TAURI__.pytauri.pyInvoke) { - window.__TAURI__.pytauri.pyInvoke('pywry_event', payload); - } - // Also dispatch locally so JS-side listeners fire immediately - this._trigger(eventType, data || {}); - }; - - window.pywry.on = function(eventType, callback) { - if (!this._handlers[eventType]) { - this._handlers[eventType] = []; - } - this._handlers[eventType].push(callback); - }; - - window.pywry.off = function(eventType, callback) { - if (!this._handlers[eventType]) return; - if (!callback) { - delete this._handlers[eventType]; - } else { - this._handlers[eventType] = this._handlers[eventType].filter( - function(h) { return h !== callback; } - ); - } - }; - - window.pywry._trigger = function(eventType, data) { - // Don't log data for secret-related events - var isSensitive = eventType.indexOf(':reveal') !== -1 || - eventType.indexOf(':copy') !== -1 || - eventType.indexOf('secret') !== -1 || - eventType.indexOf('password') !== -1 || - eventType.indexOf('api-key') !== -1 || - eventType.indexOf('token') !== -1; - if (window.PYWRY_DEBUG && !isSensitive) { - console.log('[PyWry] _trigger called:', eventType, data); - } else if (window.PYWRY_DEBUG) { - console.log('[PyWry] _trigger called:', eventType, '[REDACTED]'); - } - var handlers = this._handlers[eventType] || []; - var wildcardHandlers = this._handlers['*'] || []; - handlers.concat(wildcardHandlers).forEach(function(handler) { - try { - handler(data, eventType); - } catch (e) { - console.error('Error in event handler:', e); - } - }); - }; - - window.pywry.dispatch = function(eventType, data) { - // Don't log data for secret-related events - var isSensitive = eventType.indexOf(':reveal') !== -1 || - eventType.indexOf(':copy') !== -1 || - eventType.indexOf('secret') !== -1 || - eventType.indexOf('password') !== -1 || - eventType.indexOf('api-key') !== -1 || - eventType.indexOf('token') !== -1; - if (window.PYWRY_DEBUG && !isSensitive) { - console.log('[PyWry] dispatch called:', eventType, data); - } else if (window.PYWRY_DEBUG) { - console.log('[PyWry] dispatch called:', eventType, '[REDACTED]'); - } - this._trigger(eventType, data); - }; - - console.log('PyWry bridge initialized/updated'); -})(); -""" - -# System event handlers for built-in pywry events -# These are ALWAYS included, not just during hot reload -PYWRY_SYSTEM_EVENTS_JS = """ -(function() { - 'use strict'; - - // Guard against re-registration of system event handlers - if (window.pywry && window.pywry._systemEventsRegistered) { - console.log('[PyWry] System events already registered, skipping'); - return; - } - - // Helper function to inject or update CSS - window.pywry.injectCSS = function(css, id) { - var style = document.getElementById(id); - if (style) { - style.textContent = css; - } else { - style = document.createElement('style'); - style.id = id; - style.textContent = css; - document.head.appendChild(style); - } - console.log('[PyWry] Injected CSS with id:', id); - }; - - // Helper function to remove CSS by id - window.pywry.removeCSS = function(id) { - var style = document.getElementById(id); - if (style) { - style.remove(); - console.log('[PyWry] Removed CSS with id:', id); - } - }; - - // Helper function to set element styles - window.pywry.setStyle = function(data) { - var styles = data.styles; - if (!styles) return; - var elements = []; - if (data.id) { - var el = document.getElementById(data.id); - if (el) elements.push(el); - } else if (data.selector) { - elements = Array.from(document.querySelectorAll(data.selector)); - } - elements.forEach(function(el) { - Object.keys(styles).forEach(function(prop) { - el.style[prop] = styles[prop]; - }); - }); - console.log('[PyWry] Set styles on', elements.length, 'elements:', styles); - }; - - // Helper function to set element content - window.pywry.setContent = function(data) { - var elements = []; - if (data.id) { - var el = document.getElementById(data.id); - if (el) elements.push(el); - } else if (data.selector) { - elements = Array.from(document.querySelectorAll(data.selector)); - } - elements.forEach(function(el) { - if ('html' in data) { - el.innerHTML = data.html; - } else if ('text' in data) { - el.textContent = data.text; - } - }); - console.log('[PyWry] Set content on', elements.length, 'elements'); - }; - - // Register built-in pywry.on handlers for system events - // These are triggered via pywry.dispatch() when Python calls widget.emit() - window.pywry.on('pywry:inject-css', function(data) { - window.pywry.injectCSS(data.css, data.id); - }); - - window.pywry.on('pywry:remove-css', function(data) { - window.pywry.removeCSS(data.id); - }); - - window.pywry.on('pywry:set-style', function(data) { - window.pywry.setStyle(data); - }); - - window.pywry.on('pywry:set-content', function(data) { - window.pywry.setContent(data); - }); - - window.pywry.on('pywry:refresh', function() { - if (window.pywry.refresh) { - window.pywry.refresh(); - } else { - window.location.reload(); - } - }); - - // Handler for file downloads - uses Tauri save dialog in native mode - window.pywry.on('pywry:download', function(data) { - if (!data.content || !data.filename) { - console.error('[PyWry] Download requires content and filename'); - return; - } - // Use Tauri's native save dialog if available - if (window.__TAURI__ && window.__TAURI__.dialog && window.__TAURI__.fs) { - window.__TAURI__.dialog.save({ - defaultPath: data.filename, - title: 'Save File' - }).then(function(filePath) { - if (filePath) { - // Write the file using Tauri's filesystem API - window.__TAURI__.fs.writeTextFile(filePath, data.content).then(function() { - console.log('[PyWry] Saved to:', filePath); - }).catch(function(err) { - console.error('[PyWry] Failed to save file:', err); - }); - } else { - console.log('[PyWry] Save cancelled by user'); - } - }).catch(function(err) { - console.error('[PyWry] Save dialog error:', err); - }); - } else { - // Fallback for browser/iframe mode - var mimeType = data.mimeType || 'application/octet-stream'; - var blob = new Blob([data.content], { type: mimeType }); - var url = URL.createObjectURL(blob); - var a = document.createElement('a'); - a.href = url; - a.download = data.filename; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); - console.log('[PyWry] Downloaded:', data.filename); - } - }); - - // Handler for navigation - window.pywry.on('pywry:navigate', function(data) { - if (data.url) { - window.location.href = data.url; - } - }); - - // Handler for alert dialogs - uses PYWRY_TOAST for typed notifications - window.pywry.on('pywry:alert', function(data) { - var message = data.message || data.text || ''; - var type = data.type || 'info'; - - // Use toast system if available - if (window.PYWRY_TOAST) { - if (type === 'confirm') { - window.PYWRY_TOAST.confirm({ - message: message, - title: data.title, - position: data.position, - onConfirm: function() { - if (data.callback_event) { - window.pywry.emit(data.callback_event, { confirmed: true }); - } - }, - onCancel: function() { - if (data.callback_event) { - window.pywry.emit(data.callback_event, { confirmed: false }); - } - } - }); - } else { - window.PYWRY_TOAST.show({ - message: message, - title: data.title, - type: type, - duration: data.duration, - position: data.position - }); - } - } else { - // Fallback to browser alert - alert(message); - } - }); - - // Handler for replacing HTML content - window.pywry.on('pywry:update-html', function(data) { - if (data.html) { - var app = document.getElementById('app'); - if (app) { - app.innerHTML = data.html; - } else { - document.body.innerHTML = data.html; - } - } - }); - - // Register Tauri event listeners that use the shared helper functions - if (window.__TAURI__ && window.__TAURI__.event) { - window.__TAURI__.event.listen('pywry:inject-css', function(event) { - window.pywry.injectCSS(event.payload.css, event.payload.id); - }); - - window.__TAURI__.event.listen('pywry:remove-css', function(event) { - window.pywry.removeCSS(event.payload.id); - }); - - window.__TAURI__.event.listen('pywry:set-style', function(event) { - window.pywry.setStyle(event.payload); - }); - - window.__TAURI__.event.listen('pywry:set-content', function(event) { - window.pywry.setContent(event.payload); - }); - - window.__TAURI__.event.listen('pywry:refresh', function() { - if (window.pywry.refresh) { - window.pywry.refresh(); - } else { - window.location.reload(); - } - }); - - window.__TAURI__.event.listen('pywry:download', function(event) { - var data = event.payload; - if (!data.content || !data.filename) { - console.error('[PyWry] Download requires content and filename'); - return; - } - // Use Tauri's native save dialog - window.__TAURI__.dialog.save({ - defaultPath: data.filename, - title: 'Save File' - }).then(function(filePath) { - if (filePath) { - window.__TAURI__.fs.writeTextFile(filePath, data.content).then(function() { - console.log('[PyWry] Saved to:', filePath); - }).catch(function(err) { - console.error('[PyWry] Failed to save file:', err); - }); - } else { - console.log('[PyWry] Save cancelled by user'); - } - }).catch(function(err) { - console.error('[PyWry] Save dialog error:', err); - }); - }); - - window.__TAURI__.event.listen('pywry:navigate', function(event) { - if (event.payload.url) { - window.location.href = event.payload.url; - } - }); - - // pywry:alert is handled by window.pywry.on() - no need for duplicate Tauri listener - // The Tauri event fires window.pywry._fire() which triggers the pywry.on handler - - window.__TAURI__.event.listen('pywry:update-html', function(event) { - if (event.payload.html) { - var app = document.getElementById('app'); - if (app) { - app.innerHTML = event.payload.html; - } else { - document.body.innerHTML = event.payload.html; - } - } - }); - } - - // Mark system events as registered to prevent duplicate handlers - window.pywry._systemEventsRegistered = true; - console.log('PyWry system events initialized'); -})(); -""" - -# TOOLTIP_MANAGER_JS is now loaded from frontend/src/tooltip-manager.js -# via _get_tooltip_manager_js() to avoid duplication - -THEME_MANAGER_JS = """ -(function() { - 'use strict'; - - if (window.__TAURI__ && window.__TAURI__.event) { - window.__TAURI__.event.listen('pywry:theme-update', function(event) { - var mode = event.payload.mode; - updateTheme(mode); - }); - } - - if (window.matchMedia) { - window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', function(e) { - var html = document.documentElement; - if (html.dataset.themeMode === 'system') { - updateTheme('system'); - } - }); - } - - function updateTheme(mode) { - var html = document.documentElement; - var resolvedMode = mode; - - html.dataset.themeMode = mode; - - if (mode === 'system') { - var prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; - resolvedMode = prefersDark ? 'dark' : 'light'; - } - - html.classList.remove('light', 'dark'); - html.classList.add(resolvedMode); - window.pywry.theme = resolvedMode; - - var isDark = resolvedMode === 'dark'; - - if (window.Plotly && window.__PYWRY_PLOTLY_DIV__) { - Plotly.relayout(window.__PYWRY_PLOTLY_DIV__, { - template: isDark ? 'plotly_dark' : 'plotly_white' - }); - } - - var gridDiv = document.querySelector('[class*="ag-theme-"]'); - if (gridDiv) { - var classList = Array.from(gridDiv.classList); - classList.forEach(function(cls) { - if (cls.startsWith('ag-theme-')) { - var baseTheme = cls.replace('-dark', ''); - gridDiv.classList.remove(cls); - gridDiv.classList.add(isDark ? baseTheme + '-dark' : baseTheme); - } - }); - } - - window.pywry._trigger('pywry:theme-update', { mode: resolvedMode, original: mode }); - } - - // Register handler for pywry:update-theme events IMMEDIATELY (not in DOMContentLoaded) - // because content is injected via JavaScript after the page loads - console.log('[PyWry] Registering pywry:update-theme handler'); - window.pywry.on('pywry:update-theme', function(data) { - console.log('[PyWry] pywry:update-theme handler called with:', data); - var theme = data.theme || 'plotly_dark'; - var isDark = theme.includes('dark'); - var mode = isDark ? 'dark' : 'light'; - updateTheme(mode); - - // Also update Plotly with merged template (theme base + user overrides) - // relayout avoids carrying stale colours from the old layout. - if (window.Plotly && window.__PYWRY_PLOTLY_DIV__) { - var plotDiv = window.__PYWRY_PLOTLY_DIV__; - var templateName = isDark ? 'plotly_dark' : 'plotly_white'; - if (window.__pywryMergeThemeTemplate) { - var merged = window.__pywryMergeThemeTemplate(plotDiv, templateName); - if (window.__pywryStripThemeColors) window.__pywryStripThemeColors(plotDiv); - window.Plotly.relayout(plotDiv, { template: merged }); - } - } - - // Update AG Grid theme if present - if (data.theme && data.theme.startsWith('ag-theme-')) { - var gridDiv = document.querySelector('[class*="ag-theme-"]'); - if (gridDiv) { - var classList = Array.from(gridDiv.classList); - classList.forEach(function(cls) { - if (cls.startsWith('ag-theme-')) { - gridDiv.classList.remove(cls); - } - }); - gridDiv.classList.add(data.theme); - } - } - }); - - // Initialize theme on DOMContentLoaded (for initial page load) - document.addEventListener('DOMContentLoaded', function() { - var html = document.documentElement; - var currentTheme = html.classList.contains('dark') ? 'dark' : 'light'; - window.pywry.theme = currentTheme; - }); -})(); -""" - -EVENT_BRIDGE_JS = """ -(function() { - 'use strict'; - - // Listen for all pywry:* events from Python - if (window.__TAURI__ && window.__TAURI__.event) { - window.__TAURI__.event.listen('pywry:event', function(event) { - var eventType = event.payload.event_type; - var data = event.payload.data; - window.pywry._trigger(eventType, data); - }); - } - - console.log('Event bridge initialized'); -})(); -""" - -TOOLBAR_BRIDGE_JS = """ -(function() { - 'use strict'; - - function getToolbarState(toolbarId) { - var state = { toolbars: {}, components: {}, timestamp: Date.now() }; - - var toolbars = toolbarId - ? [document.getElementById(toolbarId)] - : document.querySelectorAll('.pywry-toolbar'); - - toolbars.forEach(function(toolbar) { - if (!toolbar) return; - var tbId = toolbar.id; - if (!tbId) return; - - state.toolbars[tbId] = { - position: Array.from(toolbar.classList) - .find(function(c) { return c.startsWith('pywry-toolbar-'); }) - ?.replace('pywry-toolbar-', '') || 'top', - components: [] - }; - - toolbar.querySelectorAll('[id]').forEach(function(el) { - var id = el.id; - var value = null; - var type = null; - - if (el.tagName === 'BUTTON') { - type = 'button'; - value = { disabled: el.disabled }; - } else if (el.tagName === 'SELECT') { - type = 'select'; - value = el.value; - } else if (el.tagName === 'INPUT') { - var inputType = el.type; - if (inputType === 'checkbox') { - return; - } else if (inputType === 'range') { - type = 'range'; - value = parseFloat(el.value); - } else if (inputType === 'number') { - type = 'number'; - value = parseFloat(el.value) || 0; - } else if (inputType === 'date') { - type = 'date'; - value = el.value; - } else if (el.classList.contains('pywry-input-secret')) { - // SECURITY: Never expose secret values via state - // Return has_value indicator instead - type = 'secret'; - value = { has_value: el.dataset.hasValue === 'true' }; - } else { - type = 'text'; - value = el.value; - } - } else if (el.classList.contains('pywry-multiselect')) { - type = 'multiselect'; - value = Array.from(el.querySelectorAll('input:checked')) - .map(function(i) { return i.value; }); - } else if (el.classList.contains('pywry-dropdown')) { - type = 'select'; - var selectedOpt = el.querySelector('.pywry-dropdown-option.pywry-selected'); - value = selectedOpt ? selectedOpt.getAttribute('data-value') : null; - } - - if (type) { - state.components[id] = { type: type, value: value }; - state.toolbars[tbId].components.push(id); - } - }); - }); - - return state; - } - - function getComponentValue(componentId) { - var el = document.getElementById(componentId); - if (!el) return null; - - if (el.tagName === 'SELECT') { - return el.value; - } else if (el.tagName === 'INPUT') { - var inputType = el.type; - // SECURITY: Never expose secret values via state getter - if (el.classList.contains('pywry-input-secret')) { - return { has_value: el.dataset.hasValue === 'true' }; - } - if (inputType === 'range' || inputType === 'number') { - return parseFloat(el.value); - } - return el.value; - } else if (el.classList.contains('pywry-multiselect')) { - return Array.from(el.querySelectorAll('input:checked')) - .map(function(i) { return i.value; }); - } else if (el.classList.contains('pywry-dropdown')) { - var selectedOpt = el.querySelector('.pywry-dropdown-option.pywry-selected'); - return selectedOpt ? selectedOpt.getAttribute('data-value') : null; - } - return null; - } - - function setComponentValue(componentId, value, attrs) { - var el = document.getElementById(componentId); - if (!el) return false; - - // SECURITY: Prevent setting secret values via state setter - // Secrets must be set via their event handler (with proper encoding) - if (el.classList && el.classList.contains('pywry-input-secret')) { - console.warn('[PyWry] Cannot set SecretInput value via toolbar:set-value. Use the event handler instead.'); - return false; - } - - // Generic attribute setter - handles any attribute for any component - // Accepts attrs object with attribute name: value pairs - if (attrs && typeof attrs === 'object') { - Object.keys(attrs).forEach(function(attrName) { - var attrValue = attrs[attrName]; - - // Skip componentId, toolbarId, value (handled separately), options (handled separately) - if (attrName === 'componentId' || attrName === 'toolbarId') return; - - // Handle specific attribute types - switch (attrName) { - case 'label': - case 'text': - // Update text content - find text element or use el directly - if (el.classList.contains('pywry-toolbar-button') || el.tagName === 'BUTTON') { - el.textContent = attrValue; - } else if (el.classList.contains('pywry-dropdown')) { - var textEl = el.querySelector('.pywry-dropdown-text'); - if (textEl) textEl.textContent = attrValue; - } else if (el.classList.contains('pywry-checkbox') || el.classList.contains('pywry-toggle')) { - var labelEl = el.querySelector('.pywry-checkbox-label, .pywry-input-label'); - if (labelEl) labelEl.textContent = attrValue; - } else if (el.classList.contains('pywry-tab-group')) { - // For tab groups, label refers to the group label - var groupLabel = el.closest('.pywry-input-group'); - if (groupLabel) { - var lbl = groupLabel.querySelector('.pywry-input-label'); - if (lbl) lbl.textContent = attrValue; - } - } else { - // Generic fallback - try to find label span or set text directly - var label = el.querySelector('.pywry-input-label'); - if (label) { - label.textContent = attrValue; - } else if (el.textContent !== undefined) { - el.textContent = attrValue; - } - } - break; - - case 'html': - case 'innerHTML': - // Update HTML content - if (el.classList.contains('pywry-toolbar-button') || el.tagName === 'BUTTON') { - el.innerHTML = attrValue; - } else if (el.classList.contains('pywry-dropdown')) { - var textEl = el.querySelector('.pywry-dropdown-text'); - if (textEl) textEl.innerHTML = attrValue; - } else { - el.innerHTML = attrValue; - } - break; - - case 'disabled': - // Toggle disabled state - if (attrValue) { - el.setAttribute('disabled', 'disabled'); - el.classList.add('pywry-disabled'); - // Also disable any inputs inside - el.querySelectorAll('input, button, select, textarea').forEach(function(inp) { - inp.setAttribute('disabled', 'disabled'); - }); - } else { - el.removeAttribute('disabled'); - el.classList.remove('pywry-disabled'); - el.querySelectorAll('input, button, select, textarea').forEach(function(inp) { - inp.removeAttribute('disabled'); - }); - } - break; - - case 'variant': - // Swap variant class for buttons - if (el.classList.contains('pywry-toolbar-button') || el.tagName === 'BUTTON') { - // Remove existing variant classes - var variants = ['primary', 'secondary', 'neutral', 'ghost', 'outline', 'danger', 'warning', 'icon']; - variants.forEach(function(v) { - el.classList.remove('pywry-btn-' + v); - }); - // Add new variant (if not primary, which is default with no class) - if (attrValue && attrValue !== 'primary') { - el.classList.add('pywry-btn-' + attrValue); - } - } - break; - - case 'size': - // Swap size class for buttons/tabs - if (el.classList.contains('pywry-toolbar-button') || el.tagName === 'BUTTON' || el.classList.contains('pywry-tab-group')) { - var sizes = ['xs', 'sm', 'lg', 'xl']; - sizes.forEach(function(s) { - el.classList.remove('pywry-btn-' + s); - el.classList.remove('pywry-tab-' + s); - }); - if (attrValue) { - if (el.classList.contains('pywry-tab-group')) { - el.classList.add('pywry-tab-' + attrValue); - } else { - el.classList.add('pywry-btn-' + attrValue); - } - } - } - break; - - case 'description': - case 'tooltip': - // Update data-tooltip attribute - if (attrValue) { - el.setAttribute('data-tooltip', attrValue); - } else { - el.removeAttribute('data-tooltip'); - } - break; - - case 'data': - // Update data-data attribute (JSON payload for buttons) - if (attrValue) { - el.setAttribute('data-data', JSON.stringify(attrValue)); - } else { - el.removeAttribute('data-data'); - } - break; - - case 'event': - // Update data-event attribute - el.setAttribute('data-event', attrValue); - break; - - case 'style': - // Update inline styles - can be string or object - if (typeof attrValue === 'string') { - el.style.cssText = attrValue; - } else if (typeof attrValue === 'object') { - Object.keys(attrValue).forEach(function(prop) { - el.style[prop] = attrValue[prop]; - }); - } - break; - - case 'className': - case 'class': - // Add/remove CSS classes - if (typeof attrValue === 'string') { - attrValue.split(' ').forEach(function(cls) { - if (cls) el.classList.add(cls); - }); - } else if (typeof attrValue === 'object') { - // Object format: {add: ['cls1'], remove: ['cls2']} - if (attrValue.add) { - (Array.isArray(attrValue.add) ? attrValue.add : [attrValue.add]).forEach(function(cls) { - if (cls) el.classList.add(cls); - }); - } - if (attrValue.remove) { - (Array.isArray(attrValue.remove) ? attrValue.remove : [attrValue.remove]).forEach(function(cls) { - if (cls) el.classList.remove(cls); - }); - } - } - break; - - case 'checked': - // Toggle checked state for checkboxes/toggles - var checkbox = el.querySelector('input[type="checkbox"]') || (el.type === 'checkbox' ? el : null); - if (checkbox) { - checkbox.checked = !!attrValue; - // Update visual state - if (attrValue) { - el.classList.add('pywry-toggle-checked'); - } else { - el.classList.remove('pywry-toggle-checked'); - } - } - break; - - case 'selected': - // Update selected value for radio groups, tab groups - if (el.classList.contains('pywry-radio-group')) { - el.querySelectorAll('input[type="radio"]').forEach(function(radio) { - radio.checked = radio.value === attrValue; - }); - } else if (el.classList.contains('pywry-tab-group')) { - el.querySelectorAll('.pywry-tab').forEach(function(tab) { - if (tab.dataset.value === attrValue) { - tab.classList.add('pywry-tab-active'); - } else { - tab.classList.remove('pywry-tab-active'); - } - }); - } - break; - - case 'placeholder': - // Update placeholder for inputs - var input = el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' ? el : el.querySelector('input, textarea'); - if (input) { - input.setAttribute('placeholder', attrValue); - } - break; - - case 'min': - case 'max': - case 'step': - // Update constraints for number/range inputs - var numInput = el.tagName === 'INPUT' ? el : el.querySelector('input[type="number"], input[type="range"]'); - if (numInput) { - numInput.setAttribute(attrName, attrValue); - } - break; - - case 'options': - // Handled separately below for dropdowns - break; +def _load_js(filename: str) -> str: + """Load a JavaScript file from the frontend/src/ directory. - case 'value': - // Handled separately below - break; - - default: - // Generic attribute setter - set as data attribute or HTML attribute - if (attrName.startsWith('data-')) { - el.setAttribute(attrName, attrValue); - } else { - // Try to set as property first, then as attribute - try { - if (attrName in el) { - el[attrName] = attrValue; - } else { - el.setAttribute(attrName, attrValue); - } - } catch (e) { - el.setAttribute(attrName, attrValue); - } - } - } - }); - } - - // Handle value and options (backward compatible behavior) - var options = attrs && attrs.options; - if (value === undefined && attrs && attrs.value !== undefined) { - value = attrs.value; - } - - if (el.tagName === 'SELECT' || el.tagName === 'INPUT') { - if (value !== undefined) el.value = value; - return true; - } else if (el.classList.contains('pywry-dropdown')) { - if (options && Array.isArray(options)) { - var menu = el.querySelector('.pywry-dropdown-menu'); - if (menu) { - menu.innerHTML = options.map(function(opt) { - var isSelected = String(opt.value) === String(value); - return '
' + opt.label + '
'; - }).join(''); - } - } - if (value !== undefined) { - var textEl = el.querySelector('.pywry-dropdown-text'); - if (textEl) { - var optionEl = el.querySelector('.pywry-dropdown-option[data-value="' + value + '"]'); - if (optionEl) { - textEl.textContent = optionEl.textContent; - el.querySelectorAll('.pywry-dropdown-option').forEach(function(opt) { - opt.classList.remove('pywry-selected'); - }); - optionEl.classList.add('pywry-selected'); - } - } - } - return true; - } else if (el.classList.contains('pywry-multiselect')) { - if (value !== undefined) { - var values = Array.isArray(value) ? value : [value]; - el.querySelectorAll('input[type="checkbox"]').forEach(function(cb) { - cb.checked = values.includes(cb.value); - }); - } - return true; - } else if (el.classList.contains('pywry-toggle')) { - if (value !== undefined) { - var checkbox = el.querySelector('input[type="checkbox"]'); - if (checkbox) { - checkbox.checked = !!value; - if (value) { - el.classList.add('pywry-toggle-checked'); - } else { - el.classList.remove('pywry-toggle-checked'); - } - } - } - return true; - } else if (el.classList.contains('pywry-checkbox')) { - if (value !== undefined) { - var checkbox = el.querySelector('input[type="checkbox"]'); - if (checkbox) checkbox.checked = !!value; - } - return true; - } else if (el.classList.contains('pywry-radio-group')) { - if (value !== undefined) { - el.querySelectorAll('input[type="radio"]').forEach(function(radio) { - radio.checked = radio.value === value; - }); - } - return true; - } else if (el.classList.contains('pywry-tab-group')) { - if (value !== undefined) { - el.querySelectorAll('.pywry-tab').forEach(function(tab) { - if (tab.dataset.value === value) { - tab.classList.add('pywry-tab-active'); - } else { - tab.classList.remove('pywry-tab-active'); - } - }); - } - return true; - } else if (el.classList.contains('pywry-range-group')) { - // Dual-handle range slider - if (attrs && (attrs.start !== undefined || attrs.end !== undefined)) { - var startInput = el.querySelector('input[data-range="start"]'); - var endInput = el.querySelector('input[data-range="end"]'); - var fill = el.querySelector('.pywry-range-track-fill'); - var startDisp = el.querySelector('.pywry-range-start-value'); - var endDisp = el.querySelector('.pywry-range-end-value'); - - if (startInput && attrs.start !== undefined) startInput.value = attrs.start; - if (endInput && attrs.end !== undefined) endInput.value = attrs.end; - - // Update visual fill - if (fill && startInput && endInput) { - var min = parseFloat(startInput.min) || 0; - var max = parseFloat(startInput.max) || 100; - var range = max - min; - var startVal = parseFloat(startInput.value); - var endVal = parseFloat(endInput.value); - var startPct = ((startVal - min) / range) * 100; - var endPct = ((endVal - min) / range) * 100; - fill.style.left = startPct + '%'; - fill.style.width = (endPct - startPct) + '%'; - } - if (startDisp && attrs.start !== undefined) startDisp.textContent = attrs.start; - if (endDisp && attrs.end !== undefined) endDisp.textContent = attrs.end; - } - return true; - } else if (el.classList.contains('pywry-input-range') || (el.tagName === 'INPUT' && el.type === 'range')) { - // Single slider - if (value !== undefined) { - el.value = value; - var display = el.nextElementSibling; - if (display && display.classList.contains('pywry-range-value')) { - display.textContent = value; - } - } - return true; - } - - // Generic fallback - try to set value if provided - if (value !== undefined && 'value' in el) { - el.value = value; - return true; - } - - // Return true if we processed any attrs - return attrs && Object.keys(attrs).length > 0; - } - - window.pywry.on('toolbar:request-state', function(data) { - var toolbarId = data && data.toolbarId; - var componentId = data && data.componentId; - var context = data && data.context; - - var response; - if (componentId) { - response = { - componentId: componentId, - value: getComponentValue(componentId), - context: context - }; - } else { - response = getToolbarState(toolbarId); - response.context = context; - if (toolbarId) response.toolbarId = toolbarId; - } - - window.pywry.emit('toolbar:state-response', response); - }); - - window.pywry.on('toolbar:set-value', function(data) { - if (data && data.componentId) { - // Pass entire data object as attrs for generic attribute setting - setComponentValue(data.componentId, data.value, data); - } - }); - - window.pywry.on('toolbar:set-values', function(data) { - if (data && data.values) { - Object.keys(data.values).forEach(function(id) { - setComponentValue(id, data.values[id]); - }); - } - }); - - window.__PYWRY_TOOLBAR__ = { - getState: getToolbarState, - getValue: getComponentValue, - setValue: setComponentValue - }; -})(); -""" - -# NOTE: Plotly and AG Grid event bridges are NOT defined here. -# They are loaded from the frontend JS files: -# - pywry/frontend/src/plotly-defaults.js (single source of truth for Plotly events) -# - pywry/frontend/src/aggrid-defaults.js (single source of truth for AG Grid events) -# These files are loaded via templates.py's build_plotly_script() and build_aggrid_script() - - -# Script for cleaning up sensitive inputs on page unload -_UNLOAD_CLEANUP_JS = """ -(function() { - 'use strict'; - - // Clear all revealed secrets from DOM - called on unload - // Restores mask for inputs that had a value, clears others - var MASK_CHARS = '••••••••••••'; - - function clearSecrets() { - try { - var secretInputs = document.querySelectorAll('.pywry-input-secret, input[type="password"]'); - for (var i = 0; i < secretInputs.length; i++) { - var inp = secretInputs[i]; - inp.type = 'password'; - // Restore mask if value existed, otherwise clear - if (inp.dataset && inp.dataset.hasValue === 'true') { - inp.value = MASK_CHARS; - inp.dataset.masked = 'true'; - } else { - inp.value = ''; - } - } - if (window.pywry && window.pywry._revealedSecrets) { - window.pywry._revealedSecrets = {}; - } - } catch (e) { - // Ignore errors during unload - } - } + Parameters + ---------- + filename : str + Name of the JS file to load. - // Page is being unloaded (close tab, refresh, navigate away) - window.addEventListener('beforeunload', function() { - clearSecrets(); - }); + Returns + ------- + str + File contents, or empty string if not found. + """ + path = _SRC_DIR / filename + if path.exists(): + return path.read_text(encoding="utf-8") + return "" - // Fallback for mobile/Safari - fires when page is hidden - window.addEventListener('pagehide', function() { - clearSecrets(); - }); -})(); -""" +@lru_cache(maxsize=1) +def _get_tooltip_manager_js() -> str: + """Load the tooltip manager JavaScript from the single source file.""" + return _load_js("tooltip-manager.js") -CLEANUP_JS = """ -(function() { - 'use strict'; - // Listen for cleanup signal before window destruction - if (window.__TAURI__ && window.__TAURI__.event) { - window.__TAURI__.event.listen('pywry:cleanup', function() { - console.log('Cleanup requested, releasing resources...'); +@lru_cache(maxsize=1) +def _get_bridge_js() -> str: + """Load the PyWry bridge (emit, on, result, etc.).""" + return _load_js("bridge.js") - // Clear Plotly - if (window.Plotly && window.__PYWRY_PLOTLY_DIV__) { - try { Plotly.purge(window.__PYWRY_PLOTLY_DIV__); } catch(e) {} - window.__PYWRY_PLOTLY_DIV__ = null; - } - // Clear AG Grid - if (window.__PYWRY_GRID_API__) { - try { window.__PYWRY_GRID_API__.destroy(); } catch(e) {} - window.__PYWRY_GRID_API__ = null; - } +@lru_cache(maxsize=1) +def _get_system_events_js() -> str: + """Load system event handlers (CSS injection, downloads, etc.).""" + return _load_js("system-events.js") - // Clear event handlers - if (window.pywry) { - window.pywry._handlers = {}; - } - console.log('Cleanup complete'); - }); - } +@lru_cache(maxsize=1) +def _get_theme_manager_js() -> str: + """Load the theme manager (dark/light switching, Plotly/AG Grid sync).""" + return _load_js("theme-manager.js") - console.log('Cleanup handler registered'); -})(); -""" -HOT_RELOAD_JS = """ -(function() { - 'use strict'; +@lru_cache(maxsize=1) +def _get_event_bridge_js() -> str: + """Load the Tauri event bridge.""" + return _load_js("event-bridge.js") - // Store scroll position in sessionStorage for preservation across refreshes - var SCROLL_KEY = 'pywry_scroll_' + (window.__PYWRY_LABEL__ || 'main'); - /** - * Save current scroll position to sessionStorage. - */ - function saveScrollPosition() { - var scrollData = { - x: window.scrollX || window.pageXOffset, - y: window.scrollY || window.pageYOffset, - timestamp: Date.now() - }; - try { - sessionStorage.setItem(SCROLL_KEY, JSON.stringify(scrollData)); - } catch (e) { - // sessionStorage may not be available - } - } +@lru_cache(maxsize=1) +def _get_toolbar_bridge_js() -> str: + """Load the toolbar state management bridge.""" + return _load_js("toolbar-bridge.js") - function restoreScrollPosition() { - try { - var data = sessionStorage.getItem(SCROLL_KEY); - if (data) { - var scrollData = JSON.parse(data); - // Only restore if saved within last 5 seconds (hot reload window) - if (Date.now() - scrollData.timestamp < 5000) { - window.scrollTo(scrollData.x, scrollData.y); - } - sessionStorage.removeItem(SCROLL_KEY); - } - } catch (e) { - // Ignore errors - } - } - // Override refresh to save scroll position before reloading - window.pywry.refresh = function() { - saveScrollPosition(); - window.location.reload(); - }; +@lru_cache(maxsize=1) +def _get_cleanup_js() -> str: + """Load cleanup handlers (secret clearing, resource release).""" + return _load_js("cleanup.js") - if (document.readyState === 'complete') { - restoreScrollPosition(); - } else { - window.addEventListener('load', restoreScrollPosition); - } - console.log('Hot reload bridge initialized'); -})(); -""" +@lru_cache(maxsize=1) +def _get_hot_reload_js() -> str: + """Load the hot reload bridge (scroll preservation).""" + return _load_js("hot-reload.js") def build_init_script( @@ -1232,17 +88,8 @@ def build_init_script( ) -> str: """Build the core initialization script for a window. - This builds the CORE JavaScript bridges: - - pywry bridge (emit, on, result, etc.) - - theme manager - - event bridge - - toolbar bridge - - cleanup handler - - hot reload (optional) - - NOTE: Plotly and AG Grid defaults are loaded separately via templates.py's - build_plotly_script() and build_aggrid_script() functions, which include - the library JS AND the defaults JS together. + Loads all bridge scripts from ``frontend/src/`` and concatenates + them with the window label assignment. Parameters ---------- @@ -1258,19 +105,17 @@ def build_init_script( """ scripts = [ f"window.__PYWRY_LABEL__ = '{window_label}';", - PYWRY_BRIDGE_JS, - PYWRY_SYSTEM_EVENTS_JS, - get_toast_notifications_js(), # Toast notification system - _get_tooltip_manager_js(), # Tooltip system for data-tooltip attributes - THEME_MANAGER_JS, - EVENT_BRIDGE_JS, - TOOLBAR_BRIDGE_JS, - _UNLOAD_CLEANUP_JS, # SecretInput cleanup on page unload - CLEANUP_JS, + _get_bridge_js(), + _get_system_events_js(), + get_toast_notifications_js(), + _get_tooltip_manager_js(), + _get_theme_manager_js(), + _get_event_bridge_js(), + _get_toolbar_bridge_js(), + _get_cleanup_js(), ] - # Add hot reload bridge only when enabled if enable_hot_reload: - scripts.append(HOT_RELOAD_JS) + scripts.append(_get_hot_reload_js()) return "\n".join(scripts) diff --git a/pywry/pywry/state/_factory.py b/pywry/pywry/state/_factory.py index 4afe607..9237010 100644 --- a/pywry/pywry/state/_factory.py +++ b/pywry/pywry/state/_factory.py @@ -73,7 +73,7 @@ def get_state_backend() -> StateBackend: Returns ------- StateBackend - The configured backend (MEMORY or REDIS). + The configured backend (MEMORY, REDIS, or SQLITE). Notes ----- @@ -83,6 +83,8 @@ def get_state_backend() -> StateBackend: backend = os.environ.get("PYWRY_DEPLOY__STATE_BACKEND", "memory").lower() if backend == "redis": return StateBackend.REDIS + if backend == "sqlite": + return StateBackend.SQLITE return StateBackend.MEMORY @@ -168,6 +170,14 @@ def get_widget_store() -> WidgetStore: pool_size=settings.redis_pool_size, ) + if backend == StateBackend.SQLITE: + from .sqlite import SqliteWidgetStore + + settings = _get_deploy_settings() + return SqliteWidgetStore( + db_path=getattr(settings, "sqlite_path", "~/.config/pywry/pywry.db") + ) + return MemoryWidgetStore() @@ -199,6 +209,9 @@ def get_event_bus() -> EventBus: pool_size=settings.redis_pool_size, ) + if backend == StateBackend.SQLITE: + return MemoryEventBus() + return MemoryEventBus() @@ -230,6 +243,9 @@ def get_connection_router() -> ConnectionRouter: pool_size=settings.redis_pool_size, ) + if backend == StateBackend.SQLITE: + return MemoryConnectionRouter() + return MemoryConnectionRouter() @@ -262,6 +278,14 @@ def get_session_store() -> SessionStore: pool_size=settings.redis_pool_size, ) + if backend == StateBackend.SQLITE: + from .sqlite import SqliteSessionStore + + settings = _get_deploy_settings() + return SqliteSessionStore( + db_path=getattr(settings, "sqlite_path", "~/.config/pywry/pywry.db") + ) + return MemorySessionStore() @@ -294,6 +318,12 @@ def get_chat_store() -> ChatStore: pool_size=settings.redis_pool_size, ) + if backend == StateBackend.SQLITE: + from .sqlite import SqliteChatStore + + settings = _get_deploy_settings() + return SqliteChatStore(db_path=getattr(settings, "sqlite_path", "~/.config/pywry/pywry.db")) + return MemoryChatStore() diff --git a/pywry/pywry/state/base.py b/pywry/pywry/state/base.py index 070cada..31ad1e4 100644 --- a/pywry/pywry/state/base.py +++ b/pywry/pywry/state/base.py @@ -657,6 +657,103 @@ async def clear_messages(self, widget_id: str, thread_id: str) -> None: """ ... + async def log_tool_call( + self, + message_id: str, + tool_call_id: str, + name: str, + kind: str = "other", + status: str = "pending", + arguments: dict[str, Any] | None = None, + result: str | None = None, + error: str | None = None, + ) -> None: + """Log a tool call for audit trail. No-op by default.""" + return + + async def log_artifact( + self, + message_id: str, + artifact_type: str, + title: str = "", + content: str | None = None, + metadata: dict[str, Any] | None = None, + ) -> None: + """Log an artifact for audit trail. No-op by default.""" + return + + async def log_token_usage( + self, + message_id: str, + model: str | None = None, + prompt_tokens: int = 0, + completion_tokens: int = 0, + total_tokens: int = 0, + cost_usd: float | None = None, + ) -> None: + """Log token usage for audit trail. No-op by default.""" + return + + async def log_resource( + self, + thread_id: str, + uri: str, + name: str = "", + mime_type: str | None = None, + content: str | None = None, + size: int | None = None, + ) -> None: + """Log a resource reference for audit trail. No-op by default.""" + return + + async def log_skill( + self, + thread_id: str, + name: str, + metadata: dict[str, Any] | None = None, + ) -> None: + """Log a skill activation for audit trail. No-op by default.""" + return + + async def get_tool_calls(self, message_id: str) -> list[dict[str, Any]]: + """Get tool calls for a message. Returns empty list by default.""" + return [] + + async def get_artifacts(self, message_id: str) -> list[dict[str, Any]]: + """Get artifacts for a message. Returns empty list by default.""" + return [] + + async def get_usage_stats( + self, + thread_id: str | None = None, + widget_id: str | None = None, + ) -> dict[str, Any]: + """Get aggregated token usage. Returns zeros by default.""" + return { + "prompt_tokens": 0, + "completion_tokens": 0, + "total_tokens": 0, + "cost_usd": 0.0, + "count": 0, + } + + async def get_total_cost( + self, + thread_id: str | None = None, + widget_id: str | None = None, + ) -> float: + """Get total cost in USD. Returns 0.0 by default.""" + return 0.0 + + async def search_messages( + self, + query: str, + widget_id: str | None = None, + limit: int = 50, + ) -> list[dict[str, Any]]: + """Search messages by content. Returns empty list by default.""" + return [] + class ChartStore(ABC): """Abstract chart layout/settings storage interface. diff --git a/pywry/pywry/state/sqlite.py b/pywry/pywry/state/sqlite.py new file mode 100644 index 0000000..b86f3dc --- /dev/null +++ b/pywry/pywry/state/sqlite.py @@ -0,0 +1,883 @@ +"""SQLite-backed state storage with encryption at rest. + +Implements all five state ABCs (WidgetStore, SessionStore, ChatStore, +EventBus, ConnectionRouter) in a single encrypted SQLite database file. +Designed for local single-user desktop apps but uses the same multi-user +schema as Redis so the interfaces are fully interchangeable. + +On first initialization, a default admin session is created with all +permissions. The database is encrypted using SQLCipher when available. +""" + +from __future__ import annotations + +import asyncio +import json +import logging +import os +import sqlite3 +import time +import uuid + +from pathlib import Path +from typing import Any + +from .base import ChatStore, SessionStore, WidgetStore +from .memory import MemoryConnectionRouter, MemoryEventBus +from .types import UserSession, WidgetData + + +logger = logging.getLogger(__name__) + +_SCHEMA = """ +CREATE TABLE IF NOT EXISTS widgets ( + widget_id TEXT PRIMARY KEY, + html TEXT NOT NULL, + token TEXT, + owner_worker_id TEXT, + created_at REAL NOT NULL, + metadata TEXT DEFAULT '{}' +); + +CREATE TABLE IF NOT EXISTS sessions ( + session_id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + roles TEXT NOT NULL DEFAULT '["admin"]', + created_at REAL NOT NULL, + expires_at REAL, + metadata TEXT DEFAULT '{}' +); + +CREATE TABLE IF NOT EXISTS role_permissions ( + role TEXT PRIMARY KEY, + permissions TEXT NOT NULL DEFAULT '[]' +); + +CREATE TABLE IF NOT EXISTS threads ( + thread_id TEXT PRIMARY KEY, + widget_id TEXT NOT NULL, + title TEXT NOT NULL DEFAULT 'New Chat', + status TEXT NOT NULL DEFAULT 'active', + created_at REAL NOT NULL, + updated_at REAL NOT NULL, + metadata TEXT DEFAULT '{}' +); + +CREATE TABLE IF NOT EXISTS messages ( + message_id TEXT PRIMARY KEY, + thread_id TEXT NOT NULL REFERENCES threads(thread_id) ON DELETE CASCADE, + widget_id TEXT NOT NULL, + role TEXT NOT NULL, + content TEXT NOT NULL, + timestamp REAL NOT NULL, + model TEXT, + stopped INTEGER DEFAULT 0, + metadata TEXT DEFAULT '{}' +); + +CREATE TABLE IF NOT EXISTS tool_calls ( + tool_call_id TEXT PRIMARY KEY, + message_id TEXT NOT NULL REFERENCES messages(message_id) ON DELETE CASCADE, + name TEXT NOT NULL, + kind TEXT NOT NULL DEFAULT 'other', + status TEXT NOT NULL DEFAULT 'pending', + arguments TEXT DEFAULT '{}', + result TEXT, + started_at REAL, + completed_at REAL, + error TEXT +); + +CREATE TABLE IF NOT EXISTS artifacts ( + artifact_id TEXT PRIMARY KEY, + message_id TEXT NOT NULL REFERENCES messages(message_id) ON DELETE CASCADE, + artifact_type TEXT NOT NULL, + title TEXT DEFAULT '', + content TEXT, + metadata TEXT DEFAULT '{}', + created_at REAL NOT NULL +); + +CREATE TABLE IF NOT EXISTS token_usage ( + usage_id INTEGER PRIMARY KEY AUTOINCREMENT, + message_id TEXT NOT NULL REFERENCES messages(message_id) ON DELETE CASCADE, + model TEXT, + prompt_tokens INTEGER DEFAULT 0, + completion_tokens INTEGER DEFAULT 0, + total_tokens INTEGER DEFAULT 0, + cost_usd REAL +); + +CREATE TABLE IF NOT EXISTS resources ( + resource_id TEXT PRIMARY KEY, + thread_id TEXT NOT NULL REFERENCES threads(thread_id) ON DELETE CASCADE, + uri TEXT NOT NULL, + name TEXT DEFAULT '', + mime_type TEXT, + content TEXT, + size INTEGER, + created_at REAL NOT NULL +); + +CREATE TABLE IF NOT EXISTS skills ( + skill_id TEXT PRIMARY KEY, + thread_id TEXT NOT NULL REFERENCES threads(thread_id) ON DELETE CASCADE, + name TEXT NOT NULL, + activated_at REAL NOT NULL, + metadata TEXT DEFAULT '{}' +); + +CREATE INDEX IF NOT EXISTS idx_threads_widget ON threads(widget_id); +CREATE INDEX IF NOT EXISTS idx_messages_thread ON messages(thread_id, timestamp); +CREATE INDEX IF NOT EXISTS idx_messages_widget ON messages(widget_id); +CREATE INDEX IF NOT EXISTS idx_tool_calls_message ON tool_calls(message_id); +CREATE INDEX IF NOT EXISTS idx_artifacts_message ON artifacts(message_id); +CREATE INDEX IF NOT EXISTS idx_token_usage_message ON token_usage(message_id); +CREATE INDEX IF NOT EXISTS idx_resources_thread ON resources(thread_id); +CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id); +""" + +_DEFAULT_ROLE_PERMISSIONS = { + "admin": ["read", "write", "admin", "delete", "manage_users"], + "editor": ["read", "write"], + "viewer": ["read"], + "anonymous": [], +} + +_MAX_MESSAGES_PER_THREAD = 1_000 + + +def _resolve_encryption_key() -> str | None: + env_key = os.environ.get("PYWRY_SQLITE_KEY") + if env_key: + return env_key + + try: + import keyring + + key = keyring.get_password("pywry", "sqlite_key") + if not key: + key = uuid.uuid4().hex + uuid.uuid4().hex + keyring.set_password("pywry", "sqlite_key", key) + except Exception: + logger.debug( + "Keyring unavailable for SQLite key storage, falling back to salt file", exc_info=True + ) + else: + return key + + import hashlib + + salt_path = Path("~/.config/pywry/.salt").expanduser() + salt_path.parent.mkdir(parents=True, exist_ok=True) + if salt_path.exists(): + salt = salt_path.read_bytes() + else: + salt = os.urandom(32) + salt_path.write_bytes(salt) + + node = str(uuid.getnode()).encode() + return hashlib.sha256(node + salt).hexdigest() + + +class SqliteStateBackend: + """Shared database connection and schema management. + + Parameters + ---------- + db_path : str or Path + Path to the SQLite database file. + encryption_key : str or None + Explicit encryption key. If ``None``, derived automatically. + encrypted : bool + Whether to encrypt the database. Defaults to ``True``. + """ + + _lock: asyncio.Lock | None = None + _conn: sqlite3.Connection | None = None + _initialized: bool = False + + def __init__( + self, + db_path: str | Path = "~/.config/pywry/pywry.db", + encryption_key: str | None = None, + encrypted: bool = True, + ) -> None: + self._db_path = Path(db_path).expanduser() + self._encrypted = encrypted + self._key = encryption_key + if encrypted and not encryption_key: + self._key = _resolve_encryption_key() + + def _get_lock(self) -> asyncio.Lock: + if self._lock is None: + self._lock = asyncio.Lock() + return self._lock + + def _connect(self) -> sqlite3.Connection: + if self._conn is not None: + return self._conn + + self._db_path.parent.mkdir(parents=True, exist_ok=True) + + if self._encrypted and self._key: + try: + from pysqlcipher3 import dbapi2 as sqlcipher # type: ignore[import-not-found] + + conn: sqlite3.Connection = sqlcipher.connect(str(self._db_path)) + conn.execute(f"PRAGMA key = '{self._key}'") + logger.debug("Opened encrypted SQLite database at %s", self._db_path) + except ImportError: + logger.warning( + "pysqlcipher3 not installed — database will NOT be encrypted. " + "Install with: pip install pysqlcipher3" + ) + conn = sqlite3.connect(str(self._db_path)) + else: + conn = sqlite3.connect(str(self._db_path)) + + conn.execute("PRAGMA journal_mode=WAL") + conn.execute("PRAGMA foreign_keys=ON") + conn.row_factory = sqlite3.Row + self._conn = conn + return conn + + async def _initialize(self) -> None: + if self._initialized: + return + async with self._get_lock(): + if self._initialized: + return + conn = self._connect() + conn.executescript(_SCHEMA) + + cursor = conn.execute("SELECT COUNT(*) FROM role_permissions") + if cursor.fetchone()[0] == 0: + for role, perms in _DEFAULT_ROLE_PERMISSIONS.items(): + conn.execute( + "INSERT INTO role_permissions (role, permissions) VALUES (?, ?)", + (role, json.dumps(perms)), + ) + + cursor = conn.execute("SELECT COUNT(*) FROM sessions") + if cursor.fetchone()[0] == 0: + conn.execute( + "INSERT INTO sessions (session_id, user_id, roles, created_at, metadata) " + "VALUES (?, ?, ?, ?, ?)", + ("local", "admin", json.dumps(["admin"]), time.time(), "{}"), + ) + + conn.commit() + self._initialized = True + + async def _execute( + self, sql: str, params: tuple[Any, ...] = (), commit: bool = True + ) -> list[sqlite3.Row]: + await self._initialize() + async with self._get_lock(): + conn = self._connect() + cursor = conn.execute(sql, params) + rows = cursor.fetchall() + if commit: + conn.commit() + return rows + + async def _executemany(self, sql: str, params_list: list[tuple[Any, ...]]) -> None: + await self._initialize() + async with self._get_lock(): + conn = self._connect() + conn.executemany(sql, params_list) + conn.commit() + + +class SqliteWidgetStore(SqliteStateBackend, WidgetStore): + """SQLite-backed widget store.""" + + async def register( + self, + widget_id: str, + html: str, + token: str | None = None, + owner_worker_id: str | None = None, + metadata: dict[str, Any] | None = None, + ) -> None: + await self._execute( + "INSERT OR REPLACE INTO widgets " + "(widget_id, html, token, owner_worker_id, created_at, metadata) " + "VALUES (?, ?, ?, ?, ?, ?)", + (widget_id, html, token, owner_worker_id, time.time(), json.dumps(metadata or {})), + ) + + async def get(self, widget_id: str) -> WidgetData | None: + rows = await self._execute( + "SELECT * FROM widgets WHERE widget_id = ?", (widget_id,), commit=False + ) + if not rows: + return None + r = rows[0] + return WidgetData( + widget_id=r["widget_id"], + html=r["html"], + token=r["token"], + created_at=r["created_at"], + owner_worker_id=r["owner_worker_id"], + metadata=json.loads(r["metadata"] or "{}"), + ) + + async def get_html(self, widget_id: str) -> str | None: + rows = await self._execute( + "SELECT html FROM widgets WHERE widget_id = ?", (widget_id,), commit=False + ) + return rows[0]["html"] if rows else None + + async def get_token(self, widget_id: str) -> str | None: + rows = await self._execute( + "SELECT token FROM widgets WHERE widget_id = ?", (widget_id,), commit=False + ) + return rows[0]["token"] if rows else None + + async def exists(self, widget_id: str) -> bool: + rows = await self._execute( + "SELECT 1 FROM widgets WHERE widget_id = ?", (widget_id,), commit=False + ) + return len(rows) > 0 + + async def delete(self, widget_id: str) -> bool: + before = await self.exists(widget_id) + if before: + await self._execute("DELETE FROM widgets WHERE widget_id = ?", (widget_id,)) + return before + + async def list_active(self) -> list[str]: + rows = await self._execute("SELECT widget_id FROM widgets", commit=False) + return [r["widget_id"] for r in rows] + + async def update_html(self, widget_id: str, html: str) -> bool: + if not await self.exists(widget_id): + return False + await self._execute("UPDATE widgets SET html = ? WHERE widget_id = ?", (html, widget_id)) + return True + + async def update_token(self, widget_id: str, token: str) -> bool: + if not await self.exists(widget_id): + return False + await self._execute("UPDATE widgets SET token = ? WHERE widget_id = ?", (token, widget_id)) + return True + + async def count(self) -> int: + rows = await self._execute("SELECT COUNT(*) as cnt FROM widgets", commit=False) + return rows[0]["cnt"] if rows else 0 + + +class SqliteSessionStore(SqliteStateBackend, SessionStore): + """SQLite-backed session store with RBAC.""" + + async def create_session( + self, + session_id: str, + user_id: str, + roles: list[str] | None = None, + ttl: int | None = None, + metadata: dict[str, Any] | None = None, + ) -> UserSession: + now = time.time() + expires_at = (now + ttl) if ttl else None + session = UserSession( + session_id=session_id, + user_id=user_id, + roles=roles or ["viewer"], + created_at=now, + expires_at=expires_at, + metadata=metadata or {}, + ) + await self._execute( + "INSERT OR REPLACE INTO sessions " + "(session_id, user_id, roles, created_at, expires_at, metadata) " + "VALUES (?, ?, ?, ?, ?, ?)", + ( + session.session_id, + session.user_id, + json.dumps(session.roles), + session.created_at, + session.expires_at, + json.dumps(session.metadata), + ), + ) + return session + + async def get_session(self, session_id: str) -> UserSession | None: + rows = await self._execute( + "SELECT * FROM sessions WHERE session_id = ?", (session_id,), commit=False + ) + if not rows: + return None + r = rows[0] + expires_at = r["expires_at"] + if expires_at and time.time() > expires_at: + await self.delete_session(session_id) + return None + return UserSession( + session_id=r["session_id"], + user_id=r["user_id"], + roles=json.loads(r["roles"]), + created_at=r["created_at"], + expires_at=expires_at, + metadata=json.loads(r["metadata"] or "{}"), + ) + + async def validate_session(self, session_id: str) -> bool: + session = await self.get_session(session_id) + return session is not None + + async def delete_session(self, session_id: str) -> bool: + rows = await self._execute( + "DELETE FROM sessions WHERE session_id = ? RETURNING session_id", (session_id,) + ) + return len(rows) > 0 + + async def refresh_session(self, session_id: str, extend_ttl: int | None = None) -> bool: + session = await self.get_session(session_id) + if session is None: + return False + if extend_ttl: + new_expires = time.time() + extend_ttl + await self._execute( + "UPDATE sessions SET expires_at = ? WHERE session_id = ?", + (new_expires, session_id), + ) + return True + + async def list_user_sessions(self, user_id: str) -> list[UserSession]: + rows = await self._execute( + "SELECT * FROM sessions WHERE user_id = ?", (user_id,), commit=False + ) + sessions = [] + now = time.time() + for r in rows: + expires_at = r["expires_at"] + if expires_at and now > expires_at: + continue + sessions.append( + UserSession( + session_id=r["session_id"], + user_id=r["user_id"], + roles=json.loads(r["roles"]), + created_at=r["created_at"], + expires_at=expires_at, + metadata=json.loads(r["metadata"] or "{}"), + ) + ) + return sessions + + async def check_permission( + self, + session_id: str, + resource_type: str, + resource_id: str, + permission: str, + ) -> bool: + session = await self.get_session(session_id) + if session is None: + return False + for role in session.roles: + rows = await self._execute( + "SELECT permissions FROM role_permissions WHERE role = ?", + (role,), + commit=False, + ) + if rows: + perms = json.loads(rows[0]["permissions"]) + if permission in perms: + return True + resource_perms = session.metadata.get("permissions", {}) + resource_key = f"{resource_type}:{resource_id}" + if resource_key in resource_perms: + return permission in resource_perms[resource_key] + return False + + +class SqliteChatStore(SqliteStateBackend, ChatStore): + """SQLite-backed chat store with audit trail.""" + + async def save_thread(self, widget_id: str, thread: Any) -> None: + await self._execute( + "INSERT OR REPLACE INTO threads " + "(thread_id, widget_id, title, status, created_at, updated_at, metadata) " + "VALUES (?, ?, ?, ?, ?, ?, ?)", + ( + thread.thread_id, + widget_id, + thread.title, + thread.status, + thread.created_at, + thread.updated_at, + json.dumps(thread.metadata), + ), + ) + + async def get_thread(self, widget_id: str, thread_id: str) -> Any: + from ..chat.models import ChatThread + + rows = await self._execute( + "SELECT * FROM threads WHERE widget_id = ? AND thread_id = ?", + (widget_id, thread_id), + commit=False, + ) + if not rows: + return None + r = rows[0] + return ChatThread( + thread_id=r["thread_id"], + title=r["title"], + status=r["status"], + created_at=r["created_at"], + updated_at=r["updated_at"], + metadata=json.loads(r["metadata"] or "{}"), + ) + + async def list_threads(self, widget_id: str) -> list[Any]: + from ..chat.models import ChatThread + + rows = await self._execute( + "SELECT * FROM threads WHERE widget_id = ? ORDER BY updated_at DESC", + (widget_id,), + commit=False, + ) + return [ + ChatThread( + thread_id=r["thread_id"], + title=r["title"], + status=r["status"], + created_at=r["created_at"], + updated_at=r["updated_at"], + metadata=json.loads(r["metadata"] or "{}"), + ) + for r in rows + ] + + async def delete_thread(self, widget_id: str, thread_id: str) -> bool: + rows = await self._execute( + "DELETE FROM threads WHERE widget_id = ? AND thread_id = ? RETURNING thread_id", + (widget_id, thread_id), + ) + return len(rows) > 0 + + async def append_message(self, widget_id: str, thread_id: str, message: Any) -> None: + content = ( + message.content + if isinstance(message.content, str) + else json.dumps([p.model_dump(by_alias=True) for p in message.content]) + ) + await self._execute( + "INSERT INTO messages " + "(message_id, thread_id, widget_id, role, content, timestamp, model, stopped, metadata) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", + ( + message.message_id, + thread_id, + widget_id, + message.role, + content, + message.timestamp, + message.model, + 1 if message.stopped else 0, + json.dumps(message.metadata), + ), + ) + + await self._execute( + "UPDATE threads SET updated_at = ? WHERE thread_id = ?", + (time.time(), thread_id), + ) + + count_rows = await self._execute( + "SELECT COUNT(*) as cnt FROM messages WHERE thread_id = ?", + (thread_id,), + commit=False, + ) + count = count_rows[0]["cnt"] if count_rows else 0 + if count > _MAX_MESSAGES_PER_THREAD: + excess = count - _MAX_MESSAGES_PER_THREAD + await self._execute( + "DELETE FROM messages WHERE message_id IN " + "(SELECT message_id FROM messages WHERE thread_id = ? " + "ORDER BY timestamp ASC LIMIT ?)", + (thread_id, excess), + ) + + async def get_messages( + self, + widget_id: str, + thread_id: str, + limit: int = 50, + before_id: str | None = None, + ) -> list[Any]: + from ..chat.models import ChatMessage + + if before_id: + ts_rows = await self._execute( + "SELECT timestamp FROM messages WHERE message_id = ?", + (before_id,), + commit=False, + ) + if ts_rows: + before_ts = ts_rows[0]["timestamp"] + rows = await self._execute( + "SELECT * FROM messages WHERE thread_id = ? AND widget_id = ? " + "AND timestamp < ? ORDER BY timestamp DESC LIMIT ?", + (thread_id, widget_id, before_ts, limit), + commit=False, + ) + else: + rows = [] + else: + rows = await self._execute( + "SELECT * FROM messages WHERE thread_id = ? AND widget_id = ? " + "ORDER BY timestamp DESC LIMIT ?", + (thread_id, widget_id, limit), + commit=False, + ) + + messages = [] + for r in reversed(rows): + content_raw = r["content"] + try: + content = json.loads(content_raw) if content_raw.startswith("[") else content_raw + except (json.JSONDecodeError, AttributeError): + content = content_raw + + messages.append( + ChatMessage( + role=r["role"], + content=content, + message_id=r["message_id"], + timestamp=r["timestamp"], + model=r["model"], + stopped=bool(r["stopped"]), + metadata=json.loads(r["metadata"] or "{}"), + ) + ) + return messages + + async def clear_messages(self, widget_id: str, thread_id: str) -> None: + await self._execute( + "DELETE FROM messages WHERE thread_id = ? AND widget_id = ?", + (thread_id, widget_id), + ) + + async def log_tool_call( + self, + message_id: str, + tool_call_id: str, + name: str, + kind: str = "other", + status: str = "pending", + arguments: dict[str, Any] | None = None, + result: str | None = None, + error: str | None = None, + ) -> None: + now = time.time() + await self._execute( + "INSERT OR REPLACE INTO tool_calls " + "(tool_call_id, message_id, name, kind, status, arguments, result, " + "started_at, completed_at, error) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + ( + tool_call_id, + message_id, + name, + kind, + status, + json.dumps(arguments or {}), + result, + now if status == "in_progress" else None, + now if status in ("completed", "failed") else None, + error, + ), + ) + + async def log_artifact( + self, + message_id: str, + artifact_type: str, + title: str = "", + content: str | None = None, + metadata: dict[str, Any] | None = None, + ) -> None: + await self._execute( + "INSERT INTO artifacts " + "(artifact_id, message_id, artifact_type, title, content, metadata, created_at) " + "VALUES (?, ?, ?, ?, ?, ?, ?)", + ( + f"art_{uuid.uuid4().hex[:12]}", + message_id, + artifact_type, + title, + content, + json.dumps(metadata or {}), + time.time(), + ), + ) + + async def log_token_usage( + self, + message_id: str, + model: str | None = None, + prompt_tokens: int = 0, + completion_tokens: int = 0, + total_tokens: int = 0, + cost_usd: float | None = None, + ) -> None: + await self._execute( + "INSERT INTO token_usage " + "(message_id, model, prompt_tokens, completion_tokens, total_tokens, cost_usd) " + "VALUES (?, ?, ?, ?, ?, ?)", + (message_id, model, prompt_tokens, completion_tokens, total_tokens, cost_usd), + ) + + async def log_resource( + self, + thread_id: str, + uri: str, + name: str = "", + mime_type: str | None = None, + content: str | None = None, + size: int | None = None, + ) -> None: + await self._execute( + "INSERT INTO resources " + "(resource_id, thread_id, uri, name, mime_type, content, size, created_at) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + ( + f"res_{uuid.uuid4().hex[:12]}", + thread_id, + uri, + name, + mime_type, + content, + size, + time.time(), + ), + ) + + async def log_skill( + self, + thread_id: str, + name: str, + metadata: dict[str, Any] | None = None, + ) -> None: + await self._execute( + "INSERT INTO skills (skill_id, thread_id, name, activated_at, metadata) " + "VALUES (?, ?, ?, ?, ?)", + ( + f"skill_{uuid.uuid4().hex[:12]}", + thread_id, + name, + time.time(), + json.dumps(metadata or {}), + ), + ) + + async def get_tool_calls(self, message_id: str) -> list[dict[str, Any]]: + rows = await self._execute( + "SELECT * FROM tool_calls WHERE message_id = ? ORDER BY started_at", + (message_id,), + commit=False, + ) + return [dict(r) for r in rows] + + async def get_artifacts(self, message_id: str) -> list[dict[str, Any]]: + rows = await self._execute( + "SELECT * FROM artifacts WHERE message_id = ? ORDER BY created_at", + (message_id,), + commit=False, + ) + return [dict(r) for r in rows] + + async def get_usage_stats( + self, + thread_id: str | None = None, + widget_id: str | None = None, + ) -> dict[str, Any]: + if thread_id: + rows = await self._execute( + "SELECT SUM(prompt_tokens) as prompt, SUM(completion_tokens) as completion, " + "SUM(total_tokens) as total, SUM(cost_usd) as cost, COUNT(*) as count " + "FROM token_usage tu JOIN messages m ON tu.message_id = m.message_id " + "WHERE m.thread_id = ?", + (thread_id,), + commit=False, + ) + elif widget_id: + rows = await self._execute( + "SELECT SUM(prompt_tokens) as prompt, SUM(completion_tokens) as completion, " + "SUM(total_tokens) as total, SUM(cost_usd) as cost, COUNT(*) as count " + "FROM token_usage tu JOIN messages m ON tu.message_id = m.message_id " + "WHERE m.widget_id = ?", + (widget_id,), + commit=False, + ) + else: + rows = await self._execute( + "SELECT SUM(prompt_tokens) as prompt, SUM(completion_tokens) as completion, " + "SUM(total_tokens) as total, SUM(cost_usd) as cost, COUNT(*) as count " + "FROM token_usage", + commit=False, + ) + if not rows: + return { + "prompt_tokens": 0, + "completion_tokens": 0, + "total_tokens": 0, + "cost_usd": 0, + "count": 0, + } + r = rows[0] + return { + "prompt_tokens": r["prompt"] or 0, + "completion_tokens": r["completion"] or 0, + "total_tokens": r["total"] or 0, + "cost_usd": r["cost"] or 0.0, + "count": r["count"] or 0, + } + + async def get_total_cost( + self, + thread_id: str | None = None, + widget_id: str | None = None, + ) -> float: + stats = await self.get_usage_stats(thread_id=thread_id, widget_id=widget_id) + cost: float = stats["cost_usd"] + return cost + + async def search_messages( + self, + query: str, + widget_id: str | None = None, + limit: int = 50, + ) -> list[dict[str, Any]]: + pattern = f"%{query}%" + if widget_id: + rows = await self._execute( + "SELECT m.*, t.title as thread_title FROM messages m " + "JOIN threads t ON m.thread_id = t.thread_id " + "WHERE m.content LIKE ? AND m.widget_id = ? " + "ORDER BY m.timestamp DESC LIMIT ?", + (pattern, widget_id, limit), + commit=False, + ) + else: + rows = await self._execute( + "SELECT m.*, t.title as thread_title FROM messages m " + "JOIN threads t ON m.thread_id = t.thread_id " + "WHERE m.content LIKE ? ORDER BY m.timestamp DESC LIMIT ?", + (pattern, limit), + commit=False, + ) + return [dict(r) for r in rows] + + +SqliteEventBus = MemoryEventBus +"""SQLite mode reuses the in-memory event bus — single-process, no pub/sub needed.""" + +SqliteConnectionRouter = MemoryConnectionRouter +"""SQLite mode reuses the in-memory connection router — single-process, routing is trivial.""" diff --git a/pywry/pywry/state/types.py b/pywry/pywry/state/types.py index f13e1d0..2eba738 100644 --- a/pywry/pywry/state/types.py +++ b/pywry/pywry/state/types.py @@ -17,6 +17,7 @@ class StateBackend(str, Enum): MEMORY = "memory" REDIS = "redis" + SQLITE = "sqlite" @dataclass diff --git a/pywry/pywry/templates.py b/pywry/pywry/templates.py index 7e4c980..7abfc8c 100644 --- a/pywry/pywry/templates.py +++ b/pywry/pywry/templates.py @@ -251,7 +251,7 @@ def build_plotly_init_script( delete config.templateDark; delete config.templateLight; - // Extract single legacy template overrides from layout.template + // Extract single template overrides from layout.template var userTemplate = null; if (typeof layout.template === 'string' && templates[layout.template]) {{ // User specified a named template - resolve it diff --git a/pywry/pywry/tvchart/__init__.py b/pywry/pywry/tvchart/__init__.py index c8a3db8..a2adf26 100644 --- a/pywry/pywry/tvchart/__init__.py +++ b/pywry/pywry/tvchart/__init__.py @@ -1,7 +1,7 @@ """TradingView chart package — models, normalization, toolbars, mixin, datafeed. -All public symbols are re-exported here for backward compatibility so that -``from pywry.tvchart import ...`` continues to work. +All public symbols are re-exported here so that +``from pywry.tvchart import ...`` works. """ from __future__ import annotations diff --git a/pywry/pywry/tvchart/udf.py b/pywry/pywry/tvchart/udf.py index 9fc6ffa..cb8b6f4 100644 --- a/pywry/pywry/tvchart/udf.py +++ b/pywry/pywry/tvchart/udf.py @@ -184,7 +184,7 @@ def parse_udf_columns(data: dict[str, Any], count: int | None = None) -> list[di def _map_symbol_keys(raw: dict[str, Any]) -> dict[str, Any]: - """Map UDF hyphen-case / legacy keys to TVChartSymbolInfo field names.""" + """Map UDF hyphen-case keys to TVChartSymbolInfo field names.""" mapped: dict[str, Any] = {} for key, val in raw.items(): canonical = _UDF_SYMBOL_KEY_MAP.get(key, key.replace("-", "_")) diff --git a/pywry/pywry/widget.py b/pywry/pywry/widget.py index b8c10bc..20aec17 100644 --- a/pywry/pywry/widget.py +++ b/pywry/pywry/widget.py @@ -242,7 +242,7 @@ def _get_aggrid_widget_esm() -> str: console.log('[PyWry AG Grid] render() called, renderId:', myRenderId); - // CRITICAL: Clear el completely to avoid stale content from re-renders + // Clear el to avoid stale content from re-renders el.innerHTML = ''; // Apply theme class to el (AnyWidget container) for proper theming @@ -687,7 +687,7 @@ def _get_aggrid_widget_esm() -> str: __TOOLBAR_HANDLERS__ function renderContent(retryCount = 0) { - // CRITICAL: Check if this render is stale (a newer render has started) + // Bail if a newer render has started if (myRenderId !== currentRenderId) { console.log('[PyWry AG Grid] Stale render detected, aborting. myId:', myRenderId, 'current:', currentRenderId); return; @@ -763,7 +763,7 @@ def _get_aggrid_widget_esm() -> str: // Wait for AG Grid to be ready before first render (poll every 50ms, max 100 attempts = 5s) function waitAndRender(attempt) { - // CRITICAL: Check if this render is stale (a newer render has started) + // Bail if a newer render has started if (myRenderId !== currentRenderId) { console.log('[PyWry AG Grid] Stale waitAndRender detected, aborting. myId:', myRenderId, 'current:', currentRenderId); return; @@ -847,7 +847,7 @@ def _get_aggrid_widget_esm() -> str: if (!getAgGrid()) {{ console.log('[PyWry AG Grid ESM] AG Grid not found, loading library...'); - // CRITICAL: AG Grid UMD checks for AMD define() first. + // AG Grid UMD checks for AMD define() first. // If define exists, it registers as AMD module instead of setting self.agGrid. // We must temporarily hide define to force the global export path. var _originalDefine = typeof define !== 'undefined' ? define : undefined; @@ -952,7 +952,7 @@ def _get_widget_esm() -> str: modelHeight = toCss(modelHeight); modelWidth = toCss(modelWidth); - // CRITICAL: Set height on el (AnyWidget's container) to constrain output size + // Set height on el to constrain output size if (modelHeight) { el.style.height = modelHeight; // Ensure el is displayed as block/inline-block to respect height @@ -1382,6 +1382,68 @@ def _get_tvchart_widget_esm() -> str: """ +def _get_chat_widget_esm() -> str: + """Build the chat widget ESM with chat-handlers.js and asset injection. + + Returns + ------- + str + JavaScript ESM module containing the base widget render function, + chat-handlers.js, toolbar handlers, and trait-based asset injection + listeners for lazy-loading Plotly/AG Grid/TradingView. + """ + from .assets import get_scrollbar_js, get_toast_notifications_js + + toolbar_handlers_js = _get_toolbar_handlers_js() + toast_js = get_toast_notifications_js() or "" + scrollbar_js = get_scrollbar_js() or "" + + chat_handlers_file = _SRC_DIR / "chat-handlers.js" + chat_handlers_js = ( + chat_handlers_file.read_text(encoding="utf-8") if chat_handlers_file.exists() else "" + ) + + # Start with the base widget ESM (content rendering, theme, events) + base_esm = _WIDGET_ESM.replace("__TOOLBAR_HANDLERS__", toolbar_handlers_js) + + return f""" +{toast_js} + +{scrollbar_js} + +{base_esm} + +// --- Chat handlers --- +{chat_handlers_js} + +// --- Trait-based asset injection for lazy-loaded artifact libraries --- +// When ChatManager pushes JS/CSS via _asset_js/_asset_css traits, +// inject them into the document head so artifact renderers work. +(function() {{ + if (typeof model !== 'undefined') {{ + model.on("change:_asset_js", function() {{ + var js = model.get("_asset_js"); + if (js) {{ + var script = document.createElement("script"); + script.textContent = js; + document.head.appendChild(script); + console.log("[PyWry Chat] Injected asset JS via trait (" + js.length + " chars)"); + }} + }}); + model.on("change:_asset_css", function() {{ + var css = model.get("_asset_css"); + if (css) {{ + var style = document.createElement("style"); + style.textContent = css; + document.head.appendChild(style); + console.log("[PyWry Chat] Injected asset CSS via trait (" + css.length + " chars)"); + }} + }}); + }} +}})(); +""" + + @lru_cache(maxsize=1) def _get_pywry_base_css() -> str: """Load pywry base CSS for widget theming, including toast styles.""" @@ -1906,12 +1968,20 @@ def display(self) -> None: class PyWryChatWidget(PyWryWidget, ChatStateMixin): # pylint: disable=abstract-method,too-many-ancestors """Widget for inline notebook rendering with chat UI. - This class extends :class:`PyWryWidget` with chat-specific state mixins so - notebook renders can emit the same chat protocol events as native windows. + This class extends :class:`PyWryWidget` with chat-specific state + mixins and bundles chat-handlers.js in the ESM. Artifact libraries + (Plotly, AG Grid, TradingView) are lazy-loaded via the + ``_asset_js`` / ``_asset_css`` traits when the first artifact of + that type is yielded by the provider. """ + _esm = _get_chat_widget_esm() + _css = _get_pywry_base_css() + content = traitlets.Unicode("").tag(sync=True) theme = traitlets.Unicode("dark").tag(sync=True) + _asset_js = traitlets.Unicode("").tag(sync=True) + _asset_css = traitlets.Unicode("").tag(sync=True) class PyWryTVChartWidget(PyWryWidget, TVChartStateMixin): # pylint: disable=abstract-method,too-many-ancestors """Widget for inline notebook rendering with TradingView Lightweight Charts.""" diff --git a/pywry/ruff.toml b/pywry/ruff.toml index cb28ec3..fb26e4e 100644 --- a/pywry/ruff.toml +++ b/pywry/ruff.toml @@ -80,9 +80,11 @@ dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$" "S104", "S105", "S106", + "S108", "S310", "ARG", "ASYNC240", + "PERF401", ] "__init__.py" = [ @@ -91,6 +93,14 @@ dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$" "D104", ] +"pywry/chat/providers/callback.py" = [ + "TC003", +] + +"pywry/state/sqlite.py" = [ + "D102", +] + "pywry/__main__.py" = [ "W293", # Trailing whitespace in JS template strings "S110", # try-except-pass in window cleanup diff --git a/pywry/tests/test_chat.py b/pywry/tests/test_chat.py index 4bb90f3..7d3a3ee 100644 --- a/pywry/tests/test_chat.py +++ b/pywry/tests/test_chat.py @@ -1,11 +1,14 @@ """Unit tests for the chat component. Tests cover: -- Chat Pydantic models (ChatMessage, ChatThread, ChatConfig, etc.) +- ACP content block models (TextPart, ImagePart, AudioPart, etc.) +- ACPToolCall model +- ChatMessage, ChatThread, ChatConfig - GenerationHandle (cancel, append_chunk, partial_content, is_expired) - ChatStateMixin: all chat state management methods - ChatStore ABC + MemoryChatStore implementation - Chat builder functions +- ACPCommand model """ # pylint: disable=missing-function-docstring,redefined-outer-name,unused-argument @@ -22,18 +25,20 @@ from pywry.chat import ( GENERATION_HANDLE_TTL, MAX_CONTENT_LENGTH, + ACPCommand, + ACPToolCall, + AudioPart, ChatConfig, ChatMessage, ChatThread, ChatWidgetConfig, + EmbeddedResource, + EmbeddedResourcePart, GenerationHandle, ImagePart, ResourceLinkPart, - SlashCommand, TextPart, - ToolCall, - ToolCallFunction, - _default_slash_commands, + build_chat_html, ) from pywry.state_mixins import ChatStateMixin, EmittingWidget @@ -75,7 +80,7 @@ def test_basic_creation(self) -> None: msg = ChatMessage(role="user", content="Hello") assert msg.role == "user" assert msg.text_content() == "Hello" - assert msg.message_id # auto-generated + assert msg.message_id assert msg.stopped is False def test_string_content(self) -> None: @@ -97,13 +102,12 @@ def test_list_content_mixed_parts(self) -> None: role="assistant", content=[ TextPart(text="See image: "), - ImagePart(data="base64data", mime_type="image/png"), + ImagePart(data="base64data", mimeType="image/png"), ], ) assert msg.text_content() == "See image: " def test_content_length_validation(self) -> None: - # Should not raise for content within limit msg = ChatMessage(role="user", content="x" * 100) assert len(msg.text_content()) == 100 @@ -118,17 +122,17 @@ def test_tool_calls(self) -> None: role="assistant", content="I'll search for that.", tool_calls=[ - ToolCall( - id="call_1", - function=ToolCallFunction( - name="search", - arguments='{"query": "test"}', - ), + ACPToolCall( + toolCallId="call_1", + name="search", + kind="fetch", + arguments={"query": "test"}, ), ], ) assert len(msg.tool_calls) == 1 - assert msg.tool_calls[0].function.name == "search" + assert msg.tool_calls[0].name == "search" + assert msg.tool_calls[0].kind == "fetch" def test_stopped_field(self) -> None: msg = ChatMessage(role="assistant", content="Partial", stopped=True) @@ -158,16 +162,52 @@ def test_with_messages(self) -> None: assert len(thread.messages) == 1 -class TestSlashCommand: - """Test SlashCommand model.""" +class TestACPCommand: + """Test ACPCommand model.""" - def test_auto_prefix(self) -> None: - cmd = SlashCommand(name="clear", description="Clear chat") - assert cmd.name == "/clear" + def test_creation(self) -> None: + cmd = ACPCommand(name="web", description="Search the web") + assert cmd.name == "web" + assert cmd.description == "Search the web" + + def test_with_input(self) -> None: + from pywry.chat.models import ACPCommandInput + + cmd = ACPCommand( + name="test", + description="Run tests", + input=ACPCommandInput(hint="Enter test name"), + ) + assert cmd.input.hint == "Enter test name" + + +class TestACPToolCall: + """Test ACPToolCall model.""" + + def test_creation(self) -> None: + tc = ACPToolCall( + toolCallId="call_1", + title="Read file", + name="fs_read", + kind="read", + status="pending", + ) + assert tc.tool_call_id == "call_1" + assert tc.kind == "read" + assert tc.status == "pending" - def test_already_prefixed(self) -> None: - cmd = SlashCommand(name="/help", description="Help") - assert cmd.name == "/help" + def test_defaults(self) -> None: + tc = ACPToolCall(name="test") + assert tc.tool_call_id # auto-generated + assert tc.kind == "other" + assert tc.status == "pending" + + def test_with_arguments(self) -> None: + tc = ACPToolCall( + name="search", + arguments={"query": "hello"}, + ) + assert tc.arguments["query"] == "hello" class TestChatConfig: @@ -208,17 +248,56 @@ def test_with_chat_config(self) -> None: assert config.chat_config.model == "gpt-4o" -class TestDefaultSlashCommands: - """Test _default_slash_commands.""" +# ============================================================================= +# Content Part Tests +# ============================================================================= - def test_returns_commands(self) -> None: - cmds = _default_slash_commands() - assert len(cmds) == 4 - names = [c.name for c in cmds] - assert "/clear" in names - assert "/export" in names - assert "/model" in names - assert "/system" in names + +class TestContentParts: + """Test ACP ContentBlock types.""" + + def test_text_part(self) -> None: + part = TextPart(text="hello") + assert part.type == "text" + assert part.text == "hello" + + def test_text_part_with_annotations(self) -> None: + part = TextPart(text="hello", annotations={"source": "llm"}) + assert part.annotations["source"] == "llm" + + def test_image_part(self) -> None: + part = ImagePart(data="base64data", mimeType="image/png") + assert part.type == "image" + assert part.data == "base64data" + assert part.mime_type == "image/png" + + def test_audio_part(self) -> None: + part = AudioPart(data="audiodata", mimeType="audio/wav") + assert part.type == "audio" + assert part.mime_type == "audio/wav" + + def test_resource_link_part(self) -> None: + part = ResourceLinkPart( + uri="pywry://resource/1", + name="Doc", + title="My Document", + size=1024, + ) + assert part.type == "resource_link" + assert part.name == "Doc" + assert part.title == "My Document" + assert part.size == 1024 + + def test_embedded_resource_part(self) -> None: + part = EmbeddedResourcePart( + resource=EmbeddedResource( + uri="file:///doc.txt", + mimeType="text/plain", + text="Hello world", + ), + ) + assert part.type == "resource" + assert part.resource.text == "Hello world" # ============================================================================= @@ -275,7 +354,6 @@ def test_is_expired(self) -> None: thread_id="t_1", ) assert not handle.is_expired - # Manually set created_at to past handle.created_at = time.time() - GENERATION_HANDLE_TTL - 1 assert handle.is_expired @@ -295,7 +373,6 @@ def test_send_chat_message(self) -> None: assert evt_type == "chat:assistant-message" assert data["messageId"] == "msg_1" assert data["text"] == "Hello!" - assert data["threadId"] == "t_1" def test_stream_chat_chunk(self) -> None: w = MockChatWidget() @@ -305,12 +382,6 @@ def test_stream_chat_chunk(self) -> None: assert data["chunk"] == "tok" assert data["done"] is False - def test_stream_chat_chunk_done(self) -> None: - w = MockChatWidget() - w.stream_chat_chunk("", "msg_1", done=True) - _evt_type, data = w.get_last_event() - assert data["done"] is True - def test_set_chat_typing(self) -> None: w = MockChatWidget() w.set_chat_typing(True) @@ -325,14 +396,6 @@ def test_switch_chat_thread(self) -> None: assert evt_type == "chat:switch-thread" assert data["threadId"] == "t_2" - def test_update_chat_thread_list(self) -> None: - w = MockChatWidget() - threads = [{"thread_id": "t1", "title": "Chat 1"}] - w.update_chat_thread_list(threads) - evt_type, data = w.get_last_event() - assert evt_type == "chat:update-thread-list" - assert data["threads"] == threads - def test_clear_chat(self) -> None: w = MockChatWidget() w.clear_chat() @@ -345,15 +408,6 @@ def test_register_chat_command(self) -> None: evt_type, data = w.get_last_event() assert evt_type == "chat:register-command" assert data["name"] == "/help" - assert data["description"] == "Show help" - - def test_update_chat_settings(self) -> None: - w = MockChatWidget() - w.update_chat_settings({"model": "gpt-4o", "temperature": 0.5}) - evt_type, data = w.get_last_event() - assert evt_type == "chat:update-settings" - assert data["model"] == "gpt-4o" - assert data["temperature"] == 0.5 def test_request_chat_state(self) -> None: w = MockChatWidget() @@ -383,7 +437,6 @@ async def test_save_and_get_thread(self, store) -> None: result = await store.get_thread("w1", "t1") assert result is not None assert result.thread_id == "t1" - assert result.title == "Test" @pytest.mark.asyncio async def test_list_threads(self, store) -> None: @@ -414,7 +467,6 @@ async def test_get_messages_pagination(self, store) -> None: for i in range(5): msg = ChatMessage(role="user", content=f"msg{i}", message_id=f"m{i}") await store.append_message("w1", "t1", msg) - # Get last 3 messages = await store.get_messages("w1", "t1", limit=3) assert len(messages) == 3 @@ -461,20 +513,6 @@ def test_build_chat_config_defaults(self) -> None: assert config.model == "gpt-4" assert config.streaming is True - def test_build_chat_config_with_commands(self) -> None: - from pywry.mcp.builders import build_chat_config - - config = build_chat_config( - { - "slash_commands": [ - {"name": "help", "description": "Show help"}, - {"name": "/test"}, - ], - } - ) - assert len(config.slash_commands) == 2 - assert config.slash_commands[0].name == "/help" - def test_build_chat_widget_config(self) -> None: from pywry.mcp.builders import build_chat_widget_config @@ -487,7 +525,6 @@ def test_build_chat_widget_config(self) -> None: } ) assert config.title == "My Chat" - assert config.height == 700 assert config.chat_config.model == "gpt-4o" assert config.show_sidebar is False @@ -501,269 +538,228 @@ class TestBuildChatHtml: """Test build_chat_html helper.""" def test_default_includes_sidebar(self) -> None: - from pywry.chat import build_chat_html - html = build_chat_html() assert "pywry-chat-sidebar" in html assert "pywry-chat-messages" in html assert "pywry-chat-input" in html - assert "pywry-chat-settings-toggle" in html def test_no_sidebar(self) -> None: - from pywry.chat import build_chat_html - html = build_chat_html(show_sidebar=False) assert "pywry-chat-sidebar" not in html - assert "pywry-chat-messages" in html def test_no_settings(self) -> None: - from pywry.chat import build_chat_html - html = build_chat_html(show_settings=False) assert "pywry-chat-settings-toggle" not in html def test_container_id(self) -> None: - from pywry.chat import build_chat_html - html = build_chat_html(container_id="my-chat") assert 'id="my-chat"' in html def test_file_attach_disabled_by_default(self) -> None: - from pywry.chat import build_chat_html - html = build_chat_html() assert "pywry-chat-attach-btn" not in html - assert "pywry-chat-drop-overlay" not in html def test_file_attach_enabled(self) -> None: - from pywry.chat import build_chat_html - html = build_chat_html(enable_file_attach=True, file_accept_types=[".csv"]) assert "pywry-chat-attach-btn" in html assert "pywry-chat-drop-overlay" in html - def test_file_attach_requires_accept_in_html(self) -> None: - """When file_accept_types is provided, data-accept-types attribute is set.""" - from pywry.chat import build_chat_html - html = build_chat_html( - enable_file_attach=True, - file_accept_types=[".csv", ".json"], - ) - assert 'data-accept-types=".csv,.json"' in html +# ============================================================================= +# Provider Tests +# ============================================================================= - def test_file_attach_custom_accept(self) -> None: - from pywry.chat import build_chat_html - html = build_chat_html( - enable_file_attach=True, - file_accept_types=[".csv", ".xlsx"], - ) - assert 'data-accept-types=".csv,.xlsx"' in html +class TestProviderFactory: + """Test provider factory function.""" - def test_context_without_file_attach(self) -> None: - from pywry.chat import build_chat_html + def test_callback_provider(self) -> None: + from pywry.chat import get_provider - html = build_chat_html(enable_context=True, enable_file_attach=False) - # @ mention popup should be present - assert "pywry-chat-mention-popup" in html - # File attach should NOT be present - assert "pywry-chat-attach-btn" not in html - assert "pywry-chat-drop-overlay" not in html + provider = get_provider("callback") + assert provider is not None - def test_file_attach_without_context(self) -> None: - from pywry.chat import build_chat_html + def test_unknown_provider_raises(self) -> None: + from pywry.chat import get_provider - html = build_chat_html( - enable_file_attach=True, - file_accept_types=[".csv"], - enable_context=False, - ) - # File attach should be present - assert "pywry-chat-attach-btn" in html - assert "pywry-chat-drop-overlay" in html - # @ mention popup should NOT be present - assert "pywry-chat-mention-popup" not in html + with pytest.raises(ValueError, match="Unknown provider"): + get_provider("nonexistent") + + +# ============================================================================= +# Session Primitives Tests +# ============================================================================= + + +class TestSessionPrimitives: + """Test ACP session models.""" - def test_both_context_and_file_attach(self) -> None: - from pywry.chat import build_chat_html + def test_session_mode(self) -> None: + from pywry.chat.session import SessionMode - html = build_chat_html( - enable_context=True, - enable_file_attach=True, - file_accept_types=[".csv"], + mode = SessionMode(id="code", name="Code Mode", description="Write code") + assert mode.id == "code" + assert mode.name == "Code Mode" + + def test_session_config_option(self) -> None: + from pywry.chat.session import ConfigOptionChoice, SessionConfigOption + + opt = SessionConfigOption( + id="model", + name="Model", + category="model", + currentValue="gpt-4", + options=[ + ConfigOptionChoice(value="gpt-4", name="GPT-4"), + ConfigOptionChoice(value="gpt-4o", name="GPT-4o"), + ], ) - assert "pywry-chat-mention-popup" in html - assert "pywry-chat-attach-btn" in html - assert "pywry-chat-drop-overlay" in html + assert opt.current_value == "gpt-4" + assert len(opt.options) == 2 + + def test_plan_entry(self) -> None: + from pywry.chat.session import PlanEntry + + entry = PlanEntry(content="Fix the bug", priority="high", status="in_progress") + assert entry.priority == "high" + assert entry.status == "in_progress" + + def test_permission_request(self) -> None: + from pywry.chat.session import PermissionRequest + + req = PermissionRequest(toolCallId="call_1", title="Execute shell command") + assert req.tool_call_id == "call_1" + assert len(req.options) == 4 # default options + + def test_capabilities(self) -> None: + from pywry.chat.session import AgentCapabilities, ClientCapabilities + + client = ClientCapabilities(fileSystem=True, terminal=False) + assert client.file_system is True + + agent = AgentCapabilities(loadSession=True, configOptions=True) + assert agent.load_session is True # ============================================================================= -# Content Part Tests +# Update Types Tests # ============================================================================= -class TestContentParts: - """Test ChatContentPart types.""" +class TestUpdateTypes: + """Test SessionUpdate models.""" - def test_text_part(self) -> None: - part = TextPart(text="hello") - assert part.type == "text" - assert part.text == "hello" + def test_agent_message_update(self) -> None: + from pywry.chat.updates import AgentMessageUpdate - def test_image_part(self) -> None: - part = ImagePart(data="base64data", mime_type="image/png") - assert part.type == "image" - assert part.data == "base64data" - assert part.mime_type == "image/png" + u = AgentMessageUpdate(text="Hello") + assert u.session_update == "agent_message" + assert u.text == "Hello" - def test_resource_link_part(self) -> None: - part = ResourceLinkPart(uri="pywry://resource/1", name="Doc") - assert part.type == "resource_link" - assert part.name == "Doc" + def test_tool_call_update(self) -> None: + from pywry.chat.updates import ToolCallUpdate + + u = ToolCallUpdate( + toolCallId="call_1", + name="search", + kind="fetch", + status="in_progress", + ) + assert u.session_update == "tool_call" + assert u.status == "in_progress" + + def test_plan_update(self) -> None: + from pywry.chat.session import PlanEntry + from pywry.chat.updates import PlanUpdate + + u = PlanUpdate( + entries=[ + PlanEntry(content="Step 1", priority="high", status="completed"), + PlanEntry(content="Step 2", priority="medium", status="pending"), + ] + ) + assert u.session_update == "plan" + assert len(u.entries) == 2 + + def test_status_update(self) -> None: + from pywry.chat.updates import StatusUpdate + + u = StatusUpdate(text="Searching...") + assert u.session_update == "x_status" + + def test_thinking_update(self) -> None: + from pywry.chat.updates import ThinkingUpdate + + u = ThinkingUpdate(text="Let me think about this...") + assert u.session_update == "x_thinking" # ============================================================================= -# Provider Tests (import only, no API calls) +# Artifact Tests # ============================================================================= -class TestProviderFactory: - """Test provider factory function.""" +class TestArtifacts: + """Test artifact models.""" - def test_callback_provider(self) -> None: - from pywry.chat_providers import get_provider + def test_code_artifact(self) -> None: + from pywry.chat.artifacts import CodeArtifact - provider = get_provider("callback") - assert provider is not None - - def test_unknown_provider_raises(self) -> None: - from pywry.chat_providers import get_provider + a = CodeArtifact(title="example.py", content="x = 42", language="python") + assert a.artifact_type == "code" - with pytest.raises(ValueError, match="Unknown provider"): - get_provider("nonexistent") + def test_tradingview_artifact(self) -> None: + from pywry.chat.artifacts import TradingViewArtifact, TradingViewSeries - def test_callback_provider_with_fns(self) -> None: - from pywry.chat_providers import CallbackProvider - - def my_gen(messages, config): - return "Hello!" - - provider = CallbackProvider(generate_fn=my_gen) - assert provider._generate_fn is my_gen - - -class TestMagenticProvider: - """Test MagenticProvider (mocked — no real magentic dependency required).""" - - def test_import_error_without_magentic(self) -> None: - """MagenticProvider raises ImportError when magentic is not installed.""" - import sys - - # Temporarily make magentic unimportable - sentinel = sys.modules.get("magentic") - sentinel_cm = sys.modules.get("magentic.chat_model") - sentinel_cmb = sys.modules.get("magentic.chat_model.base") - sys.modules["magentic"] = None # type: ignore[assignment] - sys.modules["magentic.chat_model"] = None # type: ignore[assignment] - sys.modules["magentic.chat_model.base"] = None # type: ignore[assignment] - try: - # Re-import to pick up the blocked module - from pywry.chat_providers import MagenticProvider - - with pytest.raises(ImportError, match="magentic"): - MagenticProvider(model="gpt-4o") - finally: - if sentinel is None: - sys.modules.pop("magentic", None) - else: - sys.modules["magentic"] = sentinel - if sentinel_cm is None: - sys.modules.pop("magentic.chat_model", None) - else: - sys.modules["magentic.chat_model"] = sentinel_cm - if sentinel_cmb is None: - sys.modules.pop("magentic.chat_model.base", None) - else: - sys.modules["magentic.chat_model.base"] = sentinel_cmb - - def test_registered_in_providers(self) -> None: - """MagenticProvider is accessible via get_provider('magentic').""" - from pywry.chat_providers import _PROVIDERS, MagenticProvider - - assert "magentic" in _PROVIDERS - assert _PROVIDERS["magentic"] is MagenticProvider - - def test_type_error_on_bad_model(self) -> None: - """MagenticProvider rejects non-ChatModel, non-string model args.""" - pytest.importorskip("magentic") - from pywry.chat_providers import MagenticProvider - - with pytest.raises(TypeError, match="Expected a magentic ChatModel"): - MagenticProvider(model=12345) - - def test_string_model_creates_openai_chat_model(self, monkeypatch) -> None: - """Passing a model name string auto-wraps in OpenaiChatModel.""" - magentic = pytest.importorskip("magentic") - from pywry.chat_providers import MagenticProvider - - monkeypatch.setenv("OPENAI_API_KEY", "sk-test-fake-key") - provider = MagenticProvider(model="gpt-4o-mini") - assert isinstance(provider._model, magentic.OpenaiChatModel) - - def test_accepts_chat_model_instance(self, monkeypatch) -> None: - """Passing a ChatModel instance is stored directly.""" - magentic = pytest.importorskip("magentic") - from pywry.chat_providers import MagenticProvider - - monkeypatch.setenv("OPENAI_API_KEY", "sk-test-fake-key") - model = magentic.OpenaiChatModel("gpt-4o") - provider = MagenticProvider(model=model) - assert provider._model is model - - def test_build_messages_with_system_prompt(self, monkeypatch) -> None: - """_build_messages prepends system prompt and maps roles.""" - magentic = pytest.importorskip("magentic") - from pywry.chat import ChatConfig, ChatMessage - from pywry.chat_providers import MagenticProvider - - monkeypatch.setenv("OPENAI_API_KEY", "sk-test-fake-key") - provider = MagenticProvider(model="gpt-4o") - messages = [ - ChatMessage(role="user", content="Hello"), - ChatMessage(role="assistant", content="Hi there"), - ] - config = ChatConfig(system_prompt="You are helpful.") - result = provider._build_messages(messages, config) - - assert len(result) == 3 - assert isinstance(result[0], magentic.SystemMessage) - assert isinstance(result[1], magentic.UserMessage) - assert isinstance(result[2], magentic.AssistantMessage) - - def test_build_messages_no_system_prompt(self, monkeypatch) -> None: - """_build_messages omits system message when not configured.""" - magentic = pytest.importorskip("magentic") - from pywry.chat import ChatConfig, ChatMessage - from pywry.chat_providers import MagenticProvider - - monkeypatch.setenv("OPENAI_API_KEY", "sk-test-fake-key") - provider = MagenticProvider(model="gpt-4o") - messages = [ChatMessage(role="user", content="test")] - config = ChatConfig(system_prompt=None) - result = provider._build_messages(messages, config) - - assert len(result) == 1 - assert isinstance(result[0], magentic.UserMessage) - - def test_string_model_with_kwargs(self, monkeypatch) -> None: - """String model with extra kwargs are forwarded to OpenaiChatModel.""" - magentic = pytest.importorskip("magentic") - from pywry.chat_providers import MagenticProvider - - monkeypatch.setenv("OPENAI_API_KEY", "sk-test-fake-key") - provider = MagenticProvider( - model="gpt-4o", - base_url="http://localhost:11434/v1/", + a = TradingViewArtifact( + title="AAPL", + series=[ + TradingViewSeries( + type="candlestick", + data=[ + {"time": "2024-01-02", "open": 185, "high": 186, "low": 184, "close": 185} + ], + ), + TradingViewSeries( + type="line", + data=[{"time": "2024-01-02", "value": 185}], + options={"color": "#f9e2af"}, + ), + ], + height="500px", ) - assert isinstance(provider._model, magentic.OpenaiChatModel) + assert a.artifact_type == "tradingview" + assert len(a.series) == 2 + assert a.series[0].type == "candlestick" + assert a.series[1].type == "line" + + def test_image_artifact_blocks_javascript_url(self) -> None: + from pydantic import ValidationError + + from pywry.chat.artifacts import ImageArtifact + + with pytest.raises(ValidationError): + ImageArtifact(url="javascript:alert(1)") + + +# ============================================================================= +# Permissions Tests +# ============================================================================= + + +class TestPermissions: + """Test RBAC permission mappings.""" + + def test_permission_map(self) -> None: + from pywry.chat.permissions import ACP_PERMISSION_MAP + + assert ACP_PERMISSION_MAP["session/prompt"] == "write" + assert ACP_PERMISSION_MAP["fs/write_text_file"] == "admin" + assert ACP_PERMISSION_MAP["fs/read_text_file"] == "read" + + @pytest.mark.asyncio + async def test_check_permission_no_session(self) -> None: + from pywry.chat.permissions import check_acp_permission + + result = await check_acp_permission(None, "w1", "session/prompt", None) + assert result is True # No auth = allow all diff --git a/pywry/tests/test_chat_e2e.py b/pywry/tests/test_chat_e2e.py index d43538a..11df924 100644 --- a/pywry/tests/test_chat_e2e.py +++ b/pywry/tests/test_chat_e2e.py @@ -40,7 +40,7 @@ import pytest from pywry.chat import MAX_CONTENT_LENGTH, ChatMessage, ChatThread -from pywry.chat_manager import ChatManager +from pywry.chat.manager import ChatManager from pywry.state.memory import MemoryChatStore diff --git a/pywry/tests/test_chat_manager.py b/pywry/tests/test_chat_manager.py index 634745b..9ee2d87 100644 --- a/pywry/tests/test_chat_manager.py +++ b/pywry/tests/test_chat_manager.py @@ -3,15 +3,15 @@ Tests cover: - ChatManager construction and defaults -- Protocol response models (StatusResponse, ToolCallResponse, etc.) +- ACP update types (AgentMessageUpdate, ToolCallUpdate, etc.) - ChatContext dataclass -- SettingsItem and SlashCommandDef models +- SettingsItem model - callbacks() returns correct keys - toolbar() returns a Toolbar instance - send_message() emits and stores messages - _on_user_message dispatches handler in background thread - _handle_complete sends complete message -- _handle_stream streams str chunks and rich response types +- _handle_stream streams str chunks and SessionUpdate types - _on_stop_generation cancels active generation - Thread CRUD: create, switch, delete, rename - _on_request_state emits full initialization state @@ -24,7 +24,6 @@ from __future__ import annotations -import threading import time from typing import Any @@ -32,30 +31,31 @@ import pytest -from pywry.chat_manager import ( - ArtifactResponse, - Attachment, - ChatContext, - ChatManager, - CitationResponse, +from pywry.chat.artifacts import ( CodeArtifact, HtmlArtifact, ImageArtifact, - InputRequiredResponse, JsonArtifact, MarkdownArtifact, PlotlyArtifact, - SettingsItem, - SlashCommandDef, - StatusResponse, TableArtifact, - TextChunkResponse, - ThinkingResponse, - TodoItem, - TodoUpdateResponse, - ToolCallResponse, - ToolResultResponse, - _ArtifactBase, + TradingViewArtifact, +) +from pywry.chat.manager import ( + Attachment, + ChatContext, + ChatManager, + SettingsItem, +) +from pywry.chat.session import PlanEntry +from pywry.chat.updates import ( + AgentMessageUpdate, + ArtifactUpdate, + CitationUpdate, + PlanUpdate, + StatusUpdate, + ThinkingUpdate, + ToolCallUpdate, ) @@ -74,7 +74,6 @@ def emit(self, event_type: str, data: dict[str, Any]) -> None: self.events.append((event_type, data)) def emit_fire(self, event_type: str, data: dict[str, Any]) -> None: - """Fire-and-forget emit — same as emit for testing.""" self.events.append((event_type, data)) def get_events(self, event_type: str) -> list[dict]: @@ -102,15 +101,20 @@ def stream_handler(messages, ctx): def rich_handler(messages, ctx): - """Generator handler that yields rich protocol types.""" - yield ThinkingResponse(text="Analyzing the request...") - yield ThinkingResponse(text="Considering options.") - yield StatusResponse(text="Searching...") - yield ToolCallResponse(name="search", arguments={"q": "test"}) - yield ToolResultResponse(tool_id="call_abc", result="42") - yield CitationResponse(url="https://example.com", title="Example") - yield ArtifactResponse(title="code.py", content="print('hi')", language="python") - yield TextChunkResponse(text="Done!") + """Generator handler that yields ACP update types.""" + yield ThinkingUpdate(text="Analyzing the request...") + yield StatusUpdate(text="Searching...") + yield ToolCallUpdate( + toolCallId="call_1", + name="search", + kind="fetch", + status="completed", + ) + yield CitationUpdate(url="https://example.com", title="Example") + yield ArtifactUpdate( + artifact=CodeArtifact(title="code.py", content="x = 42", language="python") + ) + yield AgentMessageUpdate(text="Done!") @pytest.fixture @@ -143,61 +147,100 @@ def bound_manager(widget): # ============================================================================= -# Protocol Model Tests +# Update Type Tests # ============================================================================= -class TestProtocolModels: - """Test protocol response models.""" +class TestUpdateTypes: + """Test ACP update type models.""" - def test_status_response(self): - r = StatusResponse(text="Searching...") - assert r.type == "status" + def test_status_update(self): + r = StatusUpdate(text="Searching...") + assert r.session_update == "x_status" assert r.text == "Searching..." - def test_tool_call_response(self): - r = ToolCallResponse(name="search", arguments={"q": "test"}) - assert r.type == "tool_call" + def test_agent_message_update(self): + r = AgentMessageUpdate(text="Hello!") + assert r.session_update == "agent_message" + assert r.text == "Hello!" + + def test_tool_call_update(self): + r = ToolCallUpdate( + toolCallId="call_1", + name="search", + kind="fetch", + status="completed", + ) + assert r.session_update == "tool_call" assert r.name == "search" - assert r.arguments == {"q": "test"} - assert r.tool_id.startswith("call_") - - def test_tool_call_custom_id(self): - r = ToolCallResponse(tool_id="my_id", name="search") - assert r.tool_id == "my_id" - - def test_tool_result_response(self): - r = ToolResultResponse(tool_id="call_123", result="42") - assert r.type == "tool_result" - assert r.tool_id == "call_123" - assert r.result == "42" - assert r.is_error is False - - def test_tool_result_error(self): - r = ToolResultResponse(tool_id="call_123", result="fail", is_error=True) - assert r.is_error is True - - def test_citation_response(self): - r = CitationResponse(url="https://x.com", title="X", snippet="stuff") - assert r.type == "citation" - assert r.url == "https://x.com" - assert r.snippet == "stuff" - - def test_artifact_response(self): - r = ArtifactResponse(title="code.py", content="print(1)", language="python") - assert r.type == "artifact" - assert r.artifact_type == "code" - assert r.language == "python" - - def test_text_chunk_response(self): - r = TextChunkResponse(text="hello") - assert r.type == "text" - assert r.text == "hello" - - def test_thinking_response(self): - r = ThinkingResponse(text="analyzing...") - assert r.type == "thinking" - assert r.text == "analyzing..." + assert r.kind == "fetch" + + def test_plan_update(self): + r = PlanUpdate( + entries=[ + PlanEntry(content="Step 1", priority="high", status="completed"), + ] + ) + assert r.session_update == "plan" + assert len(r.entries) == 1 + + def test_thinking_update(self): + r = ThinkingUpdate(text="Let me think...") + assert r.session_update == "x_thinking" + + def test_citation_update(self): + r = CitationUpdate(url="https://example.com", title="Example") + assert r.session_update == "x_citation" + assert r.url == "https://example.com" + + +# ============================================================================= +# Artifact Tests +# ============================================================================= + + +class TestArtifactModels: + """Test artifact model creation.""" + + def test_code_artifact(self): + a = CodeArtifact(title="test.py", content="x = 1", language="python") + assert a.artifact_type == "code" + assert a.language == "python" + + def test_markdown_artifact(self): + a = MarkdownArtifact(title="README", content="# Hello") + assert a.artifact_type == "markdown" + + def test_html_artifact(self): + a = HtmlArtifact(title="page", content="

Hi

") + assert a.artifact_type == "html" + + def test_table_artifact(self): + a = TableArtifact(title="data", data=[{"a": 1}]) + assert a.artifact_type == "table" + assert a.height == "400px" + + def test_plotly_artifact(self): + a = PlotlyArtifact(title="chart", figure={"data": []}) + assert a.artifact_type == "plotly" + + def test_image_artifact(self): + a = ImageArtifact(title="photo", url="data:image/png;base64,abc") + assert a.artifact_type == "image" + + def test_json_artifact(self): + a = JsonArtifact(title="config", data={"key": "value"}) + assert a.artifact_type == "json" + + def test_tradingview_artifact(self): + from pywry.chat.artifacts import TradingViewSeries + + a = TradingViewArtifact( + title="AAPL", + series=[TradingViewSeries(type="candlestick", data=[])], + ) + assert a.artifact_type == "tradingview" + assert len(a.series) == 1 # ============================================================================= @@ -211,27 +254,64 @@ class TestChatContext: def test_defaults(self): ctx = ChatContext() assert ctx.thread_id == "" - assert ctx.message_id == "" - assert ctx.settings == {} - assert isinstance(ctx.cancel_event, threading.Event) - assert ctx.system_prompt == "" assert ctx.model == "" assert ctx.temperature == 0.7 + assert ctx.attachments == [] + assert not ctx.cancel_event.is_set() + + def test_attachment_summary_empty(self): + ctx = ChatContext() + assert ctx.attachment_summary == "" + + def test_attachment_summary_file(self): + import pathlib + + ctx = ChatContext( + attachments=[ + Attachment(type="file", name="report.csv", path=pathlib.Path("/data/report.csv")), + ] + ) + assert "report.csv" in ctx.attachment_summary + assert "report.csv" in ctx.attachment_summary + assert str(pathlib.Path("/data/report.csv")) in ctx.attachment_summary + + def test_attachment_summary_widget(self): + ctx = ChatContext( + attachments=[ + Attachment(type="widget", name="@Sales Data", content="data here"), + ] + ) + assert "@Sales Data" in ctx.attachment_summary + + def test_context_text(self): + ctx = ChatContext( + attachments=[ + Attachment(type="widget", name="@Grid", content="col1,col2\n1,2"), + ] + ) + text = ctx.context_text + assert "Grid" in text + assert "col1,col2" in text - def test_custom_values(self): - cancel = threading.Event() + def test_get_attachment_found(self): ctx = ChatContext( - thread_id="t1", - message_id="m1", - settings={"model": "gpt-4"}, - cancel_event=cancel, - system_prompt="You are helpful", - model="gpt-4", - temperature=0.5, + attachments=[ + Attachment(type="widget", name="@Sales", content="revenue=100"), + ] ) - assert ctx.thread_id == "t1" - assert ctx.settings["model"] == "gpt-4" - assert ctx.cancel_event is cancel + assert ctx.get_attachment("Sales") == "revenue=100" + assert ctx.get_attachment("@Sales") == "revenue=100" + + def test_get_attachment_not_found(self): + ctx = ChatContext(attachments=[]) + result = ctx.get_attachment("Missing") + assert "not found" in result + + def test_wait_for_input_cancel(self): + ctx = ChatContext() + ctx.cancel_event.set() + result = ctx.wait_for_input(timeout=0.1) + assert result == "" # ============================================================================= @@ -243,127 +323,40 @@ class TestSettingsItem: """Test SettingsItem model.""" def test_action(self): - s = SettingsItem(id="clear", label="Clear", type="action") + s = SettingsItem(id="clear", label="Clear History", type="action") assert s.type == "action" - assert s.value is None def test_toggle(self): - s = SettingsItem(id="stream", label="Stream", type="toggle", value=True) + s = SettingsItem(id="stream", label="Streaming", type="toggle", value=True) assert s.value is True def test_select(self): - s = SettingsItem( - id="model", - label="Model", - type="select", - value="gpt-4", - options=["gpt-4", "gpt-3.5"], - ) - assert s.options == ["gpt-4", "gpt-3.5"] + s = SettingsItem(id="model", label="Model", type="select", options=["gpt-4", "gpt-4o"]) + assert len(s.options) == 2 def test_range(self): - s = SettingsItem( - id="temp", - label="Temperature", - type="range", - value=0.7, - min=0, - max=2, - step=0.1, - ) - assert s.min == 0 - assert s.max == 2 - assert s.step == 0.1 - - def test_separator(self): - s = SettingsItem(id="sep", type="separator") - assert s.type == "separator" - assert s.label == "" - - -# ============================================================================= -# SlashCommandDef Tests -# ============================================================================= - - -class TestSlashCommandDef: - """Test SlashCommandDef model.""" - - def test_with_slash(self): - cmd = SlashCommandDef(name="/joke", description="Tell a joke") - assert cmd.name == "/joke" - - def test_without_slash(self): - cmd = SlashCommandDef(name="joke", description="Tell a joke") - assert cmd.name == "/joke" - - def test_empty_description(self): - cmd = SlashCommandDef(name="/help") - assert cmd.description == "" + s = SettingsItem(id="temp", label="Temperature", type="range", min=0.0, max=2.0, step=0.1) + assert s.min == 0.0 + assert s.max == 2.0 # ============================================================================= -# ChatManager Construction Tests +# ChatManager Tests # ============================================================================= -class TestChatManagerInit: - """Test ChatManager initialization.""" - - def test_defaults(self): - mgr = ChatManager(handler=echo_handler) - assert mgr._system_prompt == "" - assert mgr._model == "" - assert mgr._temperature == 0.7 - assert mgr._welcome_message == "" - assert mgr._settings_items == [] - assert mgr._slash_commands == [] - assert mgr._show_sidebar is True - assert mgr._show_settings is True - assert mgr._toolbar_width == "380px" - assert mgr._collapsible is True - assert mgr._resizable is True - assert len(mgr._threads) == 1 - assert mgr._active_thread != "" - - def test_custom_settings(self): - items = [ - SettingsItem(id="model", label="Model", type="select", value="gpt-4"), - ] - mgr = ChatManager(handler=echo_handler, settings=items) - assert len(mgr._settings_items) == 1 - assert mgr._settings_values == {"model": "gpt-4"} - - def test_custom_slash_commands(self): - cmds = [SlashCommandDef(name="/joke", description="Joke")] - mgr = ChatManager(handler=echo_handler, slash_commands=cmds) - assert len(mgr._slash_commands) == 1 - - def test_active_thread_property(self): - mgr = ChatManager(handler=echo_handler) - assert mgr.active_thread_id == mgr._active_thread - - def test_settings_property(self): - items = [SettingsItem(id="k", label="K", type="toggle", value=True)] - mgr = ChatManager(handler=echo_handler, settings=items) - assert mgr.settings == {"k": True} +class TestChatManager: + """Test ChatManager construction and public API.""" - def test_threads_property(self): + def test_construction(self): mgr = ChatManager(handler=echo_handler) - threads = mgr.threads - assert len(threads) == 1 - assert all(isinstance(v, list) for v in threads.values()) - - -# ============================================================================= -# callbacks() and toolbar() Tests -# ============================================================================= - + assert mgr.active_thread_id # has a default thread -class TestCallbacksAndToolbar: - """Test callbacks() and toolbar() public methods.""" + def test_requires_handler_or_provider(self): + with pytest.raises(ValueError, match="Either"): + ChatManager() - def test_callbacks_keys(self, manager): + def test_callbacks_returns_expected_keys(self, manager): cbs = manager.callbacks() expected = { "chat:user-message", @@ -379,3016 +372,146 @@ def test_callbacks_keys(self, manager): "chat:input-response", } assert set(cbs.keys()) == expected - assert all(callable(v) for v in cbs.values()) - - def test_toolbar_returns_toolbar(self, manager): - tb = manager.toolbar() - from pywry.toolbar import Toolbar - - assert isinstance(tb, Toolbar) - - def test_toolbar_position(self, manager): - tb = manager.toolbar(position="left") - assert tb.position == "left" - - -# ============================================================================= -# bind() Tests -# ============================================================================= - - -class TestBind: - """Test bind().""" - - def test_bind_sets_widget(self, manager, widget): - assert manager._widget is None - manager.bind(widget) - assert manager._widget is widget - - -# ============================================================================= -# send_message() Tests -# ============================================================================= + def test_settings_property(self): + mgr = ChatManager( + handler=echo_handler, + settings=[ + SettingsItem(id="model", label="Model", type="select", value="gpt-4"), + ], + ) + assert mgr.settings["model"] == "gpt-4" -class TestSendMessage: - """Test send_message() public helper.""" - - def test_sends_and_stores(self, bound_manager, widget): - thread_id = bound_manager.active_thread_id - bound_manager.send_message("Hello!", thread_id) - + def test_send_message(self, bound_manager, widget): + bound_manager.send_message("Hello from code") events = widget.get_events("chat:assistant-message") assert len(events) == 1 - assert events[0]["text"] == "Hello!" - assert events[0]["threadId"] == thread_id - - # Message stored in thread history - msgs = bound_manager._threads[thread_id] - assert len(msgs) == 1 - assert msgs[0]["role"] == "assistant" - assert msgs[0]["text"] == "Hello!" + assert events[0]["text"] == "Hello from code" - def test_defaults_to_active_thread(self, bound_manager, widget): - bound_manager.send_message("Hi") - events = widget.get_events("chat:assistant-message") - assert events[0]["threadId"] == bound_manager.active_thread_id - - -# ============================================================================= -# _on_user_message Tests -# ============================================================================= - - -class TestOnUserMessage: - """Test _on_user_message event handler.""" + def test_send_message_stores_in_thread(self, bound_manager): + tid = bound_manager.active_thread_id + bound_manager.send_message("stored") + assert len(bound_manager.threads[tid]) == 1 + assert bound_manager.threads[tid][0]["text"] == "stored" - def test_empty_text_ignored(self, bound_manager, widget): - bound_manager._on_user_message({"text": ""}, "", "") - assert len(widget.events) == 0 - def test_stores_user_message(self, bound_manager): - tid = bound_manager.active_thread_id - bound_manager._on_user_message({"text": "Hi", "threadId": tid}, "", "") - msgs = bound_manager._threads[tid] - assert any(m["role"] == "user" and m["text"] == "Hi" for m in msgs) +class TestChatManagerHandlerDispatch: + """Test handler invocation and stream processing.""" - def test_handler_runs_in_thread(self, widget): - """Verify the handler is called and produces output.""" + def test_echo_handler(self, widget): mgr = ChatManager(handler=echo_handler) mgr.bind(widget) - tid = mgr.active_thread_id - - mgr._on_user_message({"text": "Hello", "threadId": tid}, "", "") - # Wait for background thread to finish - time.sleep(0.5) - - # Should have typing indicator on/off + assistant message - assistant_msgs = widget.get_events("chat:assistant-message") - assert len(assistant_msgs) == 1 - assert "Echo: Hello" in assistant_msgs[0]["text"] - - -# ============================================================================= -# _handle_complete Tests -# ============================================================================= - - -class TestHandleComplete: - """Test _handle_complete sends a full message.""" - - def test_emits_and_stores(self, bound_manager, widget): - tid = bound_manager.active_thread_id - bound_manager._handle_complete("Full response", "msg_001", tid) - + mgr._on_user_message( + {"text": "hello", "threadId": mgr.active_thread_id}, + "chat:user-message", + "", + ) + # Wait for background thread + time.sleep(0.3) events = widget.get_events("chat:assistant-message") - assert len(events) == 1 - assert events[0]["text"] == "Full response" - assert events[0]["messageId"] == "msg_001" - - msgs = bound_manager._threads[tid] - assert len(msgs) == 1 - assert msgs[0]["text"] == "Full response" - - -# ============================================================================= -# _handle_stream Tests -# ============================================================================= - - -class TestHandleStream: - """Test _handle_stream with various response types.""" - - def test_string_chunks(self, bound_manager, widget): - tid = bound_manager.active_thread_id - cancel = threading.Event() - - def gen(): - yield "Hello " - yield "World" - - bound_manager._handle_stream(gen(), "msg_001", tid, cancel) - - chunks = widget.get_events("chat:stream-chunk") - # "Hello ", "World", and final done chunk - assert len(chunks) == 3 - assert chunks[0]["chunk"] == "Hello " - assert chunks[1]["chunk"] == "World" - assert chunks[2]["done"] is True - - # Full text stored - msgs = bound_manager._threads[tid] - assert msgs[0]["text"] == "Hello World" - - def test_text_chunk_response(self, bound_manager, widget): - tid = bound_manager.active_thread_id - cancel = threading.Event() - - def gen(): - yield TextChunkResponse(text="Chunk1") - yield TextChunkResponse(text="Chunk2") - - bound_manager._handle_stream(gen(), "msg_001", tid, cancel) - - chunks = widget.get_events("chat:stream-chunk") - assert chunks[0]["chunk"] == "Chunk1" - assert chunks[1]["chunk"] == "Chunk2" - - def test_status_response(self, bound_manager, widget): - tid = bound_manager.active_thread_id - cancel = threading.Event() - - def gen(): - yield StatusResponse(text="Searching...") - yield "result" - - bound_manager._handle_stream(gen(), "msg_001", tid, cancel) - - statuses = widget.get_events("chat:status-update") - assert len(statuses) == 1 - assert statuses[0]["text"] == "Searching..." - - def test_tool_call_response(self, bound_manager, widget): - tid = bound_manager.active_thread_id - cancel = threading.Event() - - def gen(): - yield ToolCallResponse(tool_id="tc1", name="search", arguments={"q": "test"}) - - bound_manager._handle_stream(gen(), "msg_001", tid, cancel) - - tools = widget.get_events("chat:tool-call") - assert len(tools) == 1 - assert tools[0]["name"] == "search" - assert tools[0]["toolId"] == "tc1" - - def test_tool_result_response(self, bound_manager, widget): - tid = bound_manager.active_thread_id - cancel = threading.Event() - - def gen(): - yield ToolResultResponse(tool_id="tc1", result="42") - - bound_manager._handle_stream(gen(), "msg_001", tid, cancel) - - results = widget.get_events("chat:tool-result") - assert len(results) == 1 - assert results[0]["result"] == "42" - assert results[0]["isError"] is False - - def test_citation_response(self, bound_manager, widget): - tid = bound_manager.active_thread_id - cancel = threading.Event() - - def gen(): - yield CitationResponse(url="https://x.com", title="X", snippet="s") - - bound_manager._handle_stream(gen(), "msg_001", tid, cancel) - - citations = widget.get_events("chat:citation") - assert len(citations) == 1 - assert citations[0]["url"] == "https://x.com" - - def test_artifact_response(self, bound_manager, widget): - tid = bound_manager.active_thread_id - cancel = threading.Event() - - def gen(): - yield ArtifactResponse(title="code.py", content="print(1)", language="python") - - bound_manager._handle_stream(gen(), "msg_001", tid, cancel) - - artifacts = widget.get_events("chat:artifact") - assert len(artifacts) == 1 - assert artifacts[0]["title"] == "code.py" - assert artifacts[0]["language"] == "python" - - def test_cancellation(self, bound_manager, widget): - tid = bound_manager.active_thread_id - cancel = threading.Event() - - def gen(): - yield "partial " - cancel.set() - yield "ignored" - - bound_manager._handle_stream(gen(), "msg_001", tid, cancel) + assert any("Echo: hello" in e.get("text", "") for e in events) + def test_stream_handler(self, widget): + mgr = ChatManager(handler=stream_handler) + mgr.bind(widget) + mgr._on_user_message( + {"text": "a b c", "threadId": mgr.active_thread_id}, + "chat:user-message", + "", + ) + time.sleep(0.3) chunks = widget.get_events("chat:stream-chunk") - # First chunk + done-with-stopped + # Should have streaming chunks + done + assert len(chunks) > 0 done_chunks = [c for c in chunks if c.get("done")] - assert any(c.get("stopped") for c in done_chunks) - - def test_thinking_response(self, bound_manager, widget): - tid = bound_manager.active_thread_id - cancel = threading.Event() - - def gen(): - yield ThinkingResponse(text="Step 1...") - yield ThinkingResponse(text="Step 2...") - yield "Answer" - - bound_manager._handle_stream(gen(), "msg_001", tid, cancel) - - thinking_events = widget.get_events("chat:thinking-chunk") - assert len(thinking_events) == 2 - assert thinking_events[0]["text"] == "Step 1..." - assert thinking_events[1]["text"] == "Step 2..." - - # Thinking done is emitted at end of stream - done_events = widget.get_events("chat:thinking-done") - assert len(done_events) == 1 + assert len(done_chunks) >= 1 - # Thinking is NOT in the stored text - msgs = bound_manager._threads[tid] - assert msgs[0]["text"] == "Answer" + def test_stop_generation(self, widget): + def slow_handler(messages, ctx): + for i in range(100): + if ctx.cancel_event.is_set(): + return + yield f"chunk{i} " + time.sleep(0.01) - def test_rich_handler_all_types(self, widget): - """Verify rich_handler emits all protocol types.""" - mgr = ChatManager(handler=rich_handler) + mgr = ChatManager(handler=slow_handler) mgr.bind(widget) - tid = mgr.active_thread_id - cancel = threading.Event() - - mgr._handle_stream(rich_handler([], None), "msg_001", tid, cancel) - - assert len(widget.get_events("chat:thinking-chunk")) == 2 - assert len(widget.get_events("chat:status-update")) == 1 - assert len(widget.get_events("chat:tool-call")) == 1 - assert len(widget.get_events("chat:tool-result")) == 1 - assert len(widget.get_events("chat:citation")) == 1 - assert len(widget.get_events("chat:artifact")) == 1 - assert len(widget.get_events("chat:thinking-done")) == 1 - assert widget.get_events("chat:stream-chunk")[-1]["done"] is True - - -# ============================================================================= -# _on_stop_generation Tests -# ============================================================================= - - -class TestStopGeneration: - """Test _on_stop_generation.""" - - def test_sets_cancel_event(self, bound_manager): - cancel = threading.Event() - tid = bound_manager.active_thread_id - bound_manager._cancel_events[tid] = cancel - assert not cancel.is_set() - - bound_manager._on_stop_generation({"threadId": tid}, "", "") - assert cancel.is_set() - - def test_no_crash_on_missing_thread(self, bound_manager): - # Should not raise - bound_manager._on_stop_generation({"threadId": "nonexistent"}, "", "") - - -# ============================================================================= -# Thread CRUD Tests -# ============================================================================= + mgr._on_user_message( + {"text": "go", "threadId": mgr.active_thread_id}, + "chat:user-message", + "", + ) + time.sleep(0.05) + mgr._on_stop_generation( + {"threadId": mgr.active_thread_id}, + "chat:stop-generation", + "", + ) + time.sleep(0.3) + chunks = widget.get_events("chat:stream-chunk") + stopped = [c for c in chunks if c.get("stopped")] + assert len(stopped) >= 1 -class TestThreadCRUD: - """Test thread create, switch, delete, rename.""" +class TestChatManagerThreads: + """Test thread CRUD operations.""" def test_create_thread(self, bound_manager, widget): - old_count = len(bound_manager._threads) - bound_manager._on_thread_create({}, "", "") - - assert len(bound_manager._threads) == old_count + 1 - # Active thread switched to new one - assert bound_manager.active_thread_id != "" - # Events emitted - assert len(widget.get_events("chat:update-thread-list")) == 1 - assert len(widget.get_events("chat:switch-thread")) == 1 - - def test_create_with_title(self, bound_manager, widget): - bound_manager._on_thread_create({"title": "My Thread"}, "", "") - new_tid = bound_manager.active_thread_id - assert bound_manager._thread_titles[new_tid] == "My Thread" + bound_manager._on_thread_create({"title": "New Thread"}, "", "") + events = widget.get_events("chat:update-thread-list") + assert len(events) >= 1 + assert len(bound_manager.threads) == 2 def test_switch_thread(self, bound_manager, widget): - # Create a second thread - bound_manager._on_thread_create({}, "", "") - second_tid = bound_manager.active_thread_id - first_tid = next(t for t in bound_manager._threads if t != second_tid) - - widget.clear() - bound_manager._on_thread_switch({"threadId": first_tid}, "", "") - - assert bound_manager.active_thread_id == first_tid - assert len(widget.get_events("chat:switch-thread")) == 1 - - def test_switch_nonexistent_thread(self, bound_manager, widget): - old = bound_manager.active_thread_id - bound_manager._on_thread_switch({"threadId": "nonexistent"}, "", "") - assert bound_manager.active_thread_id == old + bound_manager._on_thread_create({"title": "Thread 2"}, "", "") + new_tid = bound_manager.active_thread_id + old_tid = next(t for t in bound_manager.threads if t != new_tid) + bound_manager._on_thread_switch({"threadId": old_tid}, "", "") + assert bound_manager.active_thread_id == old_tid def test_delete_thread(self, bound_manager, widget): - # Create a second thread so deletion doesn't leave empty - bound_manager._on_thread_create({}, "", "") - second_tid = bound_manager.active_thread_id - widget.clear() - - bound_manager._on_thread_delete({"threadId": second_tid}, "", "") - - assert second_tid not in bound_manager._threads - assert len(widget.get_events("chat:update-thread-list")) == 1 - - def test_delete_active_switches(self, bound_manager, widget): - # Create two threads, delete the active one - first_tid = bound_manager.active_thread_id - bound_manager._on_thread_create({}, "", "") - second_tid = bound_manager.active_thread_id - widget.clear() - - bound_manager._on_thread_delete({"threadId": second_tid}, "", "") - assert bound_manager.active_thread_id == first_tid + bound_manager._on_thread_create({"title": "To Delete"}, "", "") + tid = bound_manager.active_thread_id + bound_manager._on_thread_delete({"threadId": tid}, "", "") + assert tid not in bound_manager.threads def test_rename_thread(self, bound_manager, widget): tid = bound_manager.active_thread_id - bound_manager._on_thread_rename({"threadId": tid, "title": "New Name"}, "", "") - assert bound_manager._thread_titles[tid] == "New Name" - assert len(widget.get_events("chat:update-thread-list")) == 1 + bound_manager._on_thread_rename({"threadId": tid, "title": "Renamed"}, "", "") + events = widget.get_events("chat:update-thread-list") + assert len(events) >= 1 -# ============================================================================= -# _on_settings_change_event Tests -# ============================================================================= +class TestChatManagerState: + """Test state management.""" + def test_request_state(self, bound_manager, widget): + bound_manager._on_request_state({}, "", "") + events = widget.get_events("chat:state-response") + assert len(events) == 1 + state = events[0] + assert "threads" in state + assert "activeThreadId" in state -class TestSettingsChange: - """Test settings change handler.""" + def test_request_state_with_welcome(self, widget): + mgr = ChatManager(handler=echo_handler, welcome_message="Welcome!") + mgr.bind(widget) + mgr._on_request_state({}, "", "") + events = widget.get_events("chat:state-response") + assert len(events) == 1 + messages = events[0]["messages"] + assert any("Welcome!" in m.get("content", "") for m in messages) - def test_updates_value(self, bound_manager): - bound_manager._on_settings_change_event({"key": "model", "value": "claude-3"}, "", "") - assert bound_manager._settings_values["model"] == "claude-3" - - def test_clear_history_action(self, bound_manager, widget): - tid = bound_manager.active_thread_id - bound_manager._threads[tid] = [{"role": "user", "text": "hi"}] - - bound_manager._on_settings_change_event({"key": "clear-history"}, "", "") - assert bound_manager._threads[tid] == [] - assert len(widget.get_events("chat:clear")) == 1 - - def test_delegates_to_callback(self): + def test_settings_change(self, bound_manager, widget): callback = MagicMock() - mgr = ChatManager(handler=echo_handler, on_settings_change=callback) - mgr.bind(FakeWidget()) - - mgr._on_settings_change_event({"key": "model", "value": "gpt-4"}, "", "") - callback.assert_called_once_with("model", "gpt-4") - - -# ============================================================================= -# _on_slash_command_event Tests -# ============================================================================= - + bound_manager._on_settings_change = callback + bound_manager._on_settings_change_event({"key": "model", "value": "gpt-4o"}, "", "") + assert bound_manager.settings["model"] == "gpt-4o" + callback.assert_called_once_with("model", "gpt-4o") -class TestSlashCommand: - """Test slash command handler.""" - - def test_builtin_clear(self, bound_manager, widget): + def test_slash_command_clear(self, bound_manager, widget): tid = bound_manager.active_thread_id - bound_manager._threads[tid] = [{"role": "user", "text": "hi"}] - + bound_manager.send_message("test") + assert len(bound_manager.threads[tid]) == 1 bound_manager._on_slash_command_event({"command": "/clear", "threadId": tid}, "", "") - assert bound_manager._threads[tid] == [] - assert len(widget.get_events("chat:clear")) == 1 - - def test_delegates_to_callback(self): - callback = MagicMock() - mgr = ChatManager(handler=echo_handler, on_slash_command=callback) - mgr.bind(FakeWidget()) - tid = mgr.active_thread_id - - mgr._on_slash_command_event({"command": "/joke", "args": "", "threadId": tid}, "", "") - callback.assert_called_once_with("/joke", "", tid) - - -# ============================================================================= -# _on_request_state Tests -# ============================================================================= - - -class TestRequestState: - """Test state initialization response.""" - - def test_emits_state_response(self, bound_manager, widget): - bound_manager._on_request_state({}, "", "") - - states = widget.get_events("chat:state-response") - assert len(states) == 1 - assert "threads" in states[0] - assert "activeThreadId" in states[0] - - def test_registers_slash_commands(self): - cmds = [SlashCommandDef(name="/joke", description="Joke")] - mgr = ChatManager(handler=echo_handler, slash_commands=cmds) - w = FakeWidget() - mgr.bind(w) - - mgr._on_request_state({}, "", "") - - registered = w.get_events("chat:register-command") - names = [r["name"] for r in registered] - assert "/joke" in names - assert "/clear" in names # always registered - - def test_registers_settings(self): - items = [SettingsItem(id="model", label="Model", type="select", value="gpt-4")] - mgr = ChatManager(handler=echo_handler, settings=items) - w = FakeWidget() - mgr.bind(w) - - mgr._on_request_state({}, "", "") - - settings = w.get_events("chat:register-settings-item") - assert len(settings) == 1 - assert settings[0]["id"] == "model" - - def test_sends_welcome_message(self): - mgr = ChatManager(handler=echo_handler, welcome_message="Welcome!") - w = FakeWidget() - mgr.bind(w) - - mgr._on_request_state({}, "", "") - - states = w.get_events("chat:state-response") - assert len(states) == 1 - assert len(states[0]["messages"]) == 1 - assert states[0]["messages"][0]["content"] == "Welcome!" - - def test_no_welcome_if_empty(self, bound_manager, widget): - bound_manager._on_request_state({}, "", "") - - states = widget.get_events("chat:state-response") - assert len(states) == 1 - assert len(states[0]["messages"]) == 0 - - def test_eager_aggrid_injection(self): - """include_aggrid=True marks assets as already sent (page template loads them).""" - mgr = ChatManager(handler=echo_handler, include_aggrid=True) - w = FakeWidget() - mgr.bind(w) - - # Assets are already on the page — _on_request_state must NOT re-inject. - mgr._on_request_state({}, "", "") - - assets = w.get_events("chat:load-assets") - assert len(assets) == 0 - assert mgr._aggrid_assets_sent is True - - def test_eager_plotly_injection(self): - """include_plotly=True marks assets as already sent (page template loads them).""" - mgr = ChatManager(handler=echo_handler, include_plotly=True) - w = FakeWidget() - mgr.bind(w) - - mgr._on_request_state({}, "", "") - - assets = w.get_events("chat:load-assets") - assert len(assets) == 0 - assert mgr._plotly_assets_sent is True - - def test_eager_both_injection(self): - """Both include flags mark both asset sets as sent — no re-injection.""" - mgr = ChatManager(handler=echo_handler, include_aggrid=True, include_plotly=True) - w = FakeWidget() - mgr.bind(w) - - mgr._on_request_state({}, "", "") - - assets = w.get_events("chat:load-assets") - assert len(assets) == 0 - - def test_no_eager_injection_by_default(self, bound_manager, widget): - """Without include flags, no assets are injected on request-state.""" - bound_manager._on_request_state({}, "", "") - - assets = widget.get_events("chat:load-assets") - assert len(assets) == 0 - - def test_custom_aggrid_theme(self): - """aggrid_theme parameter is used when injecting assets.""" - mgr = ChatManager(handler=echo_handler, include_aggrid=True, aggrid_theme="quartz") - assert mgr._aggrid_theme == "quartz" - - -# ============================================================================= -# _build_thread_list Tests -# ============================================================================= - - -class TestBuildThreadList: - """Test _build_thread_list helper.""" - - def test_returns_list_of_dicts(self, manager): - result = manager._build_thread_list() - assert len(result) == 1 - assert "thread_id" in result[0] - assert "title" in result[0] - - def test_uses_custom_titles(self, manager): - tid = manager.active_thread_id - manager._thread_titles[tid] = "Custom Title" - result = manager._build_thread_list() - assert result[0]["title"] == "Custom Title" - - -# ============================================================================= -# Error handling in _run_handler Tests -# ============================================================================= - - -class TestRunHandlerErrors: - """Test error handling in _run_handler.""" - - def test_handler_exception_sends_error_message(self): - def bad_handler(messages, ctx): - raise ValueError("Something broke") - - mgr = ChatManager(handler=bad_handler) - w = FakeWidget() - mgr.bind(w) - tid = mgr.active_thread_id - - mgr._on_user_message({"text": "Hi", "threadId": tid}, "", "") - time.sleep(0.5) - - msgs = w.get_events("chat:assistant-message") - assert len(msgs) == 1 - assert "Something broke" in msgs[0]["text"] - - def test_generator_exception_sends_error(self): - def broken_gen(messages, ctx): - yield "partial" - raise RuntimeError("Stream error") - - mgr = ChatManager(handler=broken_gen) - w = FakeWidget() - mgr.bind(w) - tid = mgr.active_thread_id - - mgr._on_user_message({"text": "Hi", "threadId": tid}, "", "") - time.sleep(0.5) - - msgs = w.get_events("chat:assistant-message") - assert any("Stream error" in m.get("text", "") for m in msgs) - - -# ============================================================================= -# Integration: streaming handler end-to-end -# ============================================================================= - - -class TestStreamingIntegration: - """End-to-end streaming handler test.""" - - def test_stream_handler_produces_chunks(self): - mgr = ChatManager(handler=stream_handler) - w = FakeWidget() - mgr.bind(w) - tid = mgr.active_thread_id - - mgr._on_user_message({"text": "Hello World", "threadId": tid}, "", "") - time.sleep(0.5) - - chunks = w.get_events("chat:stream-chunk") - assert len(chunks) >= 3 # "Hello ", "World", done - done_chunks = [c for c in chunks if c.get("done")] - assert len(done_chunks) == 1 - - # Full text stored - msgs = mgr._threads[tid] - assistant_msgs = [m for m in msgs if m["role"] == "assistant"] - assert len(assistant_msgs) == 1 - assert assistant_msgs[0]["text"] == "Hello World" - - -# ============================================================================= -# TodoItem and TodoUpdateResponse Tests -# ============================================================================= - - -class TestTodoItem: - """Test TodoItem model.""" - - def test_defaults(self): - item = TodoItem(id=1, title="Do something") - assert item.id == 1 - assert item.title == "Do something" - assert item.status == "not-started" - - def test_statuses(self): - for status in ["not-started", "in-progress", "completed"]: - item = TodoItem(id=1, title="test", status=status) - assert item.status == status - - def test_string_id(self): - item = TodoItem(id="task-abc", title="test") - assert item.id == "task-abc" - - -class TestTodoUpdateResponse: - """Test TodoUpdateResponse model.""" - - def test_empty(self): - r = TodoUpdateResponse() - assert r.type == "todo" - assert r.items == [] - - def test_with_items(self): - r = TodoUpdateResponse( - items=[ - TodoItem(id=1, title="A", status="completed"), - TodoItem(id=2, title="B", status="in-progress"), - ] - ) - assert len(r.items) == 2 - assert r.items[0].status == "completed" - - -class TestTodoManagement: - """Test ChatManager todo public API.""" - - def test_update_todos(self, widget): - mgr = ChatManager(handler=echo_handler) - mgr.bind(widget) - - items = [ - TodoItem(id=1, title="Step 1", status="completed"), - TodoItem(id=2, title="Step 2", status="in-progress"), - ] - mgr.update_todos(items) - - events = widget.get_events("chat:todo-update") - assert len(events) == 1 - assert len(events[0]["items"]) == 2 - assert events[0]["items"][0]["title"] == "Step 1" - assert mgr._todo_items == items - - def test_clear_todos(self, widget): - mgr = ChatManager(handler=echo_handler) - mgr.bind(widget) - mgr.update_todos([TodoItem(id=1, title="X")]) - widget.clear() - - mgr.clear_todos() - - events = widget.get_events("chat:todo-update") - assert len(events) == 1 - assert events[0]["items"] == [] - assert mgr._todo_items == [] - - def test_on_todo_clear_callback(self, widget): - mgr = ChatManager(handler=echo_handler) - mgr.bind(widget) - mgr._todo_items = [TodoItem(id=1, title="X")] - - mgr._on_todo_clear({}, "", "") - - assert mgr._todo_items == [] - events = widget.get_events("chat:todo-update") - assert events[0]["items"] == [] - - def test_todo_in_callbacks(self): - mgr = ChatManager(handler=echo_handler) - cbs = mgr.callbacks() - assert "chat:todo-clear" in cbs - - def test_todo_update_response_in_stream(self, widget): - """Verify TodoUpdateResponse is dispatched during streaming.""" - - def todo_handler(messages, ctx): - yield TodoUpdateResponse( - items=[ - TodoItem(id=1, title="Thinking", status="in-progress"), - ] - ) - yield "Hello" - yield TodoUpdateResponse( - items=[ - TodoItem(id=1, title="Thinking", status="completed"), - ] - ) - - mgr = ChatManager(handler=todo_handler) - mgr.bind(widget) - tid = mgr.active_thread_id - cancel = threading.Event() - - mgr._handle_stream(todo_handler([], None), "msg_001", tid, cancel) - - todo_events = widget.get_events("chat:todo-update") - assert len(todo_events) == 2 - assert todo_events[0]["items"][0]["status"] == "in-progress" - assert todo_events[1]["items"][0]["status"] == "completed" - - # Todo is NOT stored in message history - msgs = mgr._threads[tid] - assert msgs[0]["text"] == "Hello" - - def test_todo_items_stored_in_manager(self, widget): - """Verify _todo_items is updated when TodoUpdateResponse is streamed.""" - - def handler(messages, ctx): - yield TodoUpdateResponse( - items=[ - TodoItem(id=1, title="A"), - TodoItem(id=2, title="B"), - ] - ) - - mgr = ChatManager(handler=handler) - mgr.bind(widget) - tid = mgr.active_thread_id - cancel = threading.Event() - - mgr._handle_stream(handler([], None), "msg_001", tid, cancel) - assert len(mgr._todo_items) == 2 - - -# ============================================================================= -# InputRequiredResponse Tests -# ============================================================================= - - -class TestInputRequiredResponse: - """Test InputRequiredResponse model.""" - - def test_defaults(self): - r = InputRequiredResponse() - assert r.type == "input_required" - assert r.prompt == "" - assert r.placeholder == "Type your response..." - assert r.request_id.startswith("input_") - assert r.input_type == "text" - assert r.options is None - - def test_custom_values(self): - r = InputRequiredResponse( - prompt="Which file?", - placeholder="Enter filename...", - request_id="req_custom", - ) - assert r.prompt == "Which file?" - assert r.placeholder == "Enter filename..." - assert r.request_id == "req_custom" - - def test_buttons_type(self): - r = InputRequiredResponse( - prompt="Approve?", - input_type="buttons", - ) - assert r.input_type == "buttons" - assert r.options is None - - def test_buttons_with_custom_options(self): - r = InputRequiredResponse( - prompt="Pick one", - input_type="buttons", - options=["Accept", "Reject", "Skip"], - ) - assert r.input_type == "buttons" - assert r.options == ["Accept", "Reject", "Skip"] - - def test_radio_type(self): - r = InputRequiredResponse( - prompt="Select model:", - input_type="radio", - options=["GPT-4", "Claude", "Gemini"], - ) - assert r.input_type == "radio" - assert r.options == ["GPT-4", "Claude", "Gemini"] - - -class TestWaitForInput: - """Test ChatContext.wait_for_input().""" - - def test_returns_response_text(self): - ctx = ChatContext() - ctx._input_response = "yes" - ctx._input_event.set() - - result = ctx.wait_for_input() - assert result == "yes" - # Event is cleared after reading - assert not ctx._input_event.is_set() - # Response is cleared - assert ctx._input_response == "" - - def test_returns_empty_on_cancel(self): - ctx = ChatContext() - ctx.cancel_event.set() - - result = ctx.wait_for_input() - assert result == "" - - def test_returns_empty_on_timeout(self): - ctx = ChatContext() - result = ctx.wait_for_input(timeout=0.1) - assert result == "" - - def test_blocks_until_set(self): - ctx = ChatContext() - - def _set_later(): - time.sleep(0.1) - ctx._input_response = "answer" - ctx._input_event.set() - - t = threading.Thread(target=_set_later, daemon=True) - t.start() - - result = ctx.wait_for_input() - assert result == "answer" - - -class TestOnInputResponse: - """Test ChatManager._on_input_response callback.""" - - def test_resumes_handler(self, widget): - ctx = ChatContext() - mgr = ChatManager(handler=echo_handler) - mgr.bind(widget) - tid = mgr.active_thread_id - - # Simulate a pending input request - mgr._pending_inputs["req_001"] = { - "ctx": ctx, - "thread_id": tid, - } - - mgr._on_input_response( - {"requestId": "req_001", "text": "yes", "threadId": tid}, - "", - "", - ) - - # Context should have the response - assert ctx._input_response == "yes" - assert ctx._input_event.is_set() - - # Pending input cleared - assert "req_001" not in mgr._pending_inputs - - # User response stored in thread history - msgs = mgr._threads[tid] - assert any(m["role"] == "user" and m["text"] == "yes" for m in msgs) - - def test_unknown_request_id_ignored(self, widget): - mgr = ChatManager(handler=echo_handler) - mgr.bind(widget) - - # Should not raise - mgr._on_input_response({"requestId": "nonexistent", "text": "hello"}, "", "") - - def test_input_response_in_callbacks(self): - mgr = ChatManager(handler=echo_handler) - cbs = mgr.callbacks() - assert "chat:input-response" in cbs - - -class TestInputRequiredInStream: - """Test InputRequiredResponse dispatch in _handle_stream.""" - - def test_finalizes_stream_and_emits_event(self, widget): - """Verify stream is finalized and input-required event emitted.""" - ctx = ChatContext() - - def handler(messages, c): - yield "Before question " - yield InputRequiredResponse( - prompt="Pick one", - placeholder="A or B", - request_id="req_test", - ) - answer = ctx.wait_for_input(timeout=2.0) - yield f"You picked: {answer}" - - mgr = ChatManager(handler=handler) - mgr.bind(widget) - tid = mgr.active_thread_id - cancel = threading.Event() - - # Run in thread so we can simulate user response - t = threading.Thread( - target=mgr._handle_stream, - args=(handler([], ctx), "msg_001", tid, cancel), - kwargs={"ctx": ctx}, - daemon=True, - ) - t.start() - - # Wait for input-required event - for _ in range(50): - if widget.get_events("chat:input-required"): - break - time.sleep(0.05) - - # Simulate user response - ctx._input_response = "user answer" - ctx._input_event.set() - t.join(timeout=3.0) - - # Stream chunk done emitted (finalizing first batch) - done_chunks = [c for c in widget.get_events("chat:stream-chunk") if c.get("done")] - assert len(done_chunks) >= 1 - - # Input-required event emitted - ir_events = widget.get_events("chat:input-required") - assert len(ir_events) == 1 - assert ir_events[0]["requestId"] == "req_test" - assert ir_events[0]["prompt"] == "Pick one" - assert ir_events[0]["placeholder"] == "A or B" - assert ir_events[0]["inputType"] == "text" - assert ir_events[0]["options"] == [] - - # Thinking-done emitted to collapse any open block - assert len(widget.get_events("chat:thinking-done")) >= 1 - - # First batch stored in history - msgs = mgr._threads[tid] - first_msg = [m for m in msgs if m.get("text") == "Before question "] - assert len(first_msg) == 1 - - def test_continuation_uses_new_message_id(self, widget): - """After input, streaming continues with a new message ID.""" - ctx = ChatContext() - - def handler(messages, c): - yield "Part 1" - yield InputRequiredResponse(request_id="req_x") - ctx.wait_for_input(timeout=2.0) - yield "Part 2" - - mgr = ChatManager(handler=handler) - mgr.bind(widget) - tid = mgr.active_thread_id - cancel = threading.Event() - - t = threading.Thread( - target=mgr._handle_stream, - args=(handler([], ctx), "msg_001", tid, cancel), - kwargs={"ctx": ctx}, - daemon=True, - ) - t.start() - - for _ in range(50): - if widget.get_events("chat:input-required"): - break - time.sleep(0.05) - - ctx._input_response = "yes" - ctx._input_event.set() - t.join(timeout=3.0) - - # Collect all stream-chunk messageIds - chunks = widget.get_events("chat:stream-chunk") - message_ids = {c["messageId"] for c in chunks} - # Should have at least 2 different message IDs - assert len(message_ids) >= 2 - - def test_stores_pending_input(self, widget): - """Verify pending input is stored for lookup by _on_input_response.""" - ctx = ChatContext() - - def handler(messages, c): - yield InputRequiredResponse(request_id="req_pending") - # Block forever — test won't reach here - ctx.wait_for_input(timeout=0.05) - - mgr = ChatManager(handler=handler) - mgr.bind(widget) - tid = mgr.active_thread_id - cancel = threading.Event() - - mgr._handle_stream(handler([], ctx), "msg_001", tid, cancel, ctx=ctx) - - # After handler times out, pending_inputs should have been - # populated (and may still be there if not consumed) - # The input-required event was emitted - ir_events = widget.get_events("chat:input-required") - assert len(ir_events) == 1 - assert ir_events[0]["requestId"] == "req_pending" - - def test_buttons_type_in_stream(self, widget): - """Verify buttons input_type and options are emitted.""" - ctx = ChatContext() - - def handler(messages, c): - yield InputRequiredResponse( - prompt="Approve?", - input_type="buttons", - options=["Accept", "Reject"], - request_id="req_btn", - ) - ctx.wait_for_input(timeout=0.1) - - mgr = ChatManager(handler=handler) - mgr.bind(widget) - tid = mgr.active_thread_id - cancel = threading.Event() - - mgr._handle_stream(handler([], ctx), "msg_001", tid, cancel, ctx=ctx) - - ir_events = widget.get_events("chat:input-required") - assert len(ir_events) == 1 - assert ir_events[0]["inputType"] == "buttons" - assert ir_events[0]["options"] == ["Accept", "Reject"] - - def test_radio_type_in_stream(self, widget): - """Verify radio input_type and options are emitted.""" - ctx = ChatContext() - - def handler(messages, c): - yield InputRequiredResponse( - prompt="Select model:", - input_type="radio", - options=["GPT-4", "Claude", "Gemini"], - request_id="req_radio", - ) - ctx.wait_for_input(timeout=0.1) - - mgr = ChatManager(handler=handler) - mgr.bind(widget) - tid = mgr.active_thread_id - cancel = threading.Event() - - mgr._handle_stream(handler([], ctx), "msg_001", tid, cancel, ctx=ctx) - - ir_events = widget.get_events("chat:input-required") - assert len(ir_events) == 1 - assert ir_events[0]["inputType"] == "radio" - assert ir_events[0]["options"] == ["GPT-4", "Claude", "Gemini"] - - def test_default_options_empty_list(self, widget): - """When options is None, emitted data should have empty list.""" - ctx = ChatContext() - - def handler(messages, c): - yield InputRequiredResponse(request_id="req_def") - ctx.wait_for_input(timeout=0.1) - - mgr = ChatManager(handler=handler) - mgr.bind(widget) - tid = mgr.active_thread_id - cancel = threading.Event() - - mgr._handle_stream(handler([], ctx), "msg_001", tid, cancel, ctx=ctx) - - ir_events = widget.get_events("chat:input-required") - assert ir_events[0]["inputType"] == "text" - assert ir_events[0]["options"] == [] - - def test_e2e_input_required_response_flow(self, widget): - """Full integration: InputRequired → user responds → handler continues.""" - ctx = ChatContext() - - def handler(messages, c): - yield "Question: " - yield InputRequiredResponse( - prompt="Yes or no?", - request_id="req_e2e", - ) - answer = ctx.wait_for_input() - yield f"Answer: {answer}" - - mgr = ChatManager(handler=handler) - mgr.bind(widget) - tid = mgr.active_thread_id - cancel = threading.Event() - - # Run in a thread since _handle_stream will block at wait_for_input - stream_thread = threading.Thread( - target=mgr._handle_stream, - args=(handler([], ctx), "msg_001", tid, cancel), - kwargs={"ctx": ctx}, - daemon=True, - ) - stream_thread.start() - - # Wait for the input-required event to be emitted - for _ in range(50): - if widget.get_events("chat:input-required"): - break - time.sleep(0.05) - - # Simulate user responding - mgr._on_input_response( - {"requestId": "req_e2e", "text": "yes", "threadId": tid}, - "", - "", - ) - - stream_thread.join(timeout=2.0) - assert not stream_thread.is_alive() - - # Verify the full conversation history - msgs = mgr._threads[tid] - texts = [m["text"] for m in msgs] - assert "Question: " in texts - assert "yes" in texts # user response - assert "Answer: yes" in texts # handler continuation - - -# ============================================================================= -# Artifact Model Tests — All Artifact Types -# ============================================================================= - - -class TestArtifactModels: - """Test each artifact model's defaults, type literals, and fields.""" - - def test_artifact_base(self): - a = _ArtifactBase() - assert a.type == "artifact" - assert a.title == "" - - def test_code_artifact_defaults(self): - a = CodeArtifact() - assert a.type == "artifact" - assert a.artifact_type == "code" - assert a.content == "" - assert a.language == "" - - def test_code_artifact_fields(self): - a = CodeArtifact(title="main.py", content="print(1)", language="python") - assert a.title == "main.py" - assert a.content == "print(1)" - assert a.language == "python" - - def test_artifact_response_is_code_artifact(self): - assert ArtifactResponse is CodeArtifact - - def test_markdown_artifact_defaults(self): - a = MarkdownArtifact() - assert a.artifact_type == "markdown" - assert a.content == "" - - def test_markdown_artifact_fields(self): - a = MarkdownArtifact(title="Notes", content="# Heading\n\nParagraph.") - assert a.title == "Notes" - assert a.content == "# Heading\n\nParagraph." - - def test_html_artifact_defaults(self): - a = HtmlArtifact() - assert a.artifact_type == "html" - assert a.content == "" - - def test_html_artifact_fields(self): - a = HtmlArtifact(title="Page", content="

Hello

") - assert a.content == "

Hello

" - - def test_table_artifact_defaults(self): - a = TableArtifact() - assert a.artifact_type == "table" - assert a.data == [] - assert a.column_defs is None - assert a.grid_options is None - assert a.height == "400px" - - def test_table_artifact_with_data(self): - rows = [{"name": "Alice", "age": 30}, {"name": "Bob", "age": 25}] - a = TableArtifact(title="Users", data=rows, height="300px") - assert a.data == rows - assert a.height == "300px" - - def test_table_artifact_with_column_defs(self): - cols = [{"field": "name"}, {"field": "age"}] - a = TableArtifact(data=[], column_defs=cols) - assert a.column_defs == cols - - def test_table_artifact_with_grid_options(self): - opts = {"pagination": True, "paginationPageSize": 10} - a = TableArtifact(data=[], grid_options=opts) - assert a.grid_options == opts - - def test_plotly_artifact_defaults(self): - a = PlotlyArtifact() - assert a.artifact_type == "plotly" - assert a.figure == {} - assert a.height == "400px" - - def test_plotly_artifact_with_figure(self): - fig = { - "data": [{"x": [1, 2], "y": [3, 4], "type": "scatter"}], - "layout": {"title": "Test"}, - } - a = PlotlyArtifact(title="Chart", figure=fig, height="500px") - assert a.figure == fig - assert a.height == "500px" - - def test_image_artifact_defaults(self): - a = ImageArtifact() - assert a.artifact_type == "image" - assert a.url == "" - assert a.alt == "" - - def test_image_artifact_fields(self): - a = ImageArtifact(title="Logo", url="data:image/png;base64,abc", alt="PyWry Logo") - assert a.url == "data:image/png;base64,abc" - assert a.alt == "PyWry Logo" - - def test_json_artifact_defaults(self): - a = JsonArtifact() - assert a.artifact_type == "json" - assert a.data is None - - def test_json_artifact_with_data(self): - a = JsonArtifact(title="Config", data={"key": "value", "n": 42}) - assert a.data == {"key": "value", "n": 42} - - def test_all_are_artifact_base_subclasses(self): - for cls in ( - CodeArtifact, - MarkdownArtifact, - HtmlArtifact, - TableArtifact, - PlotlyArtifact, - ImageArtifact, - JsonArtifact, - ): - assert issubclass(cls, _ArtifactBase) - - def test_isinstance_dispatch(self): - items = [ - CodeArtifact(content="x"), - MarkdownArtifact(content="# Hi"), - HtmlArtifact(content="

"), - TableArtifact(data=[]), - PlotlyArtifact(figure={}), - ImageArtifact(url="x.png"), - JsonArtifact(data={"k": 1}), - ] - for item in items: - assert isinstance(item, _ArtifactBase) - - -# ============================================================================= -# Artifact Dispatch Tests — _dispatch_artifact + asset injection -# ============================================================================= - - -class TestArtifactDispatch: - """Test _dispatch_artifact with each artifact type.""" - - def test_code_artifact_dispatch(self, bound_manager, widget): - tid = bound_manager.active_thread_id - cancel = threading.Event() - - def gen(): - yield CodeArtifact(title="code.py", content="print(1)", language="python") - - bound_manager._handle_stream(gen(), "msg_001", tid, cancel) - - artifacts = widget.get_events("chat:artifact") - assert len(artifacts) == 1 - assert artifacts[0]["artifactType"] == "code" - assert artifacts[0]["content"] == "print(1)" - assert artifacts[0]["language"] == "python" - assert artifacts[0]["title"] == "code.py" - - def test_markdown_artifact_dispatch(self, bound_manager, widget): - tid = bound_manager.active_thread_id - cancel = threading.Event() - - def gen(): - yield MarkdownArtifact(title="Notes", content="# Hello\n\nWorld") - - bound_manager._handle_stream(gen(), "msg_001", tid, cancel) - - artifacts = widget.get_events("chat:artifact") - assert len(artifacts) == 1 - assert artifacts[0]["artifactType"] == "markdown" - assert artifacts[0]["content"] == "# Hello\n\nWorld" - - def test_html_artifact_dispatch(self, bound_manager, widget): - tid = bound_manager.active_thread_id - cancel = threading.Event() - - def gen(): - yield HtmlArtifact(title="Page", content="

Hi

") - - bound_manager._handle_stream(gen(), "msg_001", tid, cancel) - - artifacts = widget.get_events("chat:artifact") - assert len(artifacts) == 1 - assert artifacts[0]["artifactType"] == "html" - assert artifacts[0]["content"] == "

Hi

" - - def test_table_artifact_dispatch(self, bound_manager, widget): - tid = bound_manager.active_thread_id - cancel = threading.Event() - - rows = [{"name": "Alice", "age": 30}, {"name": "Bob", "age": 25}] - - def gen(): - yield TableArtifact(title="Users", data=rows, height="300px") - - bound_manager._handle_stream(gen(), "msg_001", tid, cancel) - - # Assets should have been injected first - asset_events = widget.get_events("chat:load-assets") - assert len(asset_events) >= 1 - # Scripts should include AG Grid JS - assert len(asset_events[0]["scripts"]) >= 1 - - artifacts = widget.get_events("chat:artifact") - assert len(artifacts) == 1 - assert artifacts[0]["artifactType"] == "table" - assert artifacts[0]["rowData"] == rows - assert artifacts[0]["height"] == "300px" - assert "columns" in artifacts[0] - - def test_table_artifact_with_column_defs(self, bound_manager, widget): - tid = bound_manager.active_thread_id - cancel = threading.Event() - cols = [{"field": "a"}, {"field": "b"}] - - def gen(): - yield TableArtifact(data=[{"a": 1, "b": 2}], column_defs=cols) - - bound_manager._handle_stream(gen(), "msg_001", tid, cancel) - - artifacts = widget.get_events("chat:artifact") - assert artifacts[0]["columnDefs"] == cols - - def test_table_artifact_with_grid_options(self, bound_manager, widget): - tid = bound_manager.active_thread_id - cancel = threading.Event() - opts = {"pagination": True} - - def gen(): - yield TableArtifact(data=[{"x": 1}], grid_options=opts) - - bound_manager._handle_stream(gen(), "msg_001", tid, cancel) - - artifacts = widget.get_events("chat:artifact") - assert artifacts[0]["gridOptions"] == opts - - def test_plotly_artifact_dispatch(self, bound_manager, widget): - tid = bound_manager.active_thread_id - cancel = threading.Event() - fig = { - "data": [{"x": [1, 2], "y": [3, 4], "type": "scatter"}], - "layout": {"title": "Test"}, - } - - def gen(): - yield PlotlyArtifact(title="Chart", figure=fig, height="500px") - - bound_manager._handle_stream(gen(), "msg_001", tid, cancel) - - # Plotly assets should be injected - asset_events = widget.get_events("chat:load-assets") - assert len(asset_events) >= 1 - - artifacts = widget.get_events("chat:artifact") - assert len(artifacts) == 1 - assert artifacts[0]["artifactType"] == "plotly" - assert artifacts[0]["figure"] == fig - assert artifacts[0]["height"] == "500px" - - def test_image_artifact_dispatch(self, bound_manager, widget): - tid = bound_manager.active_thread_id - cancel = threading.Event() - - def gen(): - yield ImageArtifact(title="Logo", url="data:image/png;base64,abc", alt="PyWry Logo") - - bound_manager._handle_stream(gen(), "msg_001", tid, cancel) - - artifacts = widget.get_events("chat:artifact") - assert len(artifacts) == 1 - assert artifacts[0]["artifactType"] == "image" - assert artifacts[0]["url"] == "data:image/png;base64,abc" - assert artifacts[0]["alt"] == "PyWry Logo" - - def test_json_artifact_dispatch(self, bound_manager, widget): - tid = bound_manager.active_thread_id - cancel = threading.Event() - data = {"key": "value", "nested": {"a": [1, 2]}} - - def gen(): - yield JsonArtifact(title="Config", data=data) - - bound_manager._handle_stream(gen(), "msg_001", tid, cancel) - - artifacts = widget.get_events("chat:artifact") - assert len(artifacts) == 1 - assert artifacts[0]["artifactType"] == "json" - assert artifacts[0]["data"] == data - - def test_aggrid_assets_sent_once(self, bound_manager, widget): - """AG Grid assets are injected only on the first table artifact.""" - tid = bound_manager.active_thread_id - cancel = threading.Event() - - def gen(): - yield TableArtifact(data=[{"a": 1}]) - yield TableArtifact(data=[{"b": 2}]) - - bound_manager._handle_stream(gen(), "msg_001", tid, cancel) - - asset_events = widget.get_events("chat:load-assets") - assert len(asset_events) == 1 # Only once - assert bound_manager._aggrid_assets_sent is True - - artifacts = widget.get_events("chat:artifact") - assert len(artifacts) == 2 - - def test_plotly_assets_sent_once(self, bound_manager, widget): - """Plotly assets are injected only on the first plotly artifact.""" - tid = bound_manager.active_thread_id - cancel = threading.Event() - - def gen(): - yield PlotlyArtifact(figure={"data": []}) - yield PlotlyArtifact(figure={"data": []}) - - bound_manager._handle_stream(gen(), "msg_001", tid, cancel) - - asset_events = widget.get_events("chat:load-assets") - assert len(asset_events) == 1 # Only once - assert bound_manager._plotly_assets_sent is True - - def test_mixed_artifacts_both_assets(self, bound_manager, widget): - """Both AG Grid and Plotly assets injected when both types are used.""" - tid = bound_manager.active_thread_id - cancel = threading.Event() - - def gen(): - yield TableArtifact(data=[{"x": 1}]) - yield PlotlyArtifact(figure={"data": []}) - - bound_manager._handle_stream(gen(), "msg_001", tid, cancel) - - asset_events = widget.get_events("chat:load-assets") - assert len(asset_events) == 2 # One for AG Grid, one for Plotly - - def test_artifact_backward_compat(self, bound_manager, widget): - """ArtifactResponse alias still works as CodeArtifact.""" - tid = bound_manager.active_thread_id - cancel = threading.Event() - - def gen(): - yield ArtifactResponse(title="old.py", content="x = 1", language="python") - - bound_manager._handle_stream(gen(), "msg_001", tid, cancel) - - artifacts = widget.get_events("chat:artifact") - assert len(artifacts) == 1 - assert artifacts[0]["artifactType"] == "code" - - def test_table_artifact_dict_data(self, bound_manager, widget): - """TableArtifact with dict-of-lists data (column-oriented).""" - tid = bound_manager.active_thread_id - cancel = threading.Event() - data = {"name": ["Alice", "Bob"], "age": [30, 25]} - - def gen(): - yield TableArtifact(title="Users", data=data) - - bound_manager._handle_stream(gen(), "msg_001", tid, cancel) - - artifacts = widget.get_events("chat:artifact") - assert len(artifacts) == 1 - assert len(artifacts[0]["rowData"]) == 2 - - def test_rich_handler_with_new_artifacts(self, bound_manager, widget): - """Integration test: stream handler yields mixed old and new types.""" - tid = bound_manager.active_thread_id - cancel = threading.Event() - - def gen(): - yield StatusResponse(text="Working...") - yield "Here is some text. " - yield CodeArtifact(title="snippet.py", content="x = 1", language="python") - yield MarkdownArtifact(title="Notes", content="**Bold** text") - yield JsonArtifact(title="Data", data={"key": "val"}) - yield "Done!" - - bound_manager._handle_stream(gen(), "msg_001", tid, cancel) - - # Verify all event types were emitted - assert len(widget.get_events("chat:status-update")) == 1 - chunks = widget.get_events("chat:stream-chunk") - text_chunks = [c["chunk"] for c in chunks if "chunk" in c and not c.get("done")] - assert "Here is some text. " in text_chunks - assert "Done!" in text_chunks - - artifacts = widget.get_events("chat:artifact") - assert len(artifacts) == 3 - types = [a["artifactType"] for a in artifacts] - assert types == ["code", "markdown", "json"] - - -# ============================================================================= -# Security — URL scheme validation -# ============================================================================= - - -class TestURLSchemeValidation: - """Ensure javascript: and other dangerous URL schemes are rejected.""" - - def test_image_artifact_blocks_javascript_url(self): - from pydantic import ValidationError - - with pytest.raises(ValidationError): - ImageArtifact(url="javascript:alert(1)") - - def test_image_artifact_blocks_javascript_url_case_insensitive(self): - from pydantic import ValidationError - - with pytest.raises(ValidationError): - ImageArtifact(url="JaVaScRiPt:alert(1)") - - def test_image_artifact_blocks_javascript_url_with_whitespace(self): - from pydantic import ValidationError - - with pytest.raises(ValidationError): - ImageArtifact(url=" javascript:alert(1)") - - def test_image_artifact_allows_https(self): - a = ImageArtifact(url="https://example.com/img.png") - assert a.url == "https://example.com/img.png" - - def test_image_artifact_allows_data_uri(self): - a = ImageArtifact(url="data:image/png;base64,abc123") - assert a.url == "data:image/png;base64,abc123" - - def test_image_artifact_allows_empty(self): - a = ImageArtifact(url="") - assert a.url == "" - - def test_citation_blocks_javascript_url(self): - from pydantic import ValidationError - - with pytest.raises(ValidationError): - CitationResponse(url="javascript:alert(1)") - - def test_citation_blocks_javascript_url_case_insensitive(self): - from pydantic import ValidationError - - with pytest.raises(ValidationError): - CitationResponse(url="JAVASCRIPT:void(0)") - - def test_citation_allows_https(self): - c = CitationResponse(url="https://example.com", title="Example") - assert c.url == "https://example.com" - - def test_citation_allows_empty(self): - c = CitationResponse(url="") - assert c.url == "" - - -# ============================================================================= -# Async Handler Tests -# ============================================================================= - - -class TestAsyncHandler: - """Test that ChatManager natively supports async functions and generators.""" - - def test_async_coroutine_handler(self): - """An async function returning a string works as a handler.""" - - async def handler(messages, ctx): - return f"Echo: {messages[-1]['text']}" - - mgr = ChatManager(handler=handler) - w = FakeWidget() - mgr.bind(w) - tid = mgr.active_thread_id - mgr._on_user_message({"text": "hello", "threadId": tid}, "", "") - time.sleep(0.5) - - msgs = mgr._threads[tid] - assistant = [m for m in msgs if m["role"] == "assistant"] - assert len(assistant) == 1 - assert assistant[0]["text"] == "Echo: hello" - - def test_async_generator_handler(self): - """An async generator yielding str chunks streams correctly.""" - - async def handler(messages, ctx): - for word in ["Hello", " ", "async", " ", "world"]: - yield word - - mgr = ChatManager(handler=handler) - w = FakeWidget() - mgr.bind(w) - tid = mgr.active_thread_id - mgr._on_user_message({"text": "test", "threadId": tid}, "", "") - time.sleep(0.5) - - chunks = w.get_events("chat:stream-chunk") - text_chunks = [c["chunk"] for c in chunks if not c.get("done")] - assert "".join(text_chunks) == "Hello async world" - - # Done signal sent - done_chunks = [c for c in chunks if c.get("done")] - assert len(done_chunks) == 1 - - # Full text stored - assistant = [m for m in mgr._threads[tid] if m["role"] == "assistant"] - assert assistant[0]["text"] == "Hello async world" - - def test_async_generator_cancellation(self): - """Async generator respects cancel_event.""" - import asyncio as _asyncio - - async def handler(messages, ctx): - for i in range(100): - if ctx.cancel_event.is_set(): - return - yield f"chunk{i} " - await _asyncio.sleep(0.01) - - mgr = ChatManager(handler=handler) - w = FakeWidget() - mgr.bind(w) - tid = mgr.active_thread_id - mgr._on_user_message({"text": "go", "threadId": tid}, "", "") - time.sleep(0.1) - - # Cancel mid-stream - mgr._on_stop_generation({"threadId": tid}, "", "") - time.sleep(0.3) - - chunks = w.get_events("chat:stream-chunk") - # Should have been stopped before all 100 chunks - text_chunks = [c["chunk"] for c in chunks if c.get("chunk")] - assert len(text_chunks) < 100 - - def test_async_generator_with_rich_responses(self): - """Async generator can yield StatusResponse and other rich types.""" - - async def handler(messages, ctx): - yield StatusResponse(text="Thinking...") - yield "The answer is " - yield "42." - - mgr = ChatManager(handler=handler) - w = FakeWidget() - mgr.bind(w) - tid = mgr.active_thread_id - mgr._on_user_message({"text": "question", "threadId": tid}, "", "") - time.sleep(0.5) - - statuses = w.get_events("chat:status-update") - assert len(statuses) == 1 - assert statuses[0]["text"] == "Thinking..." - - assistant = [m for m in mgr._threads[tid] if m["role"] == "assistant"] - assert assistant[0]["text"] == "The answer is 42." - - def test_async_handler_exception(self): - """Async handler exceptions are caught and sent as error messages.""" - - async def handler(messages, ctx): - raise ValueError("async boom") - - mgr = ChatManager(handler=handler) - w = FakeWidget() - mgr.bind(w) - tid = mgr.active_thread_id - mgr._on_user_message({"text": "fail", "threadId": tid}, "", "") - time.sleep(0.5) - - assistant_events = w.get_events("chat:assistant-message") - assert any("async boom" in e.get("text", "") for e in assistant_events) - - -# ============================================================================= -# Stream Buffering Tests -# ============================================================================= - - -class TestStreamBuffering: - """Verify time-based stream buffering batches text chunks.""" - - def test_sync_chunks_batched(self, widget): - """With buffering enabled, fast text chunks are combined.""" - mgr = ChatManager(handler=echo_handler) - mgr._STREAM_FLUSH_INTERVAL = 10 # very high — force all into one batch - mgr._STREAM_MAX_BUFFER = 10_000 - mgr.bind(widget) - tid = mgr.active_thread_id - cancel = threading.Event() - - def gen(): - yield "A" - yield "B" - yield "C" - - mgr._handle_stream(gen(), "msg_001", tid, cancel) - - chunks = widget.get_events("chat:stream-chunk") - text_chunks = [c["chunk"] for c in chunks if c.get("chunk")] - # All three should be batched into one combined chunk - assert len(text_chunks) == 1 - assert text_chunks[0] == "ABC" - # Done signal still sent - assert chunks[-1]["done"] is True - - def test_sync_max_buffer_forces_flush(self, widget): - """Chunks exceeding MAX_BUFFER flush immediately.""" - mgr = ChatManager(handler=echo_handler) - mgr._STREAM_FLUSH_INTERVAL = 10 # high interval - mgr._STREAM_MAX_BUFFER = 5 # but very small buffer - mgr.bind(widget) - tid = mgr.active_thread_id - cancel = threading.Event() - - def gen(): - yield "AAAAAA" # 6 chars > 5 — triggers flush - yield "BB" # 2 chars < 5 — stays in buffer - - mgr._handle_stream(gen(), "msg_001", tid, cancel) - - chunks = widget.get_events("chat:stream-chunk") - text_chunks = [c["chunk"] for c in chunks if c.get("chunk")] - assert len(text_chunks) == 2 - assert text_chunks[0] == "AAAAAA" - assert text_chunks[1] == "BB" - - def test_sync_non_text_flushes_buffer(self, widget): - """Non-text items flush any pending text buffer first.""" - mgr = ChatManager(handler=echo_handler) - mgr._STREAM_FLUSH_INTERVAL = 10 - mgr._STREAM_MAX_BUFFER = 10_000 - mgr.bind(widget) - tid = mgr.active_thread_id - cancel = threading.Event() - - def gen(): - yield "before " - yield StatusResponse(text="status!") - yield "after" - - mgr._handle_stream(gen(), "msg_001", tid, cancel) - - chunks = widget.get_events("chat:stream-chunk") - text_chunks = [c["chunk"] for c in chunks if c.get("chunk")] - # "before " flushed before status, "after" flushed at end - assert text_chunks[0] == "before " - assert text_chunks[1] == "after" - - statuses = widget.get_events("chat:status-update") - assert len(statuses) == 1 - - def test_async_chunks_batched(self, widget): - """Async generator text chunks are batched like sync ones.""" - import asyncio as _asyncio - - mgr = ChatManager(handler=echo_handler) - mgr._STREAM_FLUSH_INTERVAL = 10 - mgr._STREAM_MAX_BUFFER = 10_000 - mgr.bind(widget) - tid = mgr.active_thread_id - cancel = threading.Event() - - async def agen(): - yield "X" - yield "Y" - yield "Z" - - _asyncio.run(mgr._handle_async_stream(agen(), "msg_001", tid, cancel)) - - chunks = widget.get_events("chat:stream-chunk") - text_chunks = [c["chunk"] for c in chunks if c.get("chunk")] - assert len(text_chunks) == 1 - assert text_chunks[0] == "XYZ" - - def test_async_non_text_flushes_buffer(self, widget): - """Async stream flushes text buffer before non-text items.""" - import asyncio as _asyncio - - mgr = ChatManager(handler=echo_handler) - mgr._STREAM_FLUSH_INTERVAL = 10 - mgr._STREAM_MAX_BUFFER = 10_000 - mgr.bind(widget) - tid = mgr.active_thread_id - cancel = threading.Event() - - async def agen(): - yield "hello " - yield ThinkingResponse(text="hmm") - yield "world" - - _asyncio.run(mgr._handle_async_stream(agen(), "msg_001", tid, cancel)) - - chunks = widget.get_events("chat:stream-chunk") - text_chunks = [c["chunk"] for c in chunks if c.get("chunk")] - assert text_chunks[0] == "hello " - assert text_chunks[1] == "world" - - thinking = widget.get_events("chat:thinking-chunk") - assert len(thinking) == 1 - - def test_full_text_stored_correctly_with_buffering(self, widget): - """Full text is accumulated regardless of buffering.""" - mgr = ChatManager(handler=echo_handler) - mgr._STREAM_FLUSH_INTERVAL = 10 - mgr._STREAM_MAX_BUFFER = 10_000 - mgr.bind(widget) - tid = mgr.active_thread_id - cancel = threading.Event() - - def gen(): - yield "Hello " - yield "beautiful " - yield "world!" - - mgr._handle_stream(gen(), "msg_001", tid, cancel) - - msgs = mgr._threads[tid] - assert msgs[0]["text"] == "Hello beautiful world!" - - def test_all_events_present_after_return(self, widget): - """_handle_stream delivers all events before returning.""" - mgr = ChatManager(handler=echo_handler) - mgr._STREAM_FLUSH_INTERVAL = 0 # immediate flush - mgr._STREAM_MAX_BUFFER = 1 - mgr.bind(widget) - tid = mgr.active_thread_id - cancel = threading.Event() - - def gen(): - for i in range(20): - yield f"chunk{i} " - - mgr._handle_stream(gen(), "msg_001", tid, cancel) - - # All chunks + done should be present immediately after return - chunks = widget.get_events("chat:stream-chunk") - text_chunks = [c["chunk"] for c in chunks if c.get("chunk")] - assert "".join(text_chunks) == "".join(f"chunk{i} " for i in range(20)) - assert chunks[-1]["done"] is True - - -# ============================================================================= -# Context Attachment Tests -# ============================================================================= - - -class TestContextAttachments: - """Tests for context attachment resolution and injection.""" - - def test_attachment_dataclass_file(self): - """File attachment stores path correctly.""" - import pathlib - - att = Attachment(type="file", name="test.py", path=pathlib.Path("/test_data/test.py")) - assert att.type == "file" - assert att.name == "test.py" - assert att.path == pathlib.Path("/test_data/test.py") - assert att.content == "" - assert att.source == "" - - def test_attachment_dataclass_widget(self): - """Widget attachment stores content correctly.""" - att = Attachment(type="widget", name="@Sales Data", content="a,b\n1,2") - assert att.type == "widget" - assert att.name == "@Sales Data" - assert att.content == "a,b\n1,2" - assert att.path is None - - def test_enable_context_default_false(self): - """Context is disabled by default.""" - mgr = ChatManager(handler=echo_handler) - assert mgr._enable_context is False - - def test_enable_context_constructor(self): - """enable_context=True is stored.""" - mgr = ChatManager(handler=echo_handler, enable_context=True) - assert mgr._enable_context is True - - def test_context_allowed_roots(self, tmp_path): - """context_allowed_roots is stored (resolved).""" - mgr = ChatManager( - handler=echo_handler, - enable_context=True, - context_allowed_roots=[str(tmp_path)], - ) - assert mgr._context_allowed_roots == [str(tmp_path)] - - def test_resolve_attachments_disabled(self, widget): - """When context is disabled, no attachments are resolved.""" - mgr = ChatManager(handler=echo_handler, enable_context=False) - mgr.bind(widget) - result = mgr._resolve_attachments( - [{"type": "file", "name": "test.py", "path": "/test_data/test.py"}] - ) - assert result == [] - - def test_resolve_attachments_with_paths(self, widget): - """_resolve_attachments creates Attachments with Path objects.""" - import pathlib - - mgr = ChatManager(handler=echo_handler, enable_context=True) - mgr.bind(widget) - result = mgr._resolve_attachments( - [ - {"type": "file", "name": "a.py", "path": "/data/a.py"}, - {"type": "file", "name": "b.json", "path": "/data/b.json"}, - ] - ) - assert len(result) == 2 - assert result[0].name == "a.py" - assert result[0].path == pathlib.Path("/data/a.py") - assert result[1].name == "b.json" - assert result[1].path == pathlib.Path("/data/b.json") - - def test_resolve_attachments_file_without_path_or_content_skipped(self, widget): - """File attachment without path or content is skipped.""" - mgr = ChatManager(handler=echo_handler, enable_context=True) - mgr.bind(widget) - result = mgr._resolve_attachments( - [ - {"type": "file", "name": "orphan.csv"}, - ] - ) - assert result == [] - - def test_resolve_attachments_browser_content_fallback(self, widget): - """Browser mode: file with content but no path is resolved.""" - mgr = ChatManager(handler=echo_handler, enable_context=True) - mgr.bind(widget) - result = mgr._resolve_attachments( - [ - {"type": "file", "name": "data.csv", "content": "a,b\n1,2"}, - ] - ) - assert len(result) == 1 - assert result[0].name == "data.csv" - assert result[0].path is None - assert result[0].content == "a,b\n1,2" - - def test_get_attachment_browser_content(self): - """get_attachment returns content for browser-mode files (no path).""" - ctx = ChatContext( - attachments=[ - Attachment(type="file", name="notes.txt", content="hello world"), - ], - ) - assert ctx.get_attachment("notes.txt") == "hello world" - - def test_attachment_summary_browser_content(self): - """attachment_summary shows (file) without path for browser-mode files.""" - ctx = ChatContext( - attachments=[ - Attachment(type="file", name="notes.txt", content="hello world"), - ], - ) - summary = ctx.attachment_summary - assert "notes.txt" in summary - assert "(file)" in summary - - def test_context_text_browser_content(self): - """context_text includes content for browser-mode files.""" - ctx = ChatContext( - attachments=[ - Attachment(type="file", name="data.csv", content="a,b\n1,2"), - ], - ) - text = ctx.context_text - assert "a,b" in text - assert "data.csv" in text - - def test_resolve_attachments_max_limit(self, widget): - """Only _MAX_ATTACHMENTS are resolved.""" - from pywry.chat_manager import _MAX_ATTACHMENTS - - mgr = ChatManager(handler=echo_handler, enable_context=True) - mgr.bind(widget) - raw = [ - {"type": "file", "name": f"f{i}.txt", "path": f"/test_data/f{i}.txt"} - for i in range(_MAX_ATTACHMENTS + 5) - ] - result = mgr._resolve_attachments(raw) - assert len(result) == _MAX_ATTACHMENTS - - def test_context_tool_schema(self): - """CONTEXT_TOOL is a valid OpenAI-style tool dict.""" - tool = ChatManager.CONTEXT_TOOL - assert tool["type"] == "function" - assert tool["function"]["name"] == "get_context" - params = tool["function"]["parameters"] - assert "name" in params["properties"] - assert "name" in params["required"] - - def test_get_attachment_found(self): - """ctx.get_attachment returns path string for files, content for widgets.""" - import pathlib - - ctx = ChatContext( - attachments=[ - Attachment(type="file", name="test.py", path=pathlib.Path("/test_data/test.py")), - Attachment(type="widget", name="@Sales", content="a,b"), - ], - ) - assert ctx.get_attachment("test.py") == str(pathlib.Path("/test_data/test.py")) - assert ctx.get_attachment("@Sales") == "a,b" - - def test_get_attachment_not_found(self): - """ctx.get_attachment returns error message when not found.""" - import pathlib - - ctx = ChatContext( - attachments=[ - Attachment(type="file", name="test.py", path=pathlib.Path("/test_data/test.py")), - ], - ) - result = ctx.get_attachment("missing.txt") - assert "not found" in result.lower() - assert "test.py" in result - - def test_attachment_summary(self): - """ctx.attachment_summary lists attached items.""" - import pathlib - - ctx = ChatContext( - attachments=[ - Attachment(type="file", name="data.csv", path=pathlib.Path("/data/data.csv")), - ], - ) - summary = ctx.attachment_summary - assert "data.csv" in summary - assert "file" in summary - - def test_attachment_summary_empty(self): - """ctx.attachment_summary is empty string when no attachments.""" - ctx = ChatContext() - assert ctx.attachment_summary == "" - - def test_messages_stay_clean_with_attachments(self, widget): - """Attachments go to ctx.attachments, messages list stays user/assistant only.""" - received_messages = [] - received_ctx = [] - - def capture_handler(messages, ctx): - received_messages.extend(messages) - received_ctx.append(ctx) - return "ok" - - mgr = ChatManager(handler=capture_handler, enable_context=True) - mgr.bind(widget) - - mgr._on_user_message( - { - "text": "Analyze this", - "attachments": [ - {"type": "file", "name": "data.csv", "path": "/data/data.csv"}, - ], - }, - "chat:user-message", - "", - ) - time.sleep(0.5) - - # Messages should only have user/assistant — no context role injected - assert all(m["role"] in ("user", "assistant") for m in received_messages) - # Attachments should be on ctx - assert len(received_ctx) == 1 - assert len(received_ctx[0].attachments) == 1 - assert received_ctx[0].attachments[0].name == "data.csv" - import pathlib - - assert received_ctx[0].attachments[0].path == pathlib.Path("/data/data.csv") - - def test_context_not_stored_in_threads(self, widget): - """Context messages are NOT persisted in _threads.""" - mgr = ChatManager(handler=echo_handler, enable_context=True) - mgr.bind(widget) - - mgr._on_user_message( - { - "text": "Hello", - "attachments": [ - {"type": "file", "name": "test.txt", "path": "/test_data/test.txt"}, - ], - }, - "chat:user-message", - "", - ) - time.sleep(0.5) - - # _threads should only have user + assistant, NOT context - thread = mgr._threads.get(mgr._active_thread, []) - roles = [m["role"] for m in thread] - assert "context" not in roles - assert "user" in roles - - def test_chat_context_attachments_field(self, widget): - """ChatContext.attachments is populated from resolved attachments.""" - received_ctx = [] - - def capture_handler(messages, ctx): - received_ctx.append(ctx) - return "ok" - - mgr = ChatManager(handler=capture_handler, enable_context=True) - mgr.bind(widget) - - mgr._on_user_message( - { - "text": "Check this", - "attachments": [ - {"type": "file", "name": "f.py", "path": "/test_data/f.py"}, - ], - }, - "chat:user-message", - "", - ) - time.sleep(0.5) - - assert len(received_ctx) == 1 - assert len(received_ctx[0].attachments) == 1 - assert received_ctx[0].attachments[0].name == "f.py" - - def test_no_attachments_empty_list(self, widget): - """When no attachments sent, ctx.attachments is empty list.""" - received_ctx = [] - - def capture_handler(messages, ctx): - received_ctx.append(ctx) - return "ok" - - mgr = ChatManager(handler=capture_handler, enable_context=True) - mgr.bind(widget) - - mgr._on_user_message( - {"text": "Hello"}, - "chat:user-message", - "", - ) - time.sleep(0.5) - - assert len(received_ctx) == 1 - assert received_ctx[0].attachments == [] - - def test_get_context_sources_no_app(self, widget): - """_get_context_sources returns empty when no app.""" - mgr = ChatManager(handler=echo_handler, enable_context=True) - mgr.bind(widget) - assert mgr._get_context_sources() == [] - - def test_context_sources_emitted_on_state_request(self, widget): - """When context is enabled, context sources are emitted on request-state.""" - mgr = ChatManager(handler=echo_handler, enable_context=True) - mgr.bind(widget) - widget.clear() - - mgr._on_request_state({}, "chat:request-state", "") - - # Should have emitted chat:context-sources (but may be empty if no app) - # At minimum, no error should occur - state_events = widget.get_events("chat:state-response") - assert len(state_events) == 1 - - def test_register_context_source(self, widget): - """register_context_source makes source appear in @ mention list.""" - mgr = ChatManager(handler=echo_handler, enable_context=True) - mgr.register_context_source("sales-grid", "Sales Data") - mgr.bind(widget) - - sources = mgr._get_context_sources() - assert len(sources) == 1 - assert sources[0]["id"] == "sales-grid" - assert sources[0]["name"] == "Sales Data" - assert sources[0]["componentId"] == "sales-grid" - - def test_registered_source_emitted_on_request_state(self, widget): - """Registered sources are emitted via chat:context-sources.""" - mgr = ChatManager(handler=echo_handler, enable_context=True) - mgr.register_context_source("sales-chart", "Revenue Chart") - mgr.bind(widget) - widget.clear() - - mgr._on_request_state({}, "chat:request-state", "") - - ctx_events = widget.get_events("chat:context-sources") - assert len(ctx_events) == 1 - assert len(ctx_events[0]["sources"]) == 1 - assert ctx_events[0]["sources"][0]["name"] == "Revenue Chart" - - def test_resolve_registered_source_with_content(self, widget): - """_resolve_widget_attachment uses content extracted by frontend.""" - mgr = ChatManager(handler=echo_handler, enable_context=True) - mgr.register_context_source("sales-grid", "Sales Data") - mgr.bind(widget) - - # Frontend sends extracted content along with the widget_id - att = mgr._resolve_widget_attachment( - "sales-grid", - content="Product,Revenue\nAlpha,100\nBeta,200", - ) - assert att is not None - assert att.name == "@Sales Data" - assert att.content == "Product,Revenue\nAlpha,100\nBeta,200" - assert att.type == "widget" - assert att.source == "sales-grid" - - def test_resolve_registered_source_not_found(self, widget): - """_resolve_widget_attachment returns None for unknown source.""" - mgr = ChatManager(handler=echo_handler, enable_context=True) - mgr.bind(widget) - - att = mgr._resolve_widget_attachment("nonexistent") - assert att is None - - def test_multiple_registered_sources(self, widget): - """Multiple registered sources all appear in context list.""" - mgr = ChatManager(handler=echo_handler, enable_context=True) - mgr.register_context_source("sales-grid", "Sales Data") - mgr.register_context_source("sales-chart", "Revenue Chart") - mgr.register_context_source("kpi-grid", "KPI Summary") - mgr.bind(widget) - - sources = mgr._get_context_sources() - names = [s["name"] for s in sources] - assert "Sales Data" in names - assert "Revenue Chart" in names - assert "KPI Summary" in names - - def test_resolve_widget_without_content_or_app(self, widget): - """_resolve_widget_attachment without content falls back gracefully.""" - mgr = ChatManager(handler=echo_handler, enable_context=True) - mgr.register_context_source("sales-grid", "Sales Data") - mgr.bind(widget) - - # No content provided and no app/inline_widgets => None - att = mgr._resolve_widget_attachment("sales-grid") - assert att is None - - -class TestFileAttachConfig: - """Tests for the separate enable_file_attach / file_accept_types params.""" - - def test_enable_file_attach_default_false(self): - """File attach is disabled by default.""" - mgr = ChatManager(handler=echo_handler) - assert mgr._enable_file_attach is False - - def test_enable_file_attach_true(self): - """enable_file_attach=True requires file_accept_types.""" - mgr = ChatManager( - handler=echo_handler, - enable_file_attach=True, - file_accept_types=[".csv"], - ) - assert mgr._enable_file_attach is True - - def test_enable_file_attach_requires_accept_types(self): - """ValueError when enable_file_attach=True without file_accept_types.""" - import pytest - - with pytest.raises(ValueError, match="file_accept_types is required"): - ChatManager(handler=echo_handler, enable_file_attach=True) - - def test_file_accept_types_custom(self): - """Custom file accept types are stored.""" - types = [".csv", ".json", ".xlsx"] - mgr = ChatManager( - handler=echo_handler, - enable_file_attach=True, - file_accept_types=types, - ) - assert mgr._file_accept_types == types - - def test_file_attach_independent_of_context(self, widget): - """File attachments work when enable_file_attach=True but enable_context=False.""" - import pathlib - - mgr = ChatManager( - handler=echo_handler, - enable_file_attach=True, - file_accept_types=[".csv"], - enable_context=False, - ) - mgr.bind(widget) - result = mgr._resolve_attachments( - [ - {"type": "file", "name": "data.csv", "path": "/data/data.csv"}, - ] - ) - assert len(result) == 1 - assert result[0].name == "data.csv" - assert result[0].path == pathlib.Path("/data/data.csv") - - def test_context_only_no_file_attach(self, widget): - """Widget attachments work when enable_context=True but enable_file_attach=False.""" - mgr = ChatManager( - handler=echo_handler, - enable_context=True, - enable_file_attach=False, - ) - mgr.register_context_source("sales-grid", "Sales Data") - mgr.bind(widget) - result = mgr._resolve_attachments( - [ - {"type": "widget", "widgetId": "sales-grid", "content": "a,b\n1,2"}, - ] - ) - assert len(result) == 1 - assert result[0].type == "widget" - - def test_both_disabled_resolves_nothing(self, widget): - """When both flags are False, no attachments resolve.""" - mgr = ChatManager(handler=echo_handler) - mgr.bind(widget) - result = mgr._resolve_attachments( - [ - {"type": "file", "name": "data.csv", "path": "/data/data.csv"}, - ] - ) - assert result == [] - - def test_both_enabled_resolves_all(self, widget): - """When both flags are True, both files and widgets resolve.""" - import pathlib - - mgr = ChatManager( - handler=echo_handler, - enable_context=True, - enable_file_attach=True, - file_accept_types=[".csv"], - ) - mgr.register_context_source("sales-grid", "Sales Data") - mgr.bind(widget) - result = mgr._resolve_attachments( - [ - {"type": "file", "name": "data.csv", "path": "/data/data.csv"}, - {"type": "widget", "widgetId": "sales-grid", "content": "x,y\n1,2"}, - ] - ) - assert len(result) == 2 - assert result[0].type == "file" - assert result[0].path == pathlib.Path("/data/data.csv") - assert result[1].type == "widget" - assert result[1].content == "x,y\n1,2" - - def test_rejected_file_extension(self, widget): - """Files with extensions not in file_accept_types are rejected.""" - mgr = ChatManager( - handler=echo_handler, - enable_file_attach=True, - file_accept_types=[".csv", ".json"], - ) - mgr.bind(widget) - result = mgr._resolve_attachments( - [ - {"type": "file", "name": "exploit.exe", "path": "/test_data/exploit.exe"}, - ] - ) - assert result == [] - - def test_accepted_file_extension(self, widget): - """Files with extensions in file_accept_types are accepted.""" - mgr = ChatManager( - handler=echo_handler, - enable_file_attach=True, - file_accept_types=[".csv", ".json"], - ) - mgr.bind(widget) - result = mgr._resolve_attachments( - [ - {"type": "file", "name": "data.csv", "path": "/data/data.csv"}, - ] - ) - assert len(result) == 1 - assert result[0].name == "data.csv" - - -# ============================================================================= -# Full pipeline integration tests — prove file attachments actually work -# ============================================================================= - - -class TestFileAttachPipeline: - """End-to-end tests: _on_user_message → handler receives correct Attachment - objects with the right fields, and ctx helpers return correct data.""" - - @pytest.fixture() - def widget(self): - return FakeWidget() - - # -- Desktop mode (Tauri): handler receives path, reads file from disk -- - - def test_desktop_file_path_reaches_handler(self, widget, tmp_path): - """Full pipeline: desktop file attachment delivers a real readable Path.""" - f = tmp_path / "sales.csv" - f.write_text("Product,Revenue\nAlpha,100\nBeta,200", encoding="utf-8") - - received = {} - - def handler(messages, ctx): - received["ctx"] = ctx - received["messages"] = list(messages) - # Actually read the file — this is what the handler would do - att = ctx.attachments[0] - received["file_content"] = att.path.read_text(encoding="utf-8") - return "Done" - - mgr = ChatManager( - handler=handler, - enable_file_attach=True, - file_accept_types=[".csv"], - ) - mgr.bind(widget) - - mgr._on_user_message( - { - "text": "Analyze sales", - "attachments": [ - {"type": "file", "name": "sales.csv", "path": str(f)}, - ], - }, - "chat:user-message", - "", - ) - time.sleep(0.5) - - # Handler was called - assert "ctx" in received - ctx = received["ctx"] - - # Attachment has correct fields - assert len(ctx.attachments) == 1 - att = ctx.attachments[0] - assert att.type == "file" - assert att.name == "sales.csv" - assert att.path == f - assert att.content == "" # Desktop mode — content is empty - - # Handler could actually read the file - assert received["file_content"] == "Product,Revenue\nAlpha,100\nBeta,200" - - # ctx.get_attachment returns path string for desktop files - assert ctx.get_attachment("sales.csv") == str(f) - - # ctx.attachment_summary includes path - assert str(f) in ctx.attachment_summary - assert "sales.csv" in ctx.attachment_summary - - # ctx.context_text includes "Path:" for desktop files - assert f"Path: {f}" in ctx.context_text - - # -- Browser mode (inline/iframe): handler receives content directly -- - - def test_browser_file_content_reaches_handler(self, widget): - """Full pipeline: browser file attachment delivers content directly.""" - csv_data = "Name,Age\nAlice,30\nBob,25" - received = {} - - def handler(messages, ctx): - received["ctx"] = ctx - # In browser mode, content is already available — no disk read needed - att = ctx.attachments[0] - received["from_content"] = att.content - received["from_get_attachment"] = ctx.get_attachment("people.csv") - return "Done" - - mgr = ChatManager( - handler=handler, - enable_file_attach=True, - file_accept_types=[".csv"], - ) - mgr.bind(widget) - - mgr._on_user_message( - { - "text": "Who is older?", - "attachments": [ - {"type": "file", "name": "people.csv", "content": csv_data}, - ], - }, - "chat:user-message", - "", - ) - time.sleep(0.5) - - assert "ctx" in received - ctx = received["ctx"] - - # Attachment has correct fields for browser mode - assert len(ctx.attachments) == 1 - att = ctx.attachments[0] - assert att.type == "file" - assert att.name == "people.csv" - assert att.path is None # Browser — no filesystem path - assert att.content == csv_data - - # Handler got the content - assert received["from_content"] == csv_data - # get_attachment returns content when path is None - assert received["from_get_attachment"] == csv_data - - # attachment_summary says (file) without path - assert "people.csv (file)" in ctx.attachment_summary - - # context_text includes the actual content - assert "Name,Age" in ctx.context_text - assert "people.csv" in ctx.context_text - - # -- Mixed: desktop file + widget in same message -- - - def test_mixed_file_and_widget_pipeline(self, widget, tmp_path): - """Desktop file + widget attachment both reach handler correctly.""" - f = tmp_path / "config.json" - f.write_text('{"debug": true}', encoding="utf-8") - - received = {} - - def handler(messages, ctx): - received["ctx"] = ctx - return "Done" - - mgr = ChatManager( - handler=handler, - enable_context=True, - enable_file_attach=True, - file_accept_types=[".json"], - ) - mgr.register_context_source("metrics-grid", "Metrics") - mgr.bind(widget) - - mgr._on_user_message( - { - "text": "Compare", - "attachments": [ - {"type": "file", "name": "config.json", "path": str(f)}, - {"type": "widget", "widgetId": "metrics-grid", "content": "x,y\n1,2"}, - ], - }, - "chat:user-message", - "", - ) - time.sleep(0.5) - - ctx = received["ctx"] - assert len(ctx.attachments) == 2 - - # File attachment - file_att = ctx.attachments[0] - assert file_att.type == "file" - assert file_att.path == f - assert file_att.content == "" - assert ctx.get_attachment("config.json") == str(f) - # Verify the file is actually readable - assert file_att.path.read_text(encoding="utf-8") == '{"debug": true}' - - # Widget attachment - widget_att = ctx.attachments[1] - assert widget_att.type == "widget" - assert widget_att.path is None - assert widget_att.content == "x,y\n1,2" - assert ctx.get_attachment("Metrics") == "x,y\n1,2" - - # Summary includes both - summary = ctx.attachment_summary - assert "config.json" in summary - assert "Metrics" in summary - - # -- Mixed: browser files + widget in same message -- - - def test_mixed_browser_file_and_widget_pipeline(self, widget): - """Browser file + widget attachment both reach handler correctly.""" - received = {} - - def handler(messages, ctx): - received["ctx"] = ctx - return "Done" - - mgr = ChatManager( - handler=handler, - enable_context=True, - enable_file_attach=True, - file_accept_types=[".txt"], - ) - mgr.register_context_source("chart", "Revenue Chart") - mgr.bind(widget) - - mgr._on_user_message( - { - "text": "Analyze", - "attachments": [ - {"type": "file", "name": "notes.txt", "content": "buy low sell high"}, - {"type": "widget", "widgetId": "chart", "content": "Q1:100,Q2:200"}, - ], - }, - "chat:user-message", - "", - ) - time.sleep(0.5) - - ctx = received["ctx"] - assert len(ctx.attachments) == 2 - - # Browser file - assert ctx.attachments[0].type == "file" - assert ctx.attachments[0].path is None - assert ctx.attachments[0].content == "buy low sell high" - assert ctx.get_attachment("notes.txt") == "buy low sell high" - - # Widget - assert ctx.attachments[1].type == "widget" - assert ctx.get_attachment("Revenue Chart") == "Q1:100,Q2:200" - - # -- Context text injection into messages -- - - def test_desktop_context_text_prepended_to_message(self, widget, tmp_path): - """In desktop mode, context_text with file paths is prepended to user message.""" - f = tmp_path / "data.csv" - f.write_text("a,b\n1,2", encoding="utf-8") - - received = {} - - def handler(messages, ctx): - received["messages"] = list(messages) - received["ctx"] = ctx - return "ok" - - mgr = ChatManager( - handler=handler, - enable_file_attach=True, - file_accept_types=[".csv"], - ) - mgr.bind(widget) - - mgr._on_user_message( - { - "text": "check this", - "attachments": [ - {"type": "file", "name": "data.csv", "path": str(f)}, - ], - }, - "chat:user-message", - "", - ) - time.sleep(0.5) - - # The last user message text should have context prepended - last_user = [m for m in received["messages"] if m["role"] == "user"][-1] - assert f"Path: {f}" in last_user["text"] - assert "check this" in last_user["text"] - - def test_browser_context_text_prepended_to_message(self, widget): - """In browser mode, context_text with file content is prepended to user message.""" - received = {} - - def handler(messages, ctx): - received["messages"] = list(messages) - return "ok" - - mgr = ChatManager( - handler=handler, - enable_file_attach=True, - file_accept_types=[".csv"], - ) - mgr.bind(widget) - - mgr._on_user_message( - { - "text": "check this", - "attachments": [ - {"type": "file", "name": "data.csv", "content": "x,y\n10,20"}, - ], - }, - "chat:user-message", - "", - ) - time.sleep(0.5) - - last_user = [m for m in received["messages"] if m["role"] == "user"][-1] - # Browser content should be inline in the message - assert "x,y" in last_user["text"] - assert "check this" in last_user["text"] - - # -- Rejected files never reach handler -- - - def test_rejected_extension_never_reaches_handler(self, widget): - """Files with wrong extensions are silently dropped before handler.""" - received = {} - - def handler(messages, ctx): - received["ctx"] = ctx - return "ok" - - mgr = ChatManager( - handler=handler, - enable_file_attach=True, - file_accept_types=[".csv"], - ) - mgr.bind(widget) - - mgr._on_user_message( - { - "text": "run this", - "attachments": [ - {"type": "file", "name": "malware.exe", "path": "/test_data/malware.exe"}, - ], - }, - "chat:user-message", - "", - ) - time.sleep(0.5) - - # Handler was called but with NO attachments - assert received["ctx"].attachments == [] - - def test_empty_path_and_content_never_reaches_handler(self, widget): - """File with neither path nor content is dropped.""" - received = {} - - def handler(messages, ctx): - received["ctx"] = ctx - return "ok" - - mgr = ChatManager( - handler=handler, - enable_file_attach=True, - file_accept_types=[".csv"], - ) - mgr.bind(widget) - - mgr._on_user_message( - { - "text": "test", - "attachments": [ - {"type": "file", "name": "ghost.csv"}, - ], - }, - "chat:user-message", - "", - ) - time.sleep(0.5) - - assert received["ctx"].attachments == [] - - # -- Emitted events contain attachment info -- - - def test_tool_call_events_emitted_for_desktop_file(self, widget, tmp_path): - """Attachment tool-call/tool-result events are emitted for desktop files.""" - f = tmp_path / "report.csv" - f.write_text("a,b", encoding="utf-8") - - mgr = ChatManager( - handler=echo_handler, - enable_file_attach=True, - file_accept_types=[".csv"], - ) - mgr.bind(widget) - widget.clear() - - mgr._on_user_message( - { - "text": "analyze", - "attachments": [ - {"type": "file", "name": "report.csv", "path": str(f)}, - ], - }, - "chat:user-message", - "", - ) - time.sleep(0.5) - - # Should emit tool-call with name="attach_file" - tool_calls = widget.get_events("chat:tool-call") - assert len(tool_calls) >= 1 - assert tool_calls[0]["name"] == "attach_file" - assert tool_calls[0]["arguments"]["name"] == "report.csv" - - # Should emit tool-result with path info - tool_results = widget.get_events("chat:tool-result") - assert len(tool_results) >= 1 - assert str(f) in tool_results[0]["result"] - - def test_tool_call_events_emitted_for_browser_file(self, widget): - """Attachment tool-call/tool-result events are emitted for browser files.""" - mgr = ChatManager( - handler=echo_handler, - enable_file_attach=True, - file_accept_types=[".txt"], - ) - mgr.bind(widget) - widget.clear() - - mgr._on_user_message( - { - "text": "read", - "attachments": [ - {"type": "file", "name": "notes.txt", "content": "hello"}, - ], - }, - "chat:user-message", - "", - ) - time.sleep(0.5) - - tool_calls = widget.get_events("chat:tool-call") - assert len(tool_calls) >= 1 - assert tool_calls[0]["name"] == "attach_file" - - # -- Multiple files in one message -- - - def test_multiple_desktop_files(self, widget, tmp_path): - """Multiple desktop files all reach the handler with correct paths.""" - f1 = tmp_path / "a.csv" - f2 = tmp_path / "b.csv" - f1.write_text("col1\n1", encoding="utf-8") - f2.write_text("col2\n2", encoding="utf-8") - - received = {} - - def handler(messages, ctx): - received["ctx"] = ctx - return "ok" - - mgr = ChatManager( - handler=handler, - enable_file_attach=True, - file_accept_types=[".csv"], - ) - mgr.bind(widget) - - mgr._on_user_message( - { - "text": "compare", - "attachments": [ - {"type": "file", "name": "a.csv", "path": str(f1)}, - {"type": "file", "name": "b.csv", "path": str(f2)}, - ], - }, - "chat:user-message", - "", - ) - time.sleep(0.5) - - ctx = received["ctx"] - assert len(ctx.attachments) == 2 - # Both files are independently readable - assert ctx.attachments[0].path.read_text(encoding="utf-8") == "col1\n1" - assert ctx.attachments[1].path.read_text(encoding="utf-8") == "col2\n2" - # get_attachment resolves each by name - assert ctx.get_attachment("a.csv") == str(f1) - assert ctx.get_attachment("b.csv") == str(f2) - - def test_multiple_browser_files(self, widget): - """Multiple browser files all reach the handler with correct content.""" - received = {} - - def handler(messages, ctx): - received["ctx"] = ctx - return "ok" - - mgr = ChatManager( - handler=handler, - enable_file_attach=True, - file_accept_types=[".csv", ".json"], - ) - mgr.bind(widget) - - mgr._on_user_message( - { - "text": "compare", - "attachments": [ - {"type": "file", "name": "data.csv", "content": "a,b\n1,2"}, - {"type": "file", "name": "cfg.json", "content": '{"k": "v"}'}, - ], - }, - "chat:user-message", - "", - ) - time.sleep(0.5) - - ctx = received["ctx"] - assert len(ctx.attachments) == 2 - assert ctx.get_attachment("data.csv") == "a,b\n1,2" - assert ctx.get_attachment("cfg.json") == '{"k": "v"}' - # Both show up in summary - assert "data.csv" in ctx.attachment_summary - assert "cfg.json" in ctx.attachment_summary - - # -- Handler pattern: read or use content -- - - def test_handler_reads_real_file_like_demo(self, widget, tmp_path): - """Replicate the exact magentic demo pattern: handler reads att.path.""" - f = tmp_path / "report.csv" - f.write_text("Product,Revenue\nAlpha,100\nBeta,200", encoding="utf-8") - - received = {} - - def handler(messages, ctx): - # Exact pattern from pywry_demo_magentic.py get_context tool - results = [] - for att in ctx.attachments: - if att.path: - results.append(att.path.read_text(encoding="utf-8", errors="replace")) - else: - results.append(att.content) - received["results"] = results - return "Done" - - mgr = ChatManager( - handler=handler, - enable_file_attach=True, - file_accept_types=[".csv"], - ) - mgr.bind(widget) - - mgr._on_user_message( - { - "text": "analyze", - "attachments": [ - {"type": "file", "name": "report.csv", "path": str(f)}, - ], - }, - "chat:user-message", - "", - ) - time.sleep(0.5) - - assert received["results"] == ["Product,Revenue\nAlpha,100\nBeta,200"] - - def test_handler_uses_browser_content_like_demo(self, widget): - """Replicate the demo pattern for browser mode: handler uses att.content.""" - received = {} - - def handler(messages, ctx): - results = [] - for att in ctx.attachments: - if att.path: - results.append(att.path.read_text(encoding="utf-8", errors="replace")) - else: - results.append(att.content) - received["results"] = results - return "Done" - - mgr = ChatManager( - handler=handler, - enable_file_attach=True, - file_accept_types=[".csv"], - ) - mgr.bind(widget) - - mgr._on_user_message( - { - "text": "analyze", - "attachments": [ - {"type": "file", "name": "data.csv", "content": "x,y\n1,2"}, - ], - }, - "chat:user-message", - "", - ) - time.sleep(0.5) - - assert received["results"] == ["x,y\n1,2"] + assert len(bound_manager.threads[tid]) == 0 diff --git a/pywry/tests/test_chat_protocol.py b/pywry/tests/test_chat_protocol.py new file mode 100644 index 0000000..462bf6b --- /dev/null +++ b/pywry/tests/test_chat_protocol.py @@ -0,0 +1,742 @@ +"""Protocol integration tests for the ACP chat system. + +These tests verify that the protocol actually works end-to-end: +- Providers yield SessionUpdate objects that ChatManager dispatches correctly +- ACP wire format serialization produces the correct camelCase JSON +- Tool call lifecycle transitions produce correct event sequences +- TradingView artifacts dispatch with the right payload structure +- RBAC permission checks block or allow operations correctly +- Cancel signals propagate from the user through to the provider +- Plan updates produce structured frontend events +""" + +from __future__ import annotations + +import asyncio +import time + +from typing import Any + +import pytest + +from pywry.chat.artifacts import ( + CodeArtifact, + TradingViewArtifact, + TradingViewSeries, +) +from pywry.chat.manager import ChatManager +from pywry.chat.models import ( + ACPToolCall, + AudioPart, + ChatMessage, + EmbeddedResource, + ImagePart, + ResourceLinkPart, + TextPart, +) +from pywry.chat.permissions import ACP_PERMISSION_MAP, check_acp_permission +from pywry.chat.session import ( + AgentCapabilities, + ClientCapabilities, + PermissionRequest, + PlanEntry, + PromptCapabilities, + SessionConfigOption, + SessionMode, +) +from pywry.chat.updates import ( + AgentMessageUpdate, + ArtifactUpdate, + CommandsUpdate, + ConfigOptionUpdate, + ModeUpdate, + PermissionRequestUpdate, + PlanUpdate, + StatusUpdate, + ThinkingUpdate, + ToolCallUpdate, +) + + +class FakeWidget: + def __init__(self) -> None: + self.events: list[tuple[str, dict]] = [] + + def emit(self, event_type: str, data: dict[str, Any]) -> None: + self.events.append((event_type, data)) + + def emit_fire(self, event_type: str, data: dict[str, Any]) -> None: + self.events.append((event_type, data)) + + def get_events(self, event_type: str) -> list[dict]: + return [d for e, d in self.events if e == event_type] + + +@pytest.fixture(autouse=True) +def _disable_stream_buffering(): + orig_interval = ChatManager._STREAM_FLUSH_INTERVAL + orig_max = ChatManager._STREAM_MAX_BUFFER + ChatManager._STREAM_FLUSH_INTERVAL = 0 + ChatManager._STREAM_MAX_BUFFER = 1 + yield + ChatManager._STREAM_FLUSH_INTERVAL = orig_interval + ChatManager._STREAM_MAX_BUFFER = orig_max + + +class TestACPWireFormat: + """Verify models serialize to camelCase JSON matching the ACP spec.""" + + def test_image_part_serializes_mime_type_as_camel(self): + part = ImagePart(data="abc", mime_type="image/jpeg") + dumped = part.model_dump(by_alias=True) + assert "mimeType" in dumped + assert dumped["mimeType"] == "image/jpeg" + assert "mime_type" not in dumped + + def test_audio_part_serializes_mime_type_as_camel(self): + part = AudioPart(data="abc", mime_type="audio/mp3") + dumped = part.model_dump(by_alias=True) + assert dumped["mimeType"] == "audio/mp3" + + def test_resource_link_serializes_mime_type_as_camel(self): + part = ResourceLinkPart(uri="file:///a.txt", name="a", mime_type="text/plain") + dumped = part.model_dump(by_alias=True) + assert dumped["mimeType"] == "text/plain" + + def test_embedded_resource_serializes_mime_type_as_camel(self): + res = EmbeddedResource(uri="file:///b.txt", mime_type="text/csv") + dumped = res.model_dump(by_alias=True) + assert dumped["mimeType"] == "text/csv" + + def test_tool_call_serializes_id_as_camel(self): + tc = ACPToolCall(tool_call_id="call_1", name="search", kind="fetch") + dumped = tc.model_dump(by_alias=True) + assert "toolCallId" in dumped + assert dumped["toolCallId"] == "call_1" + assert "tool_call_id" not in dumped + + def test_agent_message_update_serializes_discriminator(self): + u = AgentMessageUpdate(text="hello") + dumped = u.model_dump(by_alias=True) + assert dumped["sessionUpdate"] == "agent_message" + + def test_tool_call_update_serializes_all_camel_fields(self): + u = ToolCallUpdate(tool_call_id="c1", name="read", kind="read", status="completed") + dumped = u.model_dump(by_alias=True) + assert dumped["sessionUpdate"] == "tool_call" + assert dumped["toolCallId"] == "c1" + + def test_mode_update_serializes_camel_fields(self): + u = ModeUpdate( + current_mode_id="code", + available_modes=[SessionMode(id="code", name="Code")], + ) + dumped = u.model_dump(by_alias=True) + assert dumped["currentModeId"] == "code" + assert dumped["availableModes"][0]["id"] == "code" + + def test_permission_request_serializes_camel(self): + req = PermissionRequest(tool_call_id="c1", title="Run command") + dumped = req.model_dump(by_alias=True) + assert dumped["toolCallId"] == "c1" + + def test_session_config_option_serializes_camel(self): + opt = SessionConfigOption(id="model", name="Model", current_value="gpt-4") + dumped = opt.model_dump(by_alias=True) + assert dumped["currentValue"] == "gpt-4" + + def test_client_capabilities_serializes_camel(self): + caps = ClientCapabilities(file_system=True, terminal=False) + dumped = caps.model_dump(by_alias=True) + assert dumped["fileSystem"] is True + + def test_agent_capabilities_serializes_camel(self): + caps = AgentCapabilities( + prompt_capabilities=PromptCapabilities(image=True, embedded_context=True), + load_session=True, + config_options=False, + ) + dumped = caps.model_dump(by_alias=True) + assert dumped["loadSession"] is True + assert dumped["promptCapabilities"]["embeddedContext"] is True + + def test_snake_case_constructor_works(self): + part = ImagePart(data="x", mime_type="image/png") + assert part.mime_type == "image/png" + + def test_camel_case_constructor_works(self): + part = ImagePart(data="x", mimeType="image/png") + assert part.mime_type == "image/png" + + def test_chat_message_with_tool_calls_round_trips(self): + msg = ChatMessage( + role="assistant", + content="calling tool", + tool_calls=[ACPToolCall(tool_call_id="c1", name="search", kind="fetch")], + ) + dumped = msg.model_dump(by_alias=True) + assert dumped["tool_calls"][0]["toolCallId"] == "c1" + restored = ChatMessage.model_validate(dumped) + assert restored.tool_calls[0].tool_call_id == "c1" + + +class TestCallbackProviderRoundTrip: + """Verify CallbackProvider yields SessionUpdate objects that can be consumed.""" + + def test_string_callback_yields_agent_message(self): + from pywry.chat.providers.callback import CallbackProvider + + def my_prompt(session_id, content, cancel_event): + yield "hello " + yield "world" + + provider = CallbackProvider(prompt_fn=my_prompt) + updates = [] + + async def collect(): + await provider.initialize(ClientCapabilities()) + sid = await provider.new_session("/tmp") + async for u in provider.prompt(sid, [TextPart(text="hi")]): + updates.append(u) + + asyncio.run(collect()) + assert len(updates) == 2 + assert all(isinstance(u, AgentMessageUpdate) for u in updates) + assert updates[0].text == "hello " + assert updates[1].text == "world" + + def test_session_update_objects_pass_through(self): + from pywry.chat.providers.callback import CallbackProvider + + def my_prompt(session_id, content, cancel_event): + yield StatusUpdate(text="searching...") + yield AgentMessageUpdate(text="found it") + yield ToolCallUpdate(tool_call_id="c1", name="search", status="completed") + + provider = CallbackProvider(prompt_fn=my_prompt) + updates = [] + + async def collect(): + await provider.initialize(ClientCapabilities()) + sid = await provider.new_session("/tmp") + async for u in provider.prompt(sid, [TextPart(text="find x")]): + updates.append(u) + + asyncio.run(collect()) + assert isinstance(updates[0], StatusUpdate) + assert isinstance(updates[1], AgentMessageUpdate) + assert isinstance(updates[2], ToolCallUpdate) + assert updates[2].tool_call_id == "c1" + + def test_no_callback_yields_fallback(self): + from pywry.chat.providers.callback import CallbackProvider + + provider = CallbackProvider() + updates = [] + + async def collect(): + await provider.initialize(ClientCapabilities()) + sid = await provider.new_session("/tmp") + async for u in provider.prompt(sid, [TextPart(text="hi")]): + updates.append(u) + + asyncio.run(collect()) + assert len(updates) == 1 + assert "No prompt callback" in updates[0].text + + +class TestChatManagerProviderIntegration: + """Verify ChatManager dispatches provider SessionUpdates to the correct frontend events.""" + + def test_agent_message_produces_stream_chunks(self): + def my_prompt(session_id, content, cancel_event): + yield AgentMessageUpdate(text="hello ") + yield AgentMessageUpdate(text="world") + + from pywry.chat.providers.callback import CallbackProvider + + provider = CallbackProvider(prompt_fn=my_prompt) + widget = FakeWidget() + mgr = ChatManager(provider=provider) + mgr.bind(widget) + mgr._session_id = "test" + mgr._on_user_message( + {"text": "hi", "threadId": mgr.active_thread_id}, + "chat:user-message", + "", + ) + time.sleep(0.5) + chunks = widget.get_events("chat:stream-chunk") + text_chunks = [c["chunk"] for c in chunks if c.get("chunk")] + assert "hello " in text_chunks + assert "world" in text_chunks + + def test_tool_call_update_produces_tool_call_event(self): + def my_prompt(session_id, content, cancel_event): + yield ToolCallUpdate( + tool_call_id="c1", + name="search", + kind="fetch", + status="in_progress", + ) + yield ToolCallUpdate( + tool_call_id="c1", + name="search", + kind="fetch", + status="completed", + ) + yield AgentMessageUpdate(text="done") + + from pywry.chat.providers.callback import CallbackProvider + + provider = CallbackProvider(prompt_fn=my_prompt) + widget = FakeWidget() + mgr = ChatManager(provider=provider) + mgr.bind(widget) + mgr._session_id = "test" + mgr._on_user_message( + {"text": "search", "threadId": mgr.active_thread_id}, + "chat:user-message", + "", + ) + time.sleep(0.5) + tool_events = widget.get_events("chat:tool-call") + assert len(tool_events) == 2 + assert tool_events[0]["status"] == "in_progress" + assert tool_events[1]["status"] == "completed" + assert tool_events[0]["toolCallId"] == "c1" + + def test_plan_update_produces_plan_event(self): + def my_prompt(session_id, content, cancel_event): + yield PlanUpdate( + entries=[ + PlanEntry(content="step 1", priority="high", status="completed"), + PlanEntry(content="step 2", priority="medium", status="in_progress"), + ] + ) + yield AgentMessageUpdate(text="working") + + from pywry.chat.providers.callback import CallbackProvider + + provider = CallbackProvider(prompt_fn=my_prompt) + widget = FakeWidget() + mgr = ChatManager(provider=provider) + mgr.bind(widget) + mgr._session_id = "test" + mgr._on_user_message( + {"text": "plan", "threadId": mgr.active_thread_id}, + "chat:user-message", + "", + ) + time.sleep(0.5) + plan_events = widget.get_events("chat:plan-update") + assert len(plan_events) >= 1 + entries = plan_events[0]["entries"] + assert len(entries) == 2 + assert entries[0]["content"] == "step 1" + assert entries[0]["status"] == "completed" + assert entries[1]["priority"] == "medium" + + def test_status_and_thinking_produce_correct_events(self): + def my_prompt(session_id, content, cancel_event): + yield StatusUpdate(text="loading...") + yield ThinkingUpdate(text="considering options\n") + yield AgentMessageUpdate(text="answer") + + from pywry.chat.providers.callback import CallbackProvider + + provider = CallbackProvider(prompt_fn=my_prompt) + widget = FakeWidget() + mgr = ChatManager(provider=provider) + mgr.bind(widget) + mgr._session_id = "test" + mgr._on_user_message( + {"text": "go", "threadId": mgr.active_thread_id}, + "chat:user-message", + "", + ) + time.sleep(0.5) + status = widget.get_events("chat:status-update") + thinking = widget.get_events("chat:thinking-chunk") + assert any(s["text"] == "loading..." for s in status) + assert any(t["text"] == "considering options\n" for t in thinking) + + def test_permission_request_produces_permission_event(self): + def my_prompt(session_id, content, cancel_event): + yield PermissionRequestUpdate( + tool_call_id="c1", + title="Delete file", + request_id="perm_1", + ) + yield AgentMessageUpdate(text="waiting for approval") + + from pywry.chat.providers.callback import CallbackProvider + + provider = CallbackProvider(prompt_fn=my_prompt) + widget = FakeWidget() + mgr = ChatManager(provider=provider) + mgr.bind(widget) + mgr._session_id = "test" + mgr._on_user_message( + {"text": "delete", "threadId": mgr.active_thread_id}, + "chat:user-message", + "", + ) + time.sleep(0.5) + perms = widget.get_events("chat:permission-request") + assert len(perms) >= 1 + assert perms[0]["toolCallId"] == "c1" + assert perms[0]["title"] == "Delete file" + assert perms[0]["requestId"] == "perm_1" + + +class TestToolCallLifecycle: + """Verify tool calls transition through the correct status sequence.""" + + def test_pending_to_completed(self): + def my_prompt(session_id, content, cancel_event): + yield ToolCallUpdate(tool_call_id="c1", name="read_file", kind="read", status="pending") + yield ToolCallUpdate( + tool_call_id="c1", name="read_file", kind="read", status="in_progress" + ) + yield ToolCallUpdate( + tool_call_id="c1", name="read_file", kind="read", status="completed" + ) + yield AgentMessageUpdate(text="file contents here") + + from pywry.chat.providers.callback import CallbackProvider + + provider = CallbackProvider(prompt_fn=my_prompt) + widget = FakeWidget() + mgr = ChatManager(provider=provider) + mgr.bind(widget) + mgr._session_id = "test" + mgr._on_user_message( + {"text": "read", "threadId": mgr.active_thread_id}, + "chat:user-message", + "", + ) + time.sleep(0.5) + tool_events = widget.get_events("chat:tool-call") + statuses = [e["status"] for e in tool_events] + assert statuses == ["pending", "in_progress", "completed"] + assert all(e["toolCallId"] == "c1" for e in tool_events) + assert all(e["kind"] == "read" for e in tool_events) + + def test_failed_status(self): + def my_prompt(session_id, content, cancel_event): + yield ToolCallUpdate(tool_call_id="c2", name="exec", kind="execute", status="pending") + yield ToolCallUpdate(tool_call_id="c2", name="exec", kind="execute", status="failed") + yield AgentMessageUpdate(text="command failed") + + from pywry.chat.providers.callback import CallbackProvider + + provider = CallbackProvider(prompt_fn=my_prompt) + widget = FakeWidget() + mgr = ChatManager(provider=provider) + mgr.bind(widget) + mgr._session_id = "test" + mgr._on_user_message( + {"text": "exec", "threadId": mgr.active_thread_id}, + "chat:user-message", + "", + ) + time.sleep(0.5) + tool_events = widget.get_events("chat:tool-call") + assert tool_events[-1]["status"] == "failed" + + +class TestTradingViewArtifactDispatch: + """Verify TradingViewArtifact produces the correct event payload.""" + + def test_dispatch_produces_artifact_event_with_series(self): + def my_prompt(session_id, content, cancel_event): + yield ArtifactUpdate( + artifact=TradingViewArtifact( + title="AAPL", + series=[ + TradingViewSeries( + type="candlestick", + data=[ + { + "time": "2024-01-02", + "open": 185, + "high": 186, + "low": 184, + "close": 185, + } + ], + ), + TradingViewSeries( + type="line", + data=[{"time": "2024-01-02", "value": 185}], + options={"color": "#ff0000"}, + ), + ], + options={"timeScale": {"timeVisible": True}}, + height="500px", + ) + ) + + from pywry.chat.providers.callback import CallbackProvider + + provider = CallbackProvider(prompt_fn=my_prompt) + widget = FakeWidget() + mgr = ChatManager(provider=provider) + mgr.bind(widget) + mgr._session_id = "test" + mgr._on_user_message( + {"text": "chart", "threadId": mgr.active_thread_id}, + "chat:user-message", + "", + ) + time.sleep(0.5) + artifacts = widget.get_events("chat:artifact") + assert len(artifacts) >= 1 + a = artifacts[0] + assert a["artifactType"] == "tradingview" + assert a["title"] == "AAPL" + assert a["height"] == "500px" + assert len(a["series"]) == 2 + assert a["series"][0]["type"] == "candlestick" + assert a["series"][1]["options"]["color"] == "#ff0000" + assert a["options"]["timeScale"]["timeVisible"] is True + + def test_code_artifact_dispatch(self): + def my_prompt(session_id, content, cancel_event): + yield ArtifactUpdate( + artifact=CodeArtifact( + title="main.py", + language="python", + content="x = 42", + ) + ) + + from pywry.chat.providers.callback import CallbackProvider + + provider = CallbackProvider(prompt_fn=my_prompt) + widget = FakeWidget() + mgr = ChatManager(provider=provider) + mgr.bind(widget) + mgr._session_id = "test" + mgr._on_user_message( + {"text": "code", "threadId": mgr.active_thread_id}, + "chat:user-message", + "", + ) + time.sleep(0.5) + artifacts = widget.get_events("chat:artifact") + assert len(artifacts) >= 1 + assert artifacts[0]["artifactType"] == "code" + assert artifacts[0]["language"] == "python" + assert artifacts[0]["content"] == "x = 42" + + +class TestRBACPermissions: + """Verify permission checks block or allow operations correctly.""" + + def test_permission_map_covers_all_operations(self): + required_ops = [ + "session/new", + "session/load", + "session/prompt", + "session/cancel", + "session/set_config_option", + "session/set_mode", + "session/request_permission", + "fs/read_text_file", + "fs/write_text_file", + "terminal/create", + "terminal/kill", + ] + for op in required_ops: + assert op in ACP_PERMISSION_MAP, f"{op} missing from permission map" + + def test_prompt_requires_write(self): + assert ACP_PERMISSION_MAP["session/prompt"] == "write" + + def test_file_write_requires_admin(self): + assert ACP_PERMISSION_MAP["fs/write_text_file"] == "admin" + + def test_file_read_requires_read(self): + assert ACP_PERMISSION_MAP["fs/read_text_file"] == "read" + + def test_terminal_requires_admin(self): + assert ACP_PERMISSION_MAP["terminal/create"] == "admin" + + @pytest.mark.asyncio + async def test_no_session_allows_everything(self): + result = await check_acp_permission(None, "w1", "session/prompt", None) + assert result is True + + @pytest.mark.asyncio + async def test_no_session_allows_admin_ops(self): + result = await check_acp_permission(None, "w1", "fs/write_text_file", None) + assert result is True + + @pytest.mark.asyncio + async def test_unknown_operation_defaults_to_admin(self): + assert ACP_PERMISSION_MAP.get("unknown/op") is None + result = await check_acp_permission(None, "w1", "unknown/op", None) + assert result is True + + +class TestCancelPropagation: + """Verify cancel signal reaches the provider through ChatManager.""" + + def test_cancel_stops_generation(self): + chunks_yielded = [] + + def my_prompt(session_id, content, cancel_event): + for i in range(100): + if cancel_event and cancel_event.is_set(): + return + chunks_yielded.append(i) + yield AgentMessageUpdate(text=f"chunk{i} ") + time.sleep(0.01) + + from pywry.chat.providers.callback import CallbackProvider + + provider = CallbackProvider(prompt_fn=my_prompt) + widget = FakeWidget() + mgr = ChatManager(provider=provider) + mgr.bind(widget) + mgr._session_id = "test" + mgr._on_user_message( + {"text": "go", "threadId": mgr.active_thread_id}, + "chat:user-message", + "", + ) + time.sleep(0.05) + mgr._on_stop_generation( + {"threadId": mgr.active_thread_id}, + "chat:stop-generation", + "", + ) + time.sleep(0.5) + assert len(chunks_yielded) < 100 + done_chunks = [c for c in widget.get_events("chat:stream-chunk") if c.get("done")] + assert len(done_chunks) >= 1 + + +class TestHandlerWithSessionUpdates: + """Verify handler functions can yield SessionUpdate types alongside strings.""" + + def test_handler_yields_mixed_strings_and_updates(self): + def handler(messages, ctx): + yield "starting... " + yield StatusUpdate(text="processing") + yield PlanUpdate( + entries=[ + PlanEntry(content="task 1", priority="high", status="in_progress"), + ] + ) + yield "done" + + widget = FakeWidget() + mgr = ChatManager(handler=handler) + mgr.bind(widget) + mgr._on_user_message( + {"text": "go", "threadId": mgr.active_thread_id}, + "chat:user-message", + "", + ) + time.sleep(0.5) + chunks = widget.get_events("chat:stream-chunk") + status = widget.get_events("chat:status-update") + plan = widget.get_events("chat:plan-update") + text = "".join(c["chunk"] for c in chunks if c.get("chunk")) + assert "starting" in text + assert "done" in text + assert any(s["text"] == "processing" for s in status) + assert len(plan) >= 1 + assert plan[0]["entries"][0]["content"] == "task 1" + + +class TestCommandsAndConfigUpdates: + """Verify commands and config option updates dispatch correctly.""" + + def test_commands_update_registers_commands(self): + from pywry.chat.models import ACPCommand + + def my_prompt(session_id, content, cancel_event): + yield CommandsUpdate( + commands=[ + ACPCommand(name="test", description="Run tests"), + ACPCommand(name="deploy", description="Deploy app"), + ] + ) + yield AgentMessageUpdate(text="ready") + + from pywry.chat.providers.callback import CallbackProvider + + provider = CallbackProvider(prompt_fn=my_prompt) + widget = FakeWidget() + mgr = ChatManager(provider=provider) + mgr.bind(widget) + mgr._session_id = "test" + mgr._on_user_message( + {"text": "init", "threadId": mgr.active_thread_id}, + "chat:user-message", + "", + ) + time.sleep(0.5) + cmds = widget.get_events("chat:register-command") + names = [c["name"] for c in cmds] + assert "test" in names + assert "deploy" in names + + def test_config_option_update_dispatches(self): + def my_prompt(session_id, content, cancel_event): + yield ConfigOptionUpdate( + options=[ + SessionConfigOption(id="model", name="Model", current_value="gpt-4"), + ] + ) + yield AgentMessageUpdate(text="configured") + + from pywry.chat.providers.callback import CallbackProvider + + provider = CallbackProvider(prompt_fn=my_prompt) + widget = FakeWidget() + mgr = ChatManager(provider=provider) + mgr.bind(widget) + mgr._session_id = "test" + mgr._on_user_message( + {"text": "config", "threadId": mgr.active_thread_id}, + "chat:user-message", + "", + ) + time.sleep(0.5) + configs = widget.get_events("chat:config-update") + assert len(configs) >= 1 + assert configs[0]["options"][0]["id"] == "model" + + def test_mode_update_dispatches(self): + def my_prompt(session_id, content, cancel_event): + yield ModeUpdate( + current_mode_id="code", + available_modes=[ + SessionMode(id="ask", name="Ask"), + SessionMode(id="code", name="Code"), + ], + ) + yield AgentMessageUpdate(text="mode set") + + from pywry.chat.providers.callback import CallbackProvider + + provider = CallbackProvider(prompt_fn=my_prompt) + widget = FakeWidget() + mgr = ChatManager(provider=provider) + mgr.bind(widget) + mgr._session_id = "test" + mgr._on_user_message( + {"text": "mode", "threadId": mgr.active_thread_id}, + "chat:user-message", + "", + ) + time.sleep(0.5) + modes = widget.get_events("chat:mode-update") + assert len(modes) >= 1 + assert modes[0]["currentModeId"] == "code" + assert len(modes[0]["availableModes"]) == 2 diff --git a/pywry/tests/test_deepagent_provider.py b/pywry/tests/test_deepagent_provider.py new file mode 100644 index 0000000..20f691a --- /dev/null +++ b/pywry/tests/test_deepagent_provider.py @@ -0,0 +1,251 @@ +"""Tests for the DeepAgentProvider. + +Uses a mock CompiledGraph that yields known astream_events +to verify the provider maps LangGraph events to ACP SessionUpdate types. +""" + +from __future__ import annotations + +import asyncio + +import pytest + +from pywry.chat.models import TextPart +from pywry.chat.providers.deepagent import DeepagentProvider, _map_tool_kind +from pywry.chat.session import ClientCapabilities +from pywry.chat.updates import ( + AgentMessageUpdate, + PlanUpdate, + StatusUpdate, + ToolCallUpdate, +) + + +class FakeChunk: + def __init__(self, content: str = ""): + self.content = content + + +def make_event(event: str, name: str = "", data: dict | None = None, run_id: str = "r1"): + return {"event": event, "name": name, "data": data or {}, "run_id": run_id} + + +async def fake_stream_events(events: list[dict]): + for e in events: + yield e + + +class FakeAgent: + def __init__(self, events: list[dict]): + self._events = events + + def astream_events(self, input_data: dict, config: dict, version: str = "v2"): + return fake_stream_events(self._events) + + +class TestToolKindMapping: + def test_read_file(self): + assert _map_tool_kind("read_file") == "read" + + def test_write_file(self): + assert _map_tool_kind("write_file") == "edit" + + def test_execute(self): + assert _map_tool_kind("execute") == "execute" + + def test_write_todos(self): + assert _map_tool_kind("write_todos") == "think" + + def test_unknown_tool(self): + assert _map_tool_kind("my_custom_tool") == "other" + + +class TestDeepagentProviderConstruction: + def test_with_pre_built_agent(self): + agent = FakeAgent([]) + provider = DeepagentProvider(agent=agent) + assert provider._agent is agent + + def test_without_agent_stores_params(self): + provider = DeepagentProvider(model="openai:gpt-4o", system_prompt="be helpful") + assert provider._agent is None + assert provider._model == "openai:gpt-4o" + + +class TestDeepagentProviderInitialize: + @pytest.mark.asyncio + async def test_initialize_returns_capabilities(self): + agent = FakeAgent([]) + provider = DeepagentProvider(agent=agent, auto_checkpointer=False, auto_store=False) + caps = await provider.initialize(ClientCapabilities()) + assert caps.prompt_capabilities is not None + assert caps.prompt_capabilities.image is True + + @pytest.mark.asyncio + async def test_initialize_with_checkpointer_enables_load(self): + pytest.importorskip("langgraph") + from langgraph.checkpoint.memory import MemorySaver + + agent = FakeAgent([]) + provider = DeepagentProvider( + agent=agent, checkpointer=MemorySaver(), auto_checkpointer=False, auto_store=False + ) + caps = await provider.initialize(ClientCapabilities()) + assert caps.load_session is True + + @pytest.mark.asyncio + async def test_initialize_without_checkpointer_disables_load(self): + agent = FakeAgent([]) + provider = DeepagentProvider(agent=agent, auto_checkpointer=False, auto_store=False) + caps = await provider.initialize(ClientCapabilities()) + assert caps.load_session is False + + +class TestDeepagentProviderSessions: + @pytest.mark.asyncio + async def test_new_session_returns_id(self): + agent = FakeAgent([]) + provider = DeepagentProvider(agent=agent, auto_checkpointer=False, auto_store=False) + await provider.initialize(ClientCapabilities()) + sid = await provider.new_session("/tmp") + assert sid.startswith("da_") + + @pytest.mark.asyncio + async def test_load_nonexistent_session_raises(self): + agent = FakeAgent([]) + provider = DeepagentProvider(agent=agent, auto_checkpointer=False, auto_store=False) + await provider.initialize(ClientCapabilities()) + with pytest.raises(ValueError, match="not found"): + await provider.load_session("nonexistent", "/tmp") + + +class TestDeepagentProviderStreaming: + @pytest.mark.asyncio + async def test_text_chunks(self): + events = [ + make_event("on_chat_model_stream", data={"chunk": FakeChunk("hello ")}), + make_event("on_chat_model_stream", data={"chunk": FakeChunk("world")}), + ] + agent = FakeAgent(events) + provider = DeepagentProvider(agent=agent, auto_checkpointer=False, auto_store=False) + await provider.initialize(ClientCapabilities()) + sid = await provider.new_session("/tmp") + + updates = [] + async for u in provider.prompt(sid, [TextPart(text="hi")]): + updates.append(u) + + assert len(updates) == 2 + assert all(isinstance(u, AgentMessageUpdate) for u in updates) + assert updates[0].text == "hello " + assert updates[1].text == "world" + + @pytest.mark.asyncio + async def test_tool_call_lifecycle(self): + events = [ + make_event("on_tool_start", name="read_file", run_id="tc1"), + make_event("on_tool_end", name="read_file", run_id="tc1", data={"output": "contents"}), + ] + agent = FakeAgent(events) + provider = DeepagentProvider(agent=agent, auto_checkpointer=False, auto_store=False) + await provider.initialize(ClientCapabilities()) + sid = await provider.new_session("/tmp") + + updates = [] + async for u in provider.prompt(sid, [TextPart(text="read")]): + updates.append(u) + + assert len(updates) == 2 + assert isinstance(updates[0], ToolCallUpdate) + assert updates[0].status == "in_progress" + assert updates[0].kind == "read" + assert isinstance(updates[1], ToolCallUpdate) + assert updates[1].status == "completed" + + @pytest.mark.asyncio + async def test_tool_error(self): + events = [ + make_event("on_tool_start", name="execute", run_id="tc2"), + make_event("on_tool_error", name="execute", run_id="tc2"), + ] + agent = FakeAgent(events) + provider = DeepagentProvider(agent=agent, auto_checkpointer=False, auto_store=False) + await provider.initialize(ClientCapabilities()) + sid = await provider.new_session("/tmp") + + updates = [] + async for u in provider.prompt(sid, [TextPart(text="run")]): + updates.append(u) + + assert updates[-1].status == "failed" + + @pytest.mark.asyncio + async def test_write_todos_produces_plan_update(self): + import json + + todos = [ + {"title": "Read docs", "status": "done"}, + {"title": "Write code", "status": "in_progress"}, + ] + events = [ + make_event("on_tool_start", name="write_todos", run_id="tc3"), + make_event( + "on_tool_end", name="write_todos", run_id="tc3", data={"output": json.dumps(todos)} + ), + ] + agent = FakeAgent(events) + provider = DeepagentProvider(agent=agent, auto_checkpointer=False, auto_store=False) + await provider.initialize(ClientCapabilities()) + sid = await provider.new_session("/tmp") + + updates = [] + async for u in provider.prompt(sid, [TextPart(text="plan")]): + updates.append(u) + + plan_updates = [u for u in updates if isinstance(u, PlanUpdate)] + assert len(plan_updates) == 1 + assert len(plan_updates[0].entries) == 2 + assert plan_updates[0].entries[0].content == "Read docs" + assert plan_updates[0].entries[0].status == "completed" + assert plan_updates[0].entries[1].status == "in_progress" + + @pytest.mark.asyncio + async def test_cancel_stops_streaming(self): + events = [ + make_event("on_chat_model_stream", data={"chunk": FakeChunk(f"chunk{i}")}) + for i in range(100) + ] + agent = FakeAgent(events) + provider = DeepagentProvider(agent=agent, auto_checkpointer=False, auto_store=False) + await provider.initialize(ClientCapabilities()) + sid = await provider.new_session("/tmp") + + cancel = asyncio.Event() + updates = [] + count = 0 + async for u in provider.prompt(sid, [TextPart(text="go")], cancel_event=cancel): + updates.append(u) + count += 1 + if count == 3: + cancel.set() + + assert len(updates) < 100 + + @pytest.mark.asyncio + async def test_chat_model_start_yields_status(self): + events = [ + make_event("on_chat_model_start", name="ChatOpenAI"), + make_event("on_chat_model_stream", data={"chunk": FakeChunk("answer")}), + ] + agent = FakeAgent(events) + provider = DeepagentProvider(agent=agent, auto_checkpointer=False, auto_store=False) + await provider.initialize(ClientCapabilities()) + sid = await provider.new_session("/tmp") + + updates = [] + async for u in provider.prompt(sid, [TextPart(text="hi")]): + updates.append(u) + + assert isinstance(updates[0], StatusUpdate) + assert "ChatOpenAI" in updates[0].text + assert isinstance(updates[1], AgentMessageUpdate) diff --git a/pywry/tests/test_mcp_unit.py b/pywry/tests/test_mcp_unit.py index 908202a..49e2fa0 100644 --- a/pywry/tests/test_mcp_unit.py +++ b/pywry/tests/test_mcp_unit.py @@ -782,7 +782,7 @@ def test_resources_include_skills(self) -> None: resources = get_resources() uris = [str(r.uri) for r in resources] - # Verify no legacy pywry://skill/ URIs remain + # Verify no pywry://skill/ URIs remain assert not any("pywry://skill/" in uri for uri in uris) def test_get_resource_templates(self) -> None: diff --git a/pywry/tests/test_plotly_theme_merge.py b/pywry/tests/test_plotly_theme_merge.py index 623ab33..ece24b4 100644 --- a/pywry/tests/test_plotly_theme_merge.py +++ b/pywry/tests/test_plotly_theme_merge.py @@ -446,7 +446,7 @@ def test_light_theme_picks_light_user_template(self) -> None: assert result["layout"]["paper_bgcolor"] == "#CUSTOM_LIGHT" assert result["layout"]["font"]["color"] == "#333" # base light font - def test_fallback_to_legacy_template_when_no_dual(self) -> None: + def test_fallback_to_single_template_when_no_dual(self) -> None: """When only a single template is provided, it applies to both modes.""" result = _run_js_json(""" PYWRY_PLOTLY_TEMPLATES = { @@ -455,21 +455,21 @@ def test_fallback_to_legacy_template_when_no_dual(self) -> None: var plotDiv = {}; var merged = mergeThemeTemplate( plotDiv, 'plotly_dark', - {layout: {paper_bgcolor: '#LEGACY'}}, // single/legacy + {layout: {paper_bgcolor: '#SINGLE'}}, // single template null, null // no dual templates ); console.log(JSON.stringify(merged)); """) - assert result["layout"]["paper_bgcolor"] == "#LEGACY" + assert result["layout"]["paper_bgcolor"] == "#SINGLE" - def test_legacy_fallback_also_applies_on_light(self) -> None: - """Single/legacy template also works for light mode.""" + def test_single_template_fallback_also_applies_on_light(self) -> None: + """Single template also works for light mode.""" result = _run_js_json(""" PYWRY_PLOTLY_TEMPLATES = { plotly_white: {layout: {paper_bgcolor: '#fff'}} }; var plotDiv = {}; - // First call with legacy template + // First call with single template mergeThemeTemplate(plotDiv, 'plotly_white', {layout: {font: {size: 20}}}, null, null); // Second call: theme toggle (no new templates — reads from stored) var merged = mergeThemeTemplate(plotDiv, 'plotly_white', null, null, null); @@ -527,7 +527,7 @@ def test_unknown_theme_name_returns_override_only(self) -> None: console.log(JSON.stringify(merged)); """) # "nonexistent_theme" doesn't contain 'dark', so it's treated as light - # No light template provided, no legacy template -> base is empty + # No light template provided, no single template -> base is empty assert result == {} def test_dark_override_with_complex_nested_values(self) -> None: diff --git a/pywry/tests/test_plotly_theme_merge_e2e.py b/pywry/tests/test_plotly_theme_merge_e2e.py index 80adcd4..66ec94f 100644 --- a/pywry/tests/test_plotly_theme_merge_e2e.py +++ b/pywry/tests/test_plotly_theme_merge_e2e.py @@ -49,7 +49,7 @@ def _read_chart_template_state(label: str) -> dict | None: - fontFamily: str - the rendered font family (if set) - storedDark: bool - whether __pywry_user_template_dark__ is on the div - storedLight: bool - whether __pywry_user_template_light__ is on the div - - storedLegacy: bool - whether __pywry_user_template__ is on the div + - storedSingle: bool - whether __pywry_user_template__ is on the div - baseDarkPaperBg: str - the base plotly_dark template's paper_bgcolor - baseLightPaperBg: str - the base plotly_white template's paper_bgcolor """ @@ -72,7 +72,7 @@ def _read_chart_template_state(label: str) -> dict | None: fontFamily: plotDiv && plotDiv._fullLayout ? (plotDiv._fullLayout.font.family || null) : null, storedDark: plotDiv ? !!plotDiv.__pywry_user_template_dark__ : false, storedLight: plotDiv ? !!plotDiv.__pywry_user_template_light__ : false, - storedLegacy: plotDiv ? !!plotDiv.__pywry_user_template__ : false, + storedSingle: plotDiv ? !!plotDiv.__pywry_user_template__ : false, baseDarkPaperBg: templates.plotly_dark ? templates.plotly_dark.layout.paper_bgcolor : null, baseLightPaperBg: templates.plotly_white ? templates.plotly_white.layout.paper_bgcolor : null }); @@ -161,9 +161,8 @@ def test_dual_templates_stored_on_dom(self, dark_app) -> None: # Both templates must be persisted on the DOM element assert result["storedDark"], "template_dark not stored on plot div!" assert result["storedLight"], "template_light not stored on plot div!" - # Legacy single template should NOT be stored when dual templates are used - assert not result["storedLegacy"], ( - "Legacy template should NOT be stored when dual templates given!" + assert not result["storedSingle"], ( + "Single template should not be stored when dual templates are given" ) def test_base_theme_values_kept_where_not_overridden(self, dark_app) -> None: diff --git a/pywry/tests/test_scripts.py b/pywry/tests/test_scripts.py index f445060..aa42f4c 100644 --- a/pywry/tests/test_scripts.py +++ b/pywry/tests/test_scripts.py @@ -1,164 +1,102 @@ """Tests for JavaScript bridge scripts. -Tests the PyWry JavaScript bridge and event system scripts. +Tests the PyWry JavaScript bridge and event system scripts, +which are now loaded from frontend/src/ files. """ -from pywry.scripts import PYWRY_BRIDGE_JS, build_init_script +from pywry.scripts import _get_bridge_js, build_init_script -class TestPywryBridgeJs: - """Tests for PYWRY_BRIDGE_JS constant.""" +class TestBridgeJs: + """Tests for the bridge JS loaded from frontend/src/bridge.js.""" def test_defines_window_pywry(self): - """Defines window.pywry object.""" - assert "window.pywry" in PYWRY_BRIDGE_JS + js = _get_bridge_js() + assert "window.pywry" in js def test_defines_result_function(self): - """Defines result function.""" - assert "result" in PYWRY_BRIDGE_JS + js = _get_bridge_js() + assert "result" in js def test_defines_emit_function(self): - """Defines emit function.""" - assert "emit" in PYWRY_BRIDGE_JS + js = _get_bridge_js() + assert "emit" in js def test_defines_on_function(self): - """Defines on function for event handling.""" - assert ".on" in PYWRY_BRIDGE_JS + js = _get_bridge_js() + assert ".on" in js def test_defines_off_function(self): - """Defines off function for event handling.""" - assert ".off" in PYWRY_BRIDGE_JS + js = _get_bridge_js() + assert ".off" in js def test_defines_dispatch_function(self): - """Defines dispatch function.""" - assert "dispatch" in PYWRY_BRIDGE_JS + js = _get_bridge_js() + assert "dispatch" in js def test_is_string(self): - """Bridge JS is a string.""" - assert isinstance(PYWRY_BRIDGE_JS, str) + js = _get_bridge_js() + assert isinstance(js, str) def test_is_not_empty(self): - """Bridge JS is not empty.""" - assert len(PYWRY_BRIDGE_JS) > 0 + js = _get_bridge_js() + assert len(js) > 0 + + def test_uses_strict_mode(self): + js = _get_bridge_js() + assert "'use strict'" in js + + def test_uses_iife(self): + js = _get_bridge_js() + assert "(function()" in js + + def test_handles_json_payload(self): + js = _get_bridge_js() + assert "payload" in js + + def test_checks_for_tauri(self): + js = _get_bridge_js() + assert "__TAURI__" in js + + def test_uses_pytauri_invoke(self): + js = _get_bridge_js() + assert "pytauri" in js + assert "pyInvoke" in js + + def test_open_file_function(self): + js = _get_bridge_js() + assert "openFile" in js + + def test_wildcard_handlers_supported(self): + js = _get_bridge_js() + assert "'*'" in js class TestBuildInitScript: """Tests for build_init_script function.""" def test_returns_string(self): - """Returns a string.""" script = build_init_script(window_label="main") assert isinstance(script, str) def test_includes_window_label(self): - """Includes window label.""" script = build_init_script(window_label="test-window") assert "test-window" in script def test_includes_pywry_bridge(self): - """Includes pywry bridge code.""" script = build_init_script(window_label="main") assert "pywry" in script def test_different_labels_produce_different_scripts(self): - """Different labels produce different scripts.""" script1 = build_init_script(window_label="window-1") script2 = build_init_script(window_label="window-2") assert "window-1" in script1 assert "window-2" in script2 + def test_hot_reload_included_when_enabled(self): + script = build_init_script(window_label="main", enable_hot_reload=True) + assert "Hot reload" in script or "saveScrollPosition" in script -class TestBridgeJsStructure: - """Tests for bridge JS structure and content.""" - - def test_uses_strict_mode(self): - """Uses strict mode.""" - assert "'use strict'" in PYWRY_BRIDGE_JS or '"use strict"' in PYWRY_BRIDGE_JS - - def test_uses_iife(self): - """Uses IIFE pattern.""" - assert "(function()" in PYWRY_BRIDGE_JS - - def test_handles_json_payload(self): - """Handles JSON payload structure.""" - # Should create payload objects - assert "payload" in PYWRY_BRIDGE_JS - - -class TestBridgeJsResultFunction: - """Tests for result function in bridge JS.""" - - def test_result_sends_data(self): - """Result function sends data field.""" - assert "data:" in PYWRY_BRIDGE_JS or "data :" in PYWRY_BRIDGE_JS - - def test_result_sends_window_label(self): - """Result function sends window_label field.""" - assert "window_label" in PYWRY_BRIDGE_JS - - -class TestBridgeJsEmitFunction: - """Tests for emit function in bridge JS.""" - - def test_emit_validates_event_type(self): - """Emit function validates event type.""" - # Should have regex validation - assert "Invalid" in PYWRY_BRIDGE_JS - - def test_emit_sends_event_type(self): - """Emit function sends event_type field.""" - assert "event_type" in PYWRY_BRIDGE_JS - - def test_emit_sends_label(self): - """Emit function sends label field.""" - # Uses label for emit - assert "label:" in PYWRY_BRIDGE_JS or "label :" in PYWRY_BRIDGE_JS - - -class TestBridgeJsEventHandlers: - """Tests for event handler functions in bridge JS.""" - - def test_on_creates_handlers_array(self): - """On function creates handlers array.""" - assert "_handlers" in PYWRY_BRIDGE_JS - - def test_trigger_calls_handlers(self): - """Trigger function calls handlers.""" - assert "_trigger" in PYWRY_BRIDGE_JS or "trigger" in PYWRY_BRIDGE_JS - - def test_wildcard_handlers_supported(self): - """Wildcard handlers are supported.""" - assert "'*'" in PYWRY_BRIDGE_JS or '"*"' in PYWRY_BRIDGE_JS - - -class TestBridgeJsTauriIntegration: - """Tests for Tauri integration in bridge JS.""" - - def test_checks_for_tauri(self): - """Checks for __TAURI__ object.""" - assert "__TAURI__" in PYWRY_BRIDGE_JS - - def test_uses_pytauri_invoke(self): - """Uses pytauri.pyInvoke for IPC.""" - assert "pytauri" in PYWRY_BRIDGE_JS - assert "pyInvoke" in PYWRY_BRIDGE_JS - - def test_invokes_pywry_result(self): - """Invokes pywry_result command.""" - assert "pywry_result" in PYWRY_BRIDGE_JS - - def test_invokes_pywry_event(self): - """Invokes pywry_event command.""" - assert "pywry_event" in PYWRY_BRIDGE_JS - - -class TestBridgeJsHelperFunctions: - """Tests for helper functions in bridge JS.""" - - def test_open_file_function(self): - """openFile function exists.""" - assert "openFile" in PYWRY_BRIDGE_JS - - def test_devtools_function(self): - """devtools function exists.""" - assert "devtools" in PYWRY_BRIDGE_JS + def test_hot_reload_excluded_when_disabled(self): + script = build_init_script(window_label="main", enable_hot_reload=False) + assert "saveScrollPosition" not in script diff --git a/pywry/tests/test_state_sqlite.py b/pywry/tests/test_state_sqlite.py new file mode 100644 index 0000000..24704e1 --- /dev/null +++ b/pywry/tests/test_state_sqlite.py @@ -0,0 +1,373 @@ +"""Tests for the SQLite state backend. + +Covers ChatStore CRUD, audit trail, session management, RBAC, +encryption, auto-setup, and interchangeability with MemoryChatStore. +""" + +from __future__ import annotations + +import pytest + +from pywry.chat.models import ChatMessage, ChatThread +from pywry.state.sqlite import ( + SqliteChatStore, + SqliteConnectionRouter, + SqliteEventBus, + SqliteSessionStore, + SqliteWidgetStore, +) + + +@pytest.fixture +def db_path(tmp_path): + return str(tmp_path / "test.db") + + +@pytest.fixture +def chat_store(db_path): + return SqliteChatStore(db_path=db_path, encrypted=False) + + +@pytest.fixture +def session_store(db_path): + return SqliteSessionStore(db_path=db_path, encrypted=False) + + +@pytest.fixture +def widget_store(db_path): + return SqliteWidgetStore(db_path=db_path, encrypted=False) + + +class TestSqliteChatStoreCRUD: + @pytest.mark.asyncio + async def test_save_and_get_thread(self, chat_store): + thread = ChatThread(thread_id="t1", title="Test Thread") + await chat_store.save_thread("w1", thread) + result = await chat_store.get_thread("w1", "t1") + assert result is not None + assert result.thread_id == "t1" + assert result.title == "Test Thread" + + @pytest.mark.asyncio + async def test_list_threads(self, chat_store): + await chat_store.save_thread("w1", ChatThread(thread_id="t1", title="A")) + await chat_store.save_thread("w1", ChatThread(thread_id="t2", title="B")) + threads = await chat_store.list_threads("w1") + assert len(threads) == 2 + + @pytest.mark.asyncio + async def test_delete_thread(self, chat_store): + await chat_store.save_thread("w1", ChatThread(thread_id="t1", title="A")) + deleted = await chat_store.delete_thread("w1", "t1") + assert deleted is True + result = await chat_store.get_thread("w1", "t1") + assert result is None + + @pytest.mark.asyncio + async def test_append_and_get_messages(self, chat_store): + await chat_store.save_thread("w1", ChatThread(thread_id="t1", title="A")) + msg = ChatMessage(role="user", content="hello", message_id="m1") + await chat_store.append_message("w1", "t1", msg) + messages = await chat_store.get_messages("w1", "t1") + assert len(messages) == 1 + assert messages[0].text_content() == "hello" + + @pytest.mark.asyncio + async def test_clear_messages(self, chat_store): + await chat_store.save_thread("w1", ChatThread(thread_id="t1", title="A")) + await chat_store.append_message("w1", "t1", ChatMessage(role="user", content="x")) + await chat_store.clear_messages("w1", "t1") + messages = await chat_store.get_messages("w1", "t1") + assert len(messages) == 0 + + @pytest.mark.asyncio + async def test_get_nonexistent_thread(self, chat_store): + result = await chat_store.get_thread("w1", "nonexistent") + assert result is None + + @pytest.mark.asyncio + async def test_widget_isolation(self, chat_store): + await chat_store.save_thread("w1", ChatThread(thread_id="t1", title="W1")) + await chat_store.save_thread("w2", ChatThread(thread_id="t2", title="W2")) + w1_threads = await chat_store.list_threads("w1") + w2_threads = await chat_store.list_threads("w2") + assert len(w1_threads) == 1 + assert len(w2_threads) == 1 + assert w1_threads[0].title == "W1" + + @pytest.mark.asyncio + async def test_message_pagination(self, chat_store): + await chat_store.save_thread("w1", ChatThread(thread_id="t1", title="A")) + for i in range(10): + await chat_store.append_message( + "w1", + "t1", + ChatMessage(role="user", content=f"msg{i}", message_id=f"m{i}"), + ) + messages = await chat_store.get_messages("w1", "t1", limit=3) + assert len(messages) == 3 + + @pytest.mark.asyncio + async def test_persistence_across_instances(self, db_path): + store1 = SqliteChatStore(db_path=db_path, encrypted=False) + await store1.save_thread("w1", ChatThread(thread_id="t1", title="Persistent")) + await store1.append_message("w1", "t1", ChatMessage(role="user", content="saved")) + + store2 = SqliteChatStore(db_path=db_path, encrypted=False) + thread = await store2.get_thread("w1", "t1") + assert thread is not None + assert thread.title == "Persistent" + messages = await store2.get_messages("w1", "t1") + assert len(messages) == 1 + assert messages[0].text_content() == "saved" + + +class TestSqliteAuditTrail: + @pytest.mark.asyncio + async def test_log_and_get_tool_calls(self, chat_store): + await chat_store.save_thread("w1", ChatThread(thread_id="t1", title="A")) + await chat_store.append_message( + "w1", "t1", ChatMessage(role="assistant", content="ok", message_id="m1") + ) + await chat_store.log_tool_call( + message_id="m1", + tool_call_id="tc1", + name="read_file", + kind="read", + status="completed", + arguments={"path": "/tmp/test.txt"}, + result="file contents here", + ) + calls = await chat_store.get_tool_calls("m1") + assert len(calls) == 1 + assert calls[0]["name"] == "read_file" + assert calls[0]["status"] == "completed" + + @pytest.mark.asyncio + async def test_log_and_get_artifacts(self, chat_store): + await chat_store.save_thread("w1", ChatThread(thread_id="t1", title="A")) + await chat_store.append_message( + "w1", "t1", ChatMessage(role="assistant", content="ok", message_id="m1") + ) + await chat_store.log_artifact( + message_id="m1", + artifact_type="code", + title="main.py", + content="x = 42", + ) + artifacts = await chat_store.get_artifacts("m1") + assert len(artifacts) == 1 + assert artifacts[0]["artifact_type"] == "code" + assert artifacts[0]["title"] == "main.py" + + @pytest.mark.asyncio + async def test_log_token_usage_and_stats(self, chat_store): + await chat_store.save_thread("w1", ChatThread(thread_id="t1", title="A")) + await chat_store.append_message( + "w1", "t1", ChatMessage(role="assistant", content="ok", message_id="m1") + ) + await chat_store.log_token_usage( + message_id="m1", + model="gpt-4", + prompt_tokens=100, + completion_tokens=50, + total_tokens=150, + cost_usd=0.005, + ) + stats = await chat_store.get_usage_stats(thread_id="t1") + assert stats["prompt_tokens"] == 100 + assert stats["completion_tokens"] == 50 + assert stats["total_tokens"] == 150 + assert stats["cost_usd"] == 0.005 + + @pytest.mark.asyncio + async def test_total_cost(self, chat_store): + await chat_store.save_thread("w1", ChatThread(thread_id="t1", title="A")) + await chat_store.append_message( + "w1", "t1", ChatMessage(role="assistant", content="a", message_id="m1") + ) + await chat_store.append_message( + "w1", "t1", ChatMessage(role="assistant", content="b", message_id="m2") + ) + await chat_store.log_token_usage(message_id="m1", cost_usd=0.01) + await chat_store.log_token_usage(message_id="m2", cost_usd=0.02) + cost = await chat_store.get_total_cost(thread_id="t1") + assert abs(cost - 0.03) < 0.001 + + @pytest.mark.asyncio + async def test_search_messages(self, chat_store): + await chat_store.save_thread("w1", ChatThread(thread_id="t1", title="A")) + await chat_store.append_message( + "w1", "t1", ChatMessage(role="user", content="find the fibonacci function") + ) + await chat_store.append_message( + "w1", "t1", ChatMessage(role="assistant", content="here is the code") + ) + results = await chat_store.search_messages("fibonacci") + assert len(results) == 1 + assert "fibonacci" in results[0]["content"] + + @pytest.mark.asyncio + async def test_log_resource(self, chat_store): + await chat_store.save_thread("w1", ChatThread(thread_id="t1", title="A")) + await chat_store.log_resource( + thread_id="t1", + uri="file:///data/report.csv", + name="report.csv", + mime_type="text/csv", + size=1024, + ) + + @pytest.mark.asyncio + async def test_log_skill(self, chat_store): + await chat_store.save_thread("w1", ChatThread(thread_id="t1", title="A")) + await chat_store.log_skill( + thread_id="t1", + name="langgraph-docs", + metadata={"version": "1.0"}, + ) + + +class TestSqliteSessionStore: + @pytest.mark.asyncio + async def test_auto_admin_session(self, session_store): + session = await session_store.get_session("local") + assert session is not None + assert session.user_id == "admin" + assert "admin" in session.roles + + @pytest.mark.asyncio + async def test_create_and_get_session(self, session_store): + session = await session_store.create_session( + session_id="s1", user_id="alice", roles=["editor"] + ) + assert session.user_id == "alice" + retrieved = await session_store.get_session("s1") + assert retrieved is not None + assert "editor" in retrieved.roles + + @pytest.mark.asyncio + async def test_session_expiry(self, session_store): + await session_store.create_session(session_id="s_exp", user_id="bob", ttl=1) + session = await session_store.get_session("s_exp") + assert session is not None + import asyncio + + await asyncio.sleep(1.1) + expired = await session_store.get_session("s_exp") + assert expired is None + + @pytest.mark.asyncio + async def test_check_permission_admin(self, session_store): + allowed = await session_store.check_permission("local", "widget", "w1", "admin") + assert allowed is True + + @pytest.mark.asyncio + async def test_check_permission_viewer(self, session_store): + await session_store.create_session( + session_id="viewer_s", user_id="viewer_user", roles=["viewer"] + ) + can_read = await session_store.check_permission("viewer_s", "widget", "w1", "read") + assert can_read is True + can_write = await session_store.check_permission("viewer_s", "widget", "w1", "write") + assert can_write is False + + @pytest.mark.asyncio + async def test_delete_session(self, session_store): + await session_store.create_session(session_id="del_s", user_id="u1") + deleted = await session_store.delete_session("del_s") + assert deleted is True + assert await session_store.get_session("del_s") is None + + @pytest.mark.asyncio + async def test_list_user_sessions(self, session_store): + await session_store.create_session(session_id="s1", user_id="alice") + await session_store.create_session(session_id="s2", user_id="alice") + sessions = await session_store.list_user_sessions("alice") + assert len(sessions) == 2 + + +class TestSqliteWidgetStore: + @pytest.mark.asyncio + async def test_register_and_get(self, widget_store): + await widget_store.register("w1", "

hi

", token="tok1") + widget = await widget_store.get("w1") + assert widget is not None + assert widget.html == "

hi

" + assert widget.token == "tok1" + + @pytest.mark.asyncio + async def test_list_active(self, widget_store): + await widget_store.register("w1", "

a

") + await widget_store.register("w2", "

b

") + widgets = await widget_store.list_active() + assert "w1" in widgets + assert "w2" in widgets + + @pytest.mark.asyncio + async def test_delete(self, widget_store): + await widget_store.register("w1", "

a

") + deleted = await widget_store.delete("w1") + assert deleted is True + assert await widget_store.get("w1") is None + + @pytest.mark.asyncio + async def test_exists_and_count(self, widget_store): + assert await widget_store.exists("w1") is False + assert await widget_store.count() == 0 + await widget_store.register("w1", "

a

") + assert await widget_store.exists("w1") is True + assert await widget_store.count() == 1 + + @pytest.mark.asyncio + async def test_update_html(self, widget_store): + await widget_store.register("w1", "

old

") + updated = await widget_store.update_html("w1", "

new

") + assert updated is True + assert (await widget_store.get_html("w1")) == "

new

" + + @pytest.mark.asyncio + async def test_update_token(self, widget_store): + await widget_store.register("w1", "

a

", token="old") + updated = await widget_store.update_token("w1", "new") + assert updated is True + assert (await widget_store.get_token("w1")) == "new" + + +class TestSqliteEventBusAndRouter: + def test_event_bus_is_memory(self): + from pywry.state.memory import MemoryEventBus + + assert SqliteEventBus is MemoryEventBus + + def test_connection_router_is_memory(self): + from pywry.state.memory import MemoryConnectionRouter + + assert SqliteConnectionRouter is MemoryConnectionRouter + + +class TestSqliteFactoryIntegration: + def test_state_backend_sqlite(self, monkeypatch): + monkeypatch.setenv("PYWRY_DEPLOY__STATE_BACKEND", "sqlite") + from pywry.state._factory import get_state_backend + from pywry.state.types import StateBackend + + backend = get_state_backend() + assert backend == StateBackend.SQLITE + + +class TestAuditTrailDefaultNoOps: + """Verify Memory and Redis stores have no-op audit trail methods.""" + + @pytest.mark.asyncio + async def test_memory_store_no_op_methods(self): + from pywry.state.memory import MemoryChatStore + + store = MemoryChatStore() + await store.log_tool_call("m1", "tc1", "search") + await store.log_artifact("m1", "code", "test.py") + await store.log_token_usage("m1", prompt_tokens=100) + calls = await store.get_tool_calls("m1") + assert calls == [] + stats = await store.get_usage_stats() + assert stats["total_tokens"] == 0 diff --git a/pywry/tests/test_system_events.py b/pywry/tests/test_system_events.py index 5deb815..7e53088 100644 --- a/pywry/tests/test_system_events.py +++ b/pywry/tests/test_system_events.py @@ -41,23 +41,23 @@ def _get_widget_esm() -> str: def _get_hot_reload_js() -> str: """Get the JavaScript for native Tauri window mode (hot reload).""" - from pywry.scripts import HOT_RELOAD_JS + from pywry.scripts import _get_hot_reload_js as _load - return HOT_RELOAD_JS + return _load() def _get_system_events_js() -> str: """Get the JavaScript for system event handlers (pywry:inject-css, etc.).""" - from pywry.scripts import PYWRY_SYSTEM_EVENTS_JS + from pywry.scripts import _get_system_events_js as _load - return PYWRY_SYSTEM_EVENTS_JS + return _load() def _get_theme_manager_js() -> str: """Get the theme manager JavaScript.""" - from pywry.scripts import THEME_MANAGER_JS + from pywry.scripts import _get_theme_manager_js as _load - return THEME_MANAGER_JS + return _load() # ============================================================================= @@ -302,21 +302,24 @@ class TestPywryBridgeSystemSupport: def test_bridge_defines_on_method(self) -> None: """Verify bridge JS defines the on() method for event registration.""" - from pywry.scripts import PYWRY_BRIDGE_JS + from pywry.scripts import _get_bridge_js - assert ".on" in PYWRY_BRIDGE_JS + _js = _get_bridge_js() + assert ".on" in _js def test_bridge_defines_off_method(self) -> None: """Verify bridge JS defines the off() method for event unregistration.""" - from pywry.scripts import PYWRY_BRIDGE_JS + from pywry.scripts import _get_bridge_js - assert ".off" in PYWRY_BRIDGE_JS + _js = _get_bridge_js() + assert ".off" in _js def test_bridge_defines_handlers_storage(self) -> None: """Verify bridge JS defines handlers storage.""" - from pywry.scripts import PYWRY_BRIDGE_JS + from pywry.scripts import _get_bridge_js - assert "_handlers" in PYWRY_BRIDGE_JS + _js = _get_bridge_js() + assert "_handlers" in _js def test_theme_manager_update_theme_handler(self) -> None: """Verify theme manager registers pywry:update-theme handler.""" diff --git a/pywry/tests/test_toolbar.py b/pywry/tests/test_toolbar.py index f77a63b..63b68f9 100644 --- a/pywry/tests/test_toolbar.py +++ b/pywry/tests/test_toolbar.py @@ -3814,10 +3814,10 @@ def test_secretstr_not_exposed_in_model_repr(self) -> None: assert "SecretStr" in model_repr def test_secret_never_rendered_in_html(self) -> None: - """Test secret value is NEVER rendered in HTML for security.""" + """Test secret value is not rendered in HTML.""" si = SecretInput(event="settings:api-key", value="super-secret-api-key") html = si.build_html() - # The actual secret must NEVER appear in HTML + # The actual secret does not appear in HTML assert "super-secret-api-key" not in html # HTML should have mask value when secret exists (not the actual secret) assert ( @@ -4814,9 +4814,9 @@ class TestSecretInputStateProtection: def test_state_getter_js_protects_secret(self) -> None: """getToolbarState JS should return has_value, not the actual value.""" - from pywry.scripts import TOOLBAR_BRIDGE_JS + from pywry.scripts import _get_toolbar_bridge_js - js = TOOLBAR_BRIDGE_JS + js = _get_toolbar_bridge_js() # Should check for pywry-input-secret class assert "pywry-input-secret" in js @@ -4827,18 +4827,18 @@ def test_state_getter_js_protects_secret(self) -> None: def test_component_value_getter_js_protects_secret(self) -> None: """getComponentValue JS should return has_value for secrets.""" - from pywry.scripts import TOOLBAR_BRIDGE_JS + from pywry.scripts import _get_toolbar_bridge_js - js = TOOLBAR_BRIDGE_JS + js = _get_toolbar_bridge_js() # getComponentValue should also check for secret inputs - assert "// SECURITY: Never expose secret values via state getter" in js + assert "// Never expose secret values via state getter" in js def test_set_value_js_blocks_secret(self) -> None: """setComponentValue JS should block setting secret values.""" - from pywry.scripts import TOOLBAR_BRIDGE_JS + from pywry.scripts import _get_toolbar_bridge_js - js = TOOLBAR_BRIDGE_JS + js = _get_toolbar_bridge_js() # Should check for secret input and warn assert "Cannot set SecretInput value via toolbar:set-value" in js diff --git a/pywry/tests/test_tvchart.py b/pywry/tests/test_tvchart.py index 8e221bf..8677771 100644 --- a/pywry/tests/test_tvchart.py +++ b/pywry/tests/test_tvchart.py @@ -630,7 +630,7 @@ def test_symbol_info_price_sources(self): assert len(dumped["price_sources"]) == 2 assert dumped["price_source_id"] == "1" - def test_symbol_info_legacy_alias(self): + def test_symbol_info_alias(self): info = TVChartSymbolInfo( name="X", description="", exchange="", listed_exchange="", symbol_type="futures" ) @@ -1563,11 +1563,11 @@ def test_settings_row_helpers_exist(self, tvchart_defaults_js: str): def test_scales_settings_uses_full_value_label(self, tvchart_defaults_js: str): """The scales tab must use the full 'Value according to scale' label. A truncated 'Value according to sc...' label broke the settings key - mapping. Backward compat fallback must also exist.""" + mapping. Fallback for the truncated key must also exist.""" assert "'Value according to scale'" in tvchart_defaults_js - # The old truncated key must NOT be used in addSelectRow calls + # The truncated key must NOT be used in addSelectRow calls assert "addSelectRow(scalesSection, 'Value according to sc...'" not in tvchart_defaults_js - # Backward-compatible fallback for layouts saved with the old key + # Fallback for layouts saved with the truncated key assert "'Value according to sc...'" in tvchart_defaults_js # ------------------------------------------------------------------