diff --git a/.github/workflows/publish-pywry.yml b/.github/workflows/publish-pywry.yml index 5a59da8..caceb6b 100644 --- a/.github/workflows/publish-pywry.yml +++ b/.github/workflows/publish-pywry.yml @@ -37,15 +37,19 @@ jobs: contents: read strategy: fail-fast: true + # ``deepagents`` in ``.[dev]`` requires Python >=3.11 (3.10 is + # blocked upstream); ``sqlcipher3`` is built from source + # against the SQLCipher C library installed in the ``Build + # SQLCipher`` step, so 3.14 works too. matrix: include: - os: ubuntu-24.04 - python-version: '3.10' + python-version: '3.11' - os: ubuntu-24.04 python-version: '3.14' - os: windows-2025 python-version: '3.12' - - os: macos-15 + - os: macos-15-intel python-version: '3.12' steps: @@ -67,7 +71,61 @@ jobs: if: runner.os == 'Linux' run: | for i in 1 2 3; do sudo apt-get update && break || sleep 15; done - for i in 1 2 3; do sudo apt-get install -y --fix-missing xvfb dbus-x11 at-spi2-core libxkbcommon-x11-0 libxcb-icccm4 libxcb-image0 libxcb-keysyms1 libxcb-randr0 libxcb-render-util0 libxcb-xinerama0 libxcb-xfixes0 libxcb-shape0 libgl1 libegl1 libwebkit2gtk-4.1-dev libgtk-3-dev && break || { sleep 30; sudo apt-get update; }; done + for i in 1 2 3; do sudo apt-get install -y --fix-missing xvfb dbus-x11 at-spi2-core libxkbcommon-x11-0 libxcb-icccm4 libxcb-image0 libxcb-keysyms1 libxcb-randr0 libxcb-render-util0 libxcb-xinerama0 libxcb-xfixes0 libxcb-shape0 libgl1 libegl1 libwebkit2gtk-4.1-dev libgtk-3-dev build-essential libssl-dev && break || { sleep 30; sudo apt-get update; }; done + + - name: Build SQLCipher from source (Linux) + if: runner.os == 'Linux' + shell: bash + run: | + # ``--disable-tcl`` skips the Tcl extension; the Python + # ``sqlcipher3`` binding doesn't need it, and modern Tcl + # bottles break its compile on macOS. + git clone --depth=1 --branch=v4.6.1 https://github.com/sqlcipher/sqlcipher.git /tmp/sqlcipher + cd /tmp/sqlcipher + ./configure \ + --disable-tcl \ + --enable-tempstore=yes \ + CFLAGS="-DSQLITE_HAS_CODEC -DSQLCIPHER_CRYPTO_OPENSSL" \ + LDFLAGS="-lcrypto" + make -j$(nproc) libsqlcipher.la || make -j$(nproc) + sudo make install-libLTLIBRARIES install-includeHEADERS || sudo make install + sudo ldconfig + + - name: Build SQLCipher from source (macOS) + if: runner.os == 'macOS' + shell: bash + run: | + # No ``tcl-tk`` install — Homebrew ships Tcl 9.0, whose + # headers break SQLCipher's Tcl-8-era ``tclsqlite.c``. + # ``--disable-tcl`` is the fix. + brew install openssl@3 + OPENSSL_PREFIX="$(brew --prefix openssl@3)" + git clone --depth=1 --branch=v4.6.1 https://github.com/sqlcipher/sqlcipher.git /tmp/sqlcipher + cd /tmp/sqlcipher + ./configure \ + --disable-tcl \ + --enable-tempstore=yes \ + CFLAGS="-DSQLITE_HAS_CODEC -DSQLCIPHER_CRYPTO_OPENSSL -I$OPENSSL_PREFIX/include" \ + LDFLAGS="-L$OPENSSL_PREFIX/lib -lcrypto" + make -j$(sysctl -n hw.logicalcpu) libsqlcipher.la || make -j$(sysctl -n hw.logicalcpu) + sudo make install-libLTLIBRARIES install-includeHEADERS || sudo make install + echo "LDFLAGS=-L/usr/local/lib -L$OPENSSL_PREFIX/lib -lsqlcipher -lcrypto" >> $GITHUB_ENV + echo "CPPFLAGS=-I/usr/local/include -I/usr/local/include/sqlcipher -I$OPENSSL_PREFIX/include" >> $GITHUB_ENV + + - name: Install SQLCipher via vcpkg (Windows x64) + if: runner.os == 'Windows' && runner.arch != 'ARM64' + shell: pwsh + run: | + # arm64-windows skipped: tcl (transitive port dep) doesn't + # build there under vcpkg; ``SqliteStateBackend`` handles + # missing ``sqlcipher3`` by falling back to plain SQLite. + $env:VCPKG_ROOT = "$env:VCPKG_INSTALLATION_ROOT" + & "$env:VCPKG_ROOT\vcpkg" install "sqlcipher:x64-windows" + $sqlcipherDir = "$env:VCPKG_ROOT\installed\x64-windows" + echo "SQLCIPHER_PATH=$sqlcipherDir" >> $env:GITHUB_ENV + echo "INCLUDE=$sqlcipherDir\include;$sqlcipherDir\include\sqlcipher;$env:INCLUDE" >> $env:GITHUB_ENV + echo "LIB=$sqlcipherDir\lib;$env:LIB" >> $env:GITHUB_ENV + echo "PATH=$sqlcipherDir\bin;$env:PATH" >> $env:GITHUB_ENV - name: Install dependencies run: | @@ -548,7 +606,55 @@ jobs: if: runner.os == 'Linux' run: | for i in 1 2 3; do sudo apt-get update && break || sleep 15; done - for i in 1 2 3; do sudo apt-get install -y --fix-missing xvfb dbus-x11 at-spi2-core libxkbcommon-x11-0 libxcb-icccm4 libxcb-image0 libxcb-keysyms1 libxcb-randr0 libxcb-render-util0 libxcb-xinerama0 libxcb-xfixes0 libxcb-shape0 libgl1 libegl1 libwebkit2gtk-4.1-dev libgtk-3-dev && break || { sleep 30; sudo apt-get update; }; done + for i in 1 2 3; do sudo apt-get install -y --fix-missing xvfb dbus-x11 at-spi2-core libxkbcommon-x11-0 libxcb-icccm4 libxcb-image0 libxcb-keysyms1 libxcb-randr0 libxcb-render-util0 libxcb-xinerama0 libxcb-xfixes0 libxcb-shape0 libgl1 libegl1 libwebkit2gtk-4.1-dev libgtk-3-dev build-essential libssl-dev && break || { sleep 30; sudo apt-get update; }; done + + - name: Build SQLCipher from source (Linux) + if: runner.os == 'Linux' + shell: bash + run: | + git clone --depth=1 --branch=v4.6.1 https://github.com/sqlcipher/sqlcipher.git /tmp/sqlcipher + cd /tmp/sqlcipher + ./configure \ + --disable-tcl \ + --enable-tempstore=yes \ + CFLAGS="-DSQLITE_HAS_CODEC -DSQLCIPHER_CRYPTO_OPENSSL" \ + LDFLAGS="-lcrypto" + make -j$(nproc) libsqlcipher.la || make -j$(nproc) + sudo make install-libLTLIBRARIES install-includeHEADERS || sudo make install + sudo ldconfig + + - name: Build SQLCipher from source (macOS) + if: runner.os == 'macOS' + shell: bash + run: | + brew install openssl@3 + OPENSSL_PREFIX="$(brew --prefix openssl@3)" + git clone --depth=1 --branch=v4.6.1 https://github.com/sqlcipher/sqlcipher.git /tmp/sqlcipher + cd /tmp/sqlcipher + ./configure \ + --disable-tcl \ + --enable-tempstore=yes \ + CFLAGS="-DSQLITE_HAS_CODEC -DSQLCIPHER_CRYPTO_OPENSSL -I$OPENSSL_PREFIX/include" \ + LDFLAGS="-L$OPENSSL_PREFIX/lib -lcrypto" + make -j$(sysctl -n hw.logicalcpu) libsqlcipher.la || make -j$(sysctl -n hw.logicalcpu) + sudo make install-libLTLIBRARIES install-includeHEADERS || sudo make install + echo "LDFLAGS=-L/usr/local/lib -L$OPENSSL_PREFIX/lib -lsqlcipher -lcrypto" >> $GITHUB_ENV + echo "CPPFLAGS=-I/usr/local/include -I/usr/local/include/sqlcipher -I$OPENSSL_PREFIX/include" >> $GITHUB_ENV + + - name: Install SQLCipher via vcpkg (Windows x64) + if: runner.os == 'Windows' && runner.arch != 'ARM64' + shell: pwsh + run: | + # arm64-windows skipped: tcl (transitive port dep) doesn't + # build there under vcpkg; ``SqliteStateBackend`` handles + # missing ``sqlcipher3`` by falling back to plain SQLite. + $env:VCPKG_ROOT = "$env:VCPKG_INSTALLATION_ROOT" + & "$env:VCPKG_ROOT\vcpkg" install "sqlcipher:x64-windows" + $sqlcipherDir = "$env:VCPKG_ROOT\installed\x64-windows" + echo "SQLCIPHER_PATH=$sqlcipherDir" >> $env:GITHUB_ENV + echo "INCLUDE=$sqlcipherDir\include;$sqlcipherDir\include\sqlcipher;$env:INCLUDE" >> $env:GITHUB_ENV + echo "LIB=$sqlcipherDir\lib;$env:LIB" >> $env:GITHUB_ENV + echo "PATH=$sqlcipherDir\bin;$env:PATH" >> $env:GITHUB_ENV - name: Cache vcpkg packages (Windows ARM) if: runner.os == 'Windows' && runner.arch == 'ARM64' diff --git a/.github/workflows/test-pywry.yml b/.github/workflows/test-pywry.yml index e3405e1..cecb917 100644 --- a/.github/workflows/test-pywry.yml +++ b/.github/workflows/test-pywry.yml @@ -44,7 +44,8 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - pip install -e ".[dev]" + pip install -e ".[notebook,auth,mcp,acp,deepagent,openai,anthropic,magentic]" + pip install ruff mypy pylint pytauri-wheel plotly pandas cryptography - name: Run Ruff linter run: ruff check pywry/ tests/ @@ -65,19 +66,24 @@ jobs: contents: read strategy: fail-fast: false + # ``deepagents`` (pulled in by ``.[dev]`` → ``features``) hard- + # requires Python >=3.11 — 3.10 can't resolve the dev extra. + # ``sqlcipher3`` is a source build against the SQLCipher C + # library installed in the ``Build SQLCipher`` step below, so + # any Python ABI works (no binary-wheel coverage gaps). matrix: include: - os: ubuntu-24.04 - python-version: '3.10' + python-version: '3.11' - os: ubuntu-24.04 python-version: '3.14' - os: windows-2025 - python-version: '3.10' + python-version: '3.11' - os: windows-2025 python-version: '3.14' - - os: macos-15 - python-version: '3.10' - - os: macos-15 + - os: macos-15-intel + python-version: '3.11' + - os: macos-15-intel python-version: '3.14' - os: ubuntu-24.04-arm python-version: '3.11' @@ -88,7 +94,7 @@ jobs: - os: windows-11-arm python-version: '3.14' - os: macos-latest - python-version: '3.10' + python-version: '3.11' - os: macos-latest python-version: '3.14' @@ -117,7 +123,77 @@ jobs: if: runner.os == 'Linux' run: | for i in 1 2 3; do sudo apt-get update && break || sleep 15; done - for i in 1 2 3; do sudo apt-get install -y --fix-missing xvfb dbus-x11 at-spi2-core libxkbcommon-x11-0 libxcb-icccm4 libxcb-image0 libxcb-keysyms1 libxcb-randr0 libxcb-render-util0 libxcb-xinerama0 libxcb-xfixes0 libxcb-shape0 libgl1 libegl1 libwebkit2gtk-4.1-dev libgtk-3-dev && break || { sleep 30; sudo apt-get update; }; done + for i in 1 2 3; do sudo apt-get install -y --fix-missing xvfb dbus-x11 at-spi2-core libxkbcommon-x11-0 libxcb-icccm4 libxcb-image0 libxcb-keysyms1 libxcb-randr0 libxcb-render-util0 libxcb-xinerama0 libxcb-xfixes0 libxcb-shape0 libgl1 libegl1 libwebkit2gtk-4.1-dev libgtk-3-dev build-essential libssl-dev && break || { sleep 30; sudo apt-get update; }; done + + - name: Build SQLCipher from source (Linux) + if: runner.os == 'Linux' + shell: bash + run: | + # Build from the upstream source (https://github.com/sqlcipher/sqlcipher) + # so the ``sqlcipher3`` Python package's setup.py links + # against a known-good library on every Python ABI — + # binary wheels stop at Python 3.13. ``--disable-tcl`` + # skips the Tcl extension (not needed by the Python + # binding, and modern Tcl bottles break its compile). + git clone --depth=1 --branch=v4.6.1 https://github.com/sqlcipher/sqlcipher.git /tmp/sqlcipher + cd /tmp/sqlcipher + ./configure \ + --disable-tcl \ + --enable-tempstore=yes \ + CFLAGS="-DSQLITE_HAS_CODEC -DSQLCIPHER_CRYPTO_OPENSSL" \ + LDFLAGS="-lcrypto" + make -j$(nproc) libsqlcipher.la || make -j$(nproc) + sudo make install-libLTLIBRARIES install-includeHEADERS || sudo make install + sudo ldconfig + + - name: Build SQLCipher from source (macOS) + if: runner.os == 'macOS' + shell: bash + run: | + # Intentionally NOT installing ``tcl-tk`` from Homebrew: + # the current bottle is Tcl 9.0, whose headers drop the + # ``CONST`` macro and ``TCL_CHANNEL_VERSION_2``, so + # SQLCipher's ``tclsqlite.c`` (still Tcl-8 API) fails to + # compile. SQLCipher's configure auto-disables the Tcl + # extension when no ``tclConfig.sh`` is on the usual + # search paths — we only need the C library anyway; the + # ``sqlcipher3`` Python binding doesn't touch Tcl. + brew install openssl@3 + OPENSSL_PREFIX="$(brew --prefix openssl@3)" + git clone --depth=1 --branch=v4.6.1 https://github.com/sqlcipher/sqlcipher.git /tmp/sqlcipher + cd /tmp/sqlcipher + ./configure \ + --disable-tcl \ + --enable-tempstore=yes \ + CFLAGS="-DSQLITE_HAS_CODEC -DSQLCIPHER_CRYPTO_OPENSSL -I$OPENSSL_PREFIX/include" \ + LDFLAGS="-L$OPENSSL_PREFIX/lib -lcrypto" + # Build only the library target — the default ``make`` tries + # to build the ``sqlcipher`` shell which links against Tcl. + make -j$(sysctl -n hw.logicalcpu) libsqlcipher.la || make -j$(sysctl -n hw.logicalcpu) + sudo make install-libLTLIBRARIES install-includeHEADERS || sudo make install + echo "LDFLAGS=-L/usr/local/lib -L$OPENSSL_PREFIX/lib -lsqlcipher -lcrypto" >> $GITHUB_ENV + echo "CPPFLAGS=-I/usr/local/include -I/usr/local/include/sqlcipher -I$OPENSSL_PREFIX/include" >> $GITHUB_ENV + + - name: Install SQLCipher via vcpkg (Windows x64) + if: runner.os == 'Windows' && runner.arch != 'ARM64' + shell: pwsh + run: | + # vcpkg builds SQLCipher from source using the same upstream + # repo (https://github.com/sqlcipher/sqlcipher), just + # packaged as an MSVC-friendly port so we don't have to wire + # nmake + OpenSSL manually. ``SQLCIPHER_PATH`` points + # ``sqlcipher3``'s setup.py at the install root. Skipped on + # arm64-windows: the transitive ``tcl`` port doesn't build + # there even with ``--allow-unsupported``, and the + # ``SqliteStateBackend`` handles the missing binding by + # falling back to plain SQLite. + $env:VCPKG_ROOT = "$env:VCPKG_INSTALLATION_ROOT" + & "$env:VCPKG_ROOT\vcpkg" install "sqlcipher:x64-windows" + $sqlcipherDir = "$env:VCPKG_ROOT\installed\x64-windows" + echo "SQLCIPHER_PATH=$sqlcipherDir" >> $env:GITHUB_ENV + echo "INCLUDE=$sqlcipherDir\include;$sqlcipherDir\include\sqlcipher;$env:INCLUDE" >> $env:GITHUB_ENV + echo "LIB=$sqlcipherDir\lib;$env:LIB" >> $env:GITHUB_ENV + echo "PATH=$sqlcipherDir\bin;$env:PATH" >> $env:GITHUB_ENV - name: Cache vcpkg packages (Windows ARM) if: runner.os == 'Windows' && runner.arch == 'ARM64' @@ -170,16 +246,45 @@ jobs: if: runner.os == 'Linux' run: docker version - - name: Setup Docker (macOS Intel) - id: docker-macos - if: runner.os == 'macOS' && runner.arch == 'X64' - uses: docker/setup-docker-action@v4 - - - name: Verify Docker (macOS Intel) + - name: Configure Docker (macOS Intel) if: runner.os == 'macOS' && runner.arch == 'X64' + shell: bash run: | - echo "DOCKER_HOST=$DOCKER_HOST" - docker version + # ``docker/setup-docker-action@v4`` doesn't reliably bring up + # a Docker daemon on ``macos-15-intel`` runners — testcontainers + # then fails with ``FileNotFoundError: /var/run/docker.sock`` + # during the Redis fixture setup. Mirror the resilient + # Docker-Desktop → Colima fallback used in publish-pywry.yml. + set -euo pipefail + + if docker info >/dev/null 2>&1; then + echo "Docker daemon already available" + else + if [ -d "/Applications/Docker.app" ]; then + echo "Starting Docker Desktop" + open -a Docker + for _ in {1..30}; do + if docker info >/dev/null 2>&1; then + break + fi + sleep 5 + done + fi + fi + + if ! docker info >/dev/null 2>&1; then + echo "Falling back to Colima" + brew install docker colima + colima start --runtime docker --arch x86_64 --cpu 2 --memory 4 --disk 20 + fi + + if [ -S "$HOME/.colima/default/docker.sock" ]; then + sudo mkdir -p /var/run + sudo ln -sf "$HOME/.colima/default/docker.sock" /var/run/docker.sock + fi + + echo "DOCKER_HOST=unix:///var/run/docker.sock" >> "$GITHUB_ENV" + docker info - name: Run tests (Linux) if: runner.os == 'Linux' 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/README.md b/README.md index 0e40ff6..8cce3db 100644 --- a/README.md +++ b/README.md @@ -20,18 +20,28 @@ Python 3.10–3.14, virtual environment recommended. pip install pywry ``` +Core extras: + | Extra | When to use | |-------|-------------| | `pip install 'pywry[notebook]'` | Jupyter / anywidget integration | | `pip install 'pywry[auth]'` | OAuth2 and keyring-backed auth support | | `pip install 'pywry[freeze]'` | PyInstaller hook for standalone executables | | `pip install 'pywry[mcp]'` | Model Context Protocol server support | -| `pip install 'pywry[openai]'` | `OpenAIProvider` integration | -| `pip install 'pywry[anthropic]'` | `AnthropicProvider` integration | -| `pip install 'pywry[magentic]'` | `MagenticProvider` integration | +| `pip install 'pywry[sqlite]'` | Encrypted SQLite state backend (SQLCipher) | | `pip install 'pywry[all]'` | Everything above | -The chat UI itself is included in the base package. The provider extras only install optional third-party SDKs. +Chat provider extras: + +| Extra | When to use | +|-------|-------------| +| `pip install 'pywry[openai]'` | `OpenAIProvider` (OpenAI SDK) | +| `pip install 'pywry[anthropic]'` | `AnthropicProvider` (Anthropic SDK) | +| `pip install 'pywry[magentic]'` | `MagenticProvider` (any magentic-supported LLM) | +| `pip install 'pywry[acp]'` | `StdioProvider` (Agent Client Protocol subprocess) | +| `pip install 'pywry[deepagent]'` | `DeepagentProvider` (LangChain Deep Agents — includes MCP adapters and ACP) | + +The chat UI itself is included in the base package. Provider extras only install the matching third-party SDK. **Linux only** — install system webview dependencies first: @@ -101,12 +111,15 @@ app.block() ## Features -- **18 toolbar components** — `Button`, `Select`, `MultiSelect`, `TextInput`, `SecretInput`, `SliderInput`, `RangeInput`, `Toggle`, `Checkbox`, `RadioGroup`, `TabGroup`, `Marquee`, `Modal`, and more. All Pydantic models, 7 layout positions. -- **Two-way events** — `app.emit()` and `app.on()` bridge Python and JavaScript in both directions. Pre-wired Plotly and AgGrid events included. -- **Theming** — light/dark modes, 60+ CSS variables, hot reload during development. -- **Security** — token auth, CSP headers, `SecuritySettings.strict()` / `.permissive()` / `.localhost()` presets. `SecretInput` stores values server-side, never in HTML. -- **Standalone executables** — PyInstaller hook ships with `pywry[freeze]`. No `.spec` edits or `--hidden-import` flags required. -- **MCP server** — 25 tools, 8 skills, 20+ resources for AI agent integration. +- **Toolbar components** — `Button`, `Select`, `MultiSelect`, `TextInput`, `SecretInput`, `SliderInput`, `RangeInput`, `Toggle`, `Checkbox`, `RadioGroup`, `TabGroup`, `Marquee`, `Modal`, and more. All Pydantic models; position them around the content edges or inside the chart area. +- **Two-way events** — `app.emit()` and `app.on()` bridge Python and JavaScript in both directions. Pre-wired Plotly and AgGrid events included. +- **Chat** — streaming chat widget with threads, slash commands, artifacts, and pluggable providers: `OpenAIProvider`, `AnthropicProvider`, `MagenticProvider`, `CallbackProvider`, `StdioProvider` (ACP subprocess), and `DeepagentProvider` (LangChain Deep Agents). +- **TradingView charts** — extended Lightweight Charts integration with a full drawing surface (trendlines, fib tools, text annotations, price notes, brushes), pluggable datafeed API, UDF adapter for external quote servers, streaming bar updates, compare overlays, compare-derivative indicators (Spread / Ratio / Sum / Product / Correlation), savable layouts, and a themeable settings panel. +- **Theming** — light / dark / system modes, themeable via `--pywry-*` CSS variables, hot reload during development. +- **Security** — token auth, CSP headers, `SecuritySettings.strict()` / `.permissive()` / `.localhost()` presets. `SecretInput` stores values server-side, never in HTML. +- **State backends** — in-memory (default), Redis (multi-worker), or SQLite with SQLCipher encryption at rest. +- **Standalone executables** — PyInstaller hook ships with `pywry[freeze]`. No `.spec` edits or `--hidden-import` flags required. +- **MCP server** — drive widgets, charts, and dashboards from any Model Context Protocol client (Claude Desktop, Claude Code, Cursor, etc.). ## MCP Server diff --git a/pywry/AGENTS.md b/pywry/AGENTS.md index ce2fa76..3d439d3 100644 --- a/pywry/AGENTS.md +++ b/pywry/AGENTS.md @@ -8,12 +8,14 @@ | Aspect | Details | |--------|---------| -| **Language** | Python 3.10+ | -| **Type System** | Strict typing with Pydantic v2 models | +| **Language** | Python 3.10–3.14 (base); dev tooling requires 3.11+ | +| **Type System** | Strict mypy, Pydantic v2 models | | **Style** | Ruff (line length 100), NumPy docstrings | | **Testing** | pytest with fixtures, `PYWRY_HEADLESS=1` for CI | -| **Architecture** | Subprocess IPC (desktop) + FastAPI inline server (notebooks) | -| **Scaling** | Deploy mode with Redis-backed state for multi-worker deployments | +| **Architecture** | Subprocess IPC (native) + FastAPI inline server (notebook / browser) | +| **State** | Memory (default), Redis, or SQLite + SQLCipher for multi-worker / at-rest encryption | +| **Chat** | Streaming chat widget with OpenAI / Anthropic / Magentic / Callback / Stdio-ACP / Deepagent providers | +| **MCP** | FastMCP server (`pywry mcp`) exposing widget, chart, grid, chat, tvchart, and auth tooling | --- @@ -27,45 +29,64 @@ Built on [PyTauri](https://pypi.org/project/pytauri/) (which uses Rust's [Tauri] ### Core Capabilities -- **Five Window Modes**: `NEW_WINDOW`, `SINGLE_WINDOW`, `MULTI_WINDOW`, `NOTEBOOK`, `BROWSER` -- **Notebook Support**: Automatic inline rendering via anywidget or IFrame in Jupyter/Colab -- **Toolbar System**: 18 declarative Pydantic components with 7 layout positions — automatic nested flexbox structure -- **Two-Way Events**: Python ↔ JavaScript communication with pre-wired Plotly/AgGrid events and utility events for DOM manipulation -- **Theming & CSS**: Light/dark modes, 60+ CSS variables, component ID targeting, and dynamic styling via events (`pywry:set-style`, `pywry:inject-css`) -- **Security**: Scoped token auth enabled by default, CSP headers, internal API protection, production presets available -- **AgGrid Tables**: Best-in-class Pandas → AgGrid conversion with pre-wired events, context menus, and practical defaults -- **Plotly Charts**: Plotly rendering with pre-wired plot events for Dash-like interactivity -- **TradingView Charts**: TradingView Lightweight Charts integration with OHLCV normalization, multi-series, indicators, and toolbar-driven controls -- **Toast Notifications**: Built-in alert system with positioning (info, success, warning, error, confirm) -- **Marquee Ticker**: Scrolling text/content with dynamic updates -- **Secrets Handling**: Secure password inputs — values stored server-side, never rendered in HTML -- **Hot Reload**: CSS injection and JS refresh with scroll preservation -- **Bundled Libraries**: Plotly.js 3.3.1 and AgGrid 35.0.0 (offline capable) -- **Native File Dialogs**: Tauri-powered save/open dialogs and filesystem access -- **Configuration System**: TOML files, pyproject.toml, and environment variables -- **CLI Tools**: Configuration management and project initialization -- **Deploy Mode**: Horizontal scaling with Redis-backed state for multi-worker deployments -- **Authentication & RBAC**: JWT-based authentication with role-based access control +- **Window Modes**: `NEW_WINDOW`, `SINGLE_WINDOW`, `MULTI_WINDOW`, `NOTEBOOK`, `BROWSER`. +- **Notebook Support**: Automatic inline rendering via anywidget or IFrame in Jupyter / VS Code / Colab. +- **Toolbar System**: Declarative Pydantic components (`Button`, `Select`, `MultiSelect`, `TextInput`, `SecretInput`, `SliderInput`, `RangeInput`, `Toggle`, `Checkbox`, `RadioGroup`, `TabGroup`, `Marquee`, `Modal`, …) with top / bottom / left / right / header / footer / inside positions and automatic nested-flexbox layout. +- **Two-Way Events**: Python ↔ JavaScript communication with pre-wired Plotly / AgGrid / TradingView / chat events plus utility events for DOM manipulation. +- **Theming & CSS**: Light / dark / system modes, `--pywry-*` CSS variables, component-ID targeting, hot reload, and dynamic styling via events (`pywry:set-style`, `pywry:inject-css`). +- **Security**: Scoped token auth enabled by default, CSP headers, internal API protection, `SecuritySettings.strict() / .permissive() / .localhost()` presets. +- **AgGrid Tables**: Pandas → AgGrid conversion with pre-wired events, context menus, server-side mode, and persisted column/filter/sort state. +- **Plotly Charts**: Plotly rendering with pre-wired plot events, layout / trace / figure updates, and state round-trips. +- **TradingView Charts**: Extended Lightweight Charts integration — drawing surface (trendlines, fib tools, text, price notes, brushes), pluggable datafeed API, UDF adapter for external quote servers, streaming bar updates, compare overlays, compare-derivative indicators (Spread / Ratio / Sum / Product / Correlation), savable layouts, and a themeable settings panel. +- **Chat Widget**: Streaming chat UI with threads, artifacts, slash commands, plan / todo updates, permission prompts, context sources, and pluggable providers (`OpenAIProvider`, `AnthropicProvider`, `MagenticProvider`, `CallbackProvider`, `StdioProvider` for ACP subprocesses, `DeepagentProvider` for LangChain Deep Agents). +- **MCP Server**: `pywry mcp --transport stdio | http` exposes widget management, components, chart / grid / tvchart control, events, chat-agent driving, auth, and docs skills to any MCP client. +- **Toast Notifications**: Built-in alert system (info, success, warning, error, confirm) with positioning and blocking overlay. +- **Marquee Ticker**: Scrolling text/content with dynamic per-item updates. +- **Secrets Handling**: `SecretInput` stores values server-side; HTML only carries opaque component IDs. +- **Hot Reload**: CSS injection and JS refresh with scroll preservation. +- **Bundled Libraries**: Plotly.js, AgGrid, and TradingView Lightweight Charts are bundled (offline capable) and served from `pywry/frontend/assets/`. +- **Native File Dialogs, Menus, Tray**: Tauri-powered save/open dialogs and filesystem access; `MenuProxy`, `TrayProxy`, and `WindowProxy` wrap the Tauri runtime APIs. +- **Configuration System**: TOML files, `pyproject.toml` `[tool.pywry]`, environment variables (`PYWRY_*`). +- **CLI Tools**: `pywry config`, `pywry init`, `pywry mcp`, `pywry mcp install`. +- **Deploy Mode**: Horizontal scaling with Redis-backed state, or SQLite + SQLCipher for single-node at-rest encryption. +- **Authentication**: OAuth2 (Google / GitHub / Microsoft / OIDC / custom) with PKCE, keyring-backed token storage, automatic refresh, and optional RBAC for deploy-mode routes. +- **Standalone Executables**: `pywry[freeze]` ships a PyInstaller hook — no `.spec` edits or `--hidden-import` flags required. ### Dependencies +Base package (always installed): + ``` -Python 3.10+ +Python 3.10–3.14 pytauri >= 0.8.0 -pytauri-wheel >= 0.8.0 +anyio >= 4.0.0 +httpx >= 0.27.0 pydantic >= 2.0.0 pydantic-settings >= 2.0.0 -anyio >= 4.0.0 +redis >= 7.1.0 # client library; only used if state_backend != "memory" fastapi >= 0.128.0 uvicorn >= 0.40.0 watchdog >= 3.0.0 -websockets >= 15.0.1 -requests >= 2.32.5 -pandas >= 1.5.3 -anywidget >= 0.9.0 (optional, recommended) -redis >= 5.0.0 (optional, for deploy mode) +setproctitle >= 1.3.0 +websockets >= 16.0.0 ``` +Optional extras (see `pyproject.toml` for full lists): + +| Extra | Pulls in | Purpose | +|-------|----------|---------| +| `notebook` | `anywidget`, `ipykernel` | Jupyter / anywidget integration | +| `auth` | `authlib`, `keyring` | OAuth2 and keyring-backed token storage | +| `mcp` | `fastmcp` | `pywry mcp` MCP server | +| `acp` | `agent-client-protocol` | `StdioProvider` (Agent Client Protocol subprocess) | +| `sqlite` | `sqlcipher3` | Encrypted SQLite state backend | +| `openai` / `anthropic` / `magentic` | matching SDK | Chat providers | +| `deepagent` | `deepagents`, `langchain-mcp-adapters`, `fastmcp`, `agent-client-protocol` | `DeepagentProvider` (LangChain Deep Agents) | +| `freeze` | `pyinstaller` | Standalone executable builds | +| `all` | all of the above | Everything (no `freeze`) | + +Also used at build time: `cryptography` (for token storage in deploy mode), `plotly`, `pandas`. + --- ## Architecture Overview @@ -107,31 +128,39 @@ PyWry automatically selects the appropriate rendering path based on environment: ``` pywry/ -├── __init__.py # Public API exports -├── __main__.py # PyTauri subprocess entry point -├── app.py # Main PyWry class - user entry point +├── __init__.py # Public API exports (see "Core API / Imports") +├── __main__.py # PyTauri subprocess entry point (IPC loop + plugin loader) +├── _freeze.py # PyInstaller-aware freeze support + runtime setup +├── _pyinstaller_hook/ # PyInstaller hook dir (pulled in by `pywry[freeze]`) +├── _vendor/ # Vendored pytauri_wheel bundle +├── app.py # Main PyWry class — user entry point ├── runtime.py # PyTauri subprocess management (stdin/stdout IPC) +├── window_dispatch.py # Thread-safe dispatch into the PyTauri runtime +├── window_proxy.py # WindowProxy wrapping Tauri WebviewWindow API +├── menu_proxy.py # MenuProxy (native window / app menus) +├── tray_proxy.py # TrayProxy (system tray icons) +├── modal.py # Modal toolbar component + helpers ├── inline.py # FastAPI-based inline rendering + InlineWidget class ├── notebook.py # Notebook environment detection (NotebookEnvironment enum) -├── widget.py # anywidget-based widgets (PyWryWidget, PyWryPlotlyWidget, PyWryAgGridWidget) +├── widget.py # anywidget widgets (PyWryWidget, PyWry{Plotly,AgGrid,TVChart}Widget) ├── widget_protocol.py # BaseWidget protocol and NativeWindowHandle class ├── config.py # Layered configuration system (pydantic-settings) -├── models.py # Pydantic models (WindowConfig, HtmlContent, ThemeMode, WindowMode) -├── templates.py # HTML template builder with CSP, themes, scripts, toolbar +├── models.py # Core Pydantic models (WindowConfig, HtmlContent, ThemeMode, WindowMode) +├── types.py # Shared enum / menu / tray / mouse-button TypedDicts & Pydantic models +├── exceptions.py # PyWry exception hierarchy +├── templates.py # HTML template builder (CSP, themes, scripts, toolbar) ├── scripts.py # JavaScript bridge code injected into windows ├── callbacks.py # Event callback registry (singleton) -├── assets.py # Bundled asset loading (Plotly.js, AgGrid, CSS) +├── assets.py # Bundled asset loading (Plotly.js, AgGrid, TradingView CSS/JS) ├── asset_loader.py # CSS/JS file loading with caching ├── grid.py # AgGrid Pydantic models (ColDef, GridOptions, etc.) -├── plotly_config.py # Plotly configuration models (PlotlyConfig, ModeBarButton, etc.) -├── tvchart_config.py # TradingView chart config models (TVChartConfig, SeriesConfig, etc.) -├── tvchart.py # OHLCV data normalization and toolbar factory -├── toolbar.py # Toolbar component models (Button, Select, etc.) -├── state_mixins.py # Widget state management mixins +├── plotly_config.py # Plotly configuration models (PlotlyConfig, ModeBarButton, …) +├── toolbar.py # Toolbar component models (Button, Select, Marquee, …) +├── state_mixins.py # Widget state management mixins (Grid/Plotly/Toolbar/TVChart) ├── hot_reload.py # Hot reload manager ├── watcher.py # File system watcher (watchdog-based) ├── log.py # Logging utilities -├── cli.py # CLI commands (config, init) +├── cli.py # CLI commands (config, init, mcp …) ├── Tauri.toml # Tauri configuration ├── capabilities/ # Tauri capability permissions │ └── default.toml @@ -139,16 +168,64 @@ pywry/ │ └── window_commands.py ├── frontend/ # Frontend HTML and bundled assets │ ├── index.html -│ ├── assets/ # Compressed libraries (plotly, ag-grid, icons) -│ ├── src/ # JavaScript files (main.js, aggrid-defaults.js, plotly-defaults.js) -│ └── style/ # CSS files (pywry.css) +│ ├── assets/ # Compressed libraries (plotly, ag-grid, tvchart, icons, PyWry.png) +│ ├── src/ # JS modules (main.js, aggrid-defaults.js, plotly-defaults.js, tvchart/*.js, chat/*.js) +│ └── style/ # CSS files (pywry.css, chat.css, tvchart.css, …) +├── tvchart/ # TradingView Lightweight Charts integration +│ ├── __init__.py # Public exports (TVChartConfig, DatafeedProvider, UDFAdapter, mixin, …) +│ ├── config.py # TVChartConfig / SeriesConfig / layout / drawings models +│ ├── models.py # TVChartBar, TVChartSymbolInfo, quote / search / mark payloads +│ ├── normalize.py # OHLCV normalization (DataFrame → bars, indicator helpers) +│ ├── datafeed.py # DatafeedProvider ABC + default async dispatcher +│ ├── udf.py # UDFAdapter (HTTP UDF-compatible datafeed) +│ ├── mixin.py # TVChartStateMixin (Python-side chart state surface) +│ └── toolbars.py # build_tvchart_toolbars() factory +├── chat/ # Streaming chat widget +│ ├── __init__.py # Public exports (ChatProvider, artifacts, html builder, get_provider) +│ ├── manager.py # ChatManager, ChatContext, SettingsItem +│ ├── session.py # ACP session lifecycle: capabilities, modes, config options, plans +│ ├── models.py # ChatConfig, ChatMessage, ChatThread, ContentBlock, ACP payloads +│ ├── updates.py # SessionUpdate union (agent / artifact / plan / mode / tool-call / …) +│ ├── artifacts.py # Code / Html / Image / Json / Markdown / Plotly / Table / TradingView +│ ├── permissions.py # Permission request / response plumbing +│ ├── html.py # build_chat_html() — standalone chat UI bootstrap +│ └── providers/ # Pluggable ChatProvider backends +│ ├── __init__.py # ChatProvider ABC + get_provider() factory +│ ├── openai.py # OpenAIProvider (OpenAI SDK) +│ ├── anthropic.py # AnthropicProvider (Anthropic SDK) +│ ├── magentic.py # MagenticProvider (any magentic-supported LLM) +│ ├── callback.py # CallbackProvider (sync/async Python callback) +│ ├── stdio.py # StdioProvider (Agent Client Protocol subprocess) +│ └── deepagent.py # DeepagentProvider (LangChain Deep Agents + MCP adapters + ACP) +├── mcp/ # Model Context Protocol server +│ ├── __init__.py +│ ├── __main__.py # `python -m pywry.mcp` +│ ├── server.py # FastMCP server setup, transport wiring +│ ├── tools.py # Low-level event dispatch + widget/component tools +│ ├── handlers.py # Tool handlers +│ ├── builders.py # HTML/component builders shared across tools +│ ├── resources.py # MCP resources (bundled assets, component reference) +│ ├── prompts.py # MCP prompts +│ ├── agentic.py # Agent orchestration helpers +│ ├── state.py # Per-session state for long-running tools +│ ├── docs.py # Docs skill content +│ ├── install.py # `pywry mcp install` — client config writer +│ └── skills/ # Skills (authentication, chat, chat_agent, component_reference, +│ # css_selectors, data_visualization, deploy, events, +│ # forms_and_inputs, iframe, interactive_buttons, jupyter, +│ # modals, native, styling, tvchart, autonomous_building) ├── state/ # Pluggable state management for deploy mode │ ├── __init__.py # Public exports (stores, factory functions, types) │ ├── _factory.py # Factory functions for store instantiation -│ ├── memory.py # In-memory state backends (default) -│ ├── redis.py # Redis-backed state backends -│ ├── types.py # Type definitions (StateBackend, WidgetData, OAuthTokenSet, etc.) +│ ├── base.py # WidgetStore / EventBus / ConnectionRouter / SessionStore ABCs +│ ├── memory.py # In-memory backends (default) +│ ├── redis.py # Redis-backed backends +│ ├── sqlite.py # SQLite + SQLCipher backend (at-rest encryption) +│ ├── file.py # File-backed backend (dev / single-process) +│ ├── server.py # Deploy-mode FastAPI routes glue +│ ├── callbacks.py # Cross-worker callback dispatch │ ├── auth.py # Authentication and RBAC utilities +│ ├── types.py # Type definitions (StateBackend, WidgetData, OAuthTokenSet, …) │ └── sync_helpers.py # Sync↔async bridging (run_async, wait_for_event) ├── auth/ # OAuth2 authentication system │ ├── __init__.py # Public exports @@ -157,9 +234,10 @@ pywry/ │ ├── token_store.py # TokenStore ABC + Memory, Keyring, Redis backends │ ├── callback_server.py # Ephemeral localhost server for native auth redirects │ ├── deploy_routes.py # FastAPI /auth/* routes for deploy mode +│ ├── login_page.py # Rendered login page shipped with deploy-mode routes │ ├── flow.py # AuthFlowManager orchestrator │ └── session.py # SessionManager with automatic token refresh -├── utils/ # Utility helpers +├── utils/ # Utility helpers (async_helpers, …) └── window_manager/ # Window mode implementations ├── controller.py ├── lifecycle.py @@ -186,14 +264,70 @@ from pywry import HtmlContent, WindowConfig from pywry import ( Toolbar, Button, Select, MultiSelect, TextInput, TextArea, SearchInput, SecretInput, NumberInput, DateInput, SliderInput, RangeInput, Toggle, - Checkbox, RadioGroup, TabGroup, Div, Marquee, TickerItem, Option, ToolbarItem + Checkbox, RadioGroup, TabGroup, Div, Marquee, TickerItem, Option, ToolbarItem, + Modal, ) # Plotly configuration (for customizing modebar, icons, buttons) from pywry import PlotlyConfig, PlotlyIconName, ModeBarButton, ModeBarConfig, SvgIcon, StandardButton # Grid models (for AgGrid customization) -from pywry.grid import ColDef, ColGroupDef, DefaultColDef, RowSelection, GridOptions, GridConfig, GridData, build_grid_config, to_js_grid_config +from pywry import ColDef, ColGroupDef, DefaultColDef, RowSelection, GridOptions, build_grid_config, to_js_grid_config +# Or, with additional helpers: +from pywry.grid import GridConfig, GridData + +# TradingView charts +from pywry import ( + TVChartConfig, TVChartData, TVChartBar, TVChartMark, TVChartTimescaleMark, + TVChartExchange, TVChartSymbolInfo, TVChartSymbolInfoPriceSource, + TVChartLibrarySubsessionInfo, TVChartSearchSymbolResultItem, + TVChartStateMixin, QuoteData, + # Datafeed surface + DatafeedProvider, UDFAdapter, build_tvchart_toolbars, + # Datafeed request/response payloads + TVChartDatafeedConfigRequest, TVChartDatafeedConfigResponse, TVChartDatafeedConfiguration, + TVChartDatafeedHistoryRequest, TVChartDatafeedHistoryResponse, + TVChartDatafeedResolveRequest, TVChartDatafeedResolveResponse, + TVChartDatafeedSearchRequest, TVChartDatafeedSearchResponse, + TVChartDatafeedMarksRequest, TVChartDatafeedMarksResponse, + TVChartDatafeedTimescaleMarksRequest, TVChartDatafeedTimescaleMarksResponse, + TVChartDatafeedServerTimeRequest, TVChartDatafeedServerTimeResponse, + TVChartDatafeedSubscribeRequest, TVChartDatafeedUnsubscribeRequest, + TVChartDatafeedBarUpdate, TVChartDatafeedSymbolType, +) + +# Chat widget (ChatProvider ABC, artifact models, HTML bootstrap, provider factory) +from pywry import ( + ChatProvider, get_provider, build_chat_html, + ChatManager, ChatContext, SettingsItem, + ChatConfig, ChatMessage, ChatThread, ContentBlock, + CodeArtifact, HtmlArtifact, ImageArtifact, JsonArtifact, + MarkdownArtifact, PlotlyArtifact, TableArtifact, + TradingViewArtifact, TradingViewSeries, + # Session / update types + PlanEntry, SessionConfigOption, SessionMode, + AgentMessageUpdate, ArtifactUpdate, CitationUpdate, CommandsUpdate, + ConfigOptionUpdate, ModeUpdate, PlanUpdate, StatusUpdate, + ThinkingUpdate, ToolCallUpdate, + # ACP command payloads + ACPCommand, ACPToolCall, +) +# Concrete providers are imported from their modules: +from pywry.chat.providers.openai import OpenAIProvider # requires [openai] +from pywry.chat.providers.anthropic import AnthropicProvider # requires [anthropic] +from pywry.chat.providers.magentic import MagenticProvider # requires [magentic] +from pywry.chat.providers.callback import CallbackProvider +from pywry.chat.providers.stdio import StdioProvider # requires [acp] +from pywry.chat.providers.deepagent import DeepagentProvider # requires [deepagent] + +# Native / Tauri proxies +from pywry import MenuProxy, TrayProxy +from pywry.window_proxy import WindowProxy +from pywry.types import ( + MenuConfig, MenuItemConfig, CheckMenuItemConfig, IconMenuItemConfig, + PredefinedMenuItemConfig, PredefinedMenuItemKind, SubmenuConfig, + TrayIconConfig, MouseButton, MouseButtonState, +) # State mixins (for extending custom widgets) from pywry import GridStateMixin, PlotlyStateMixin, ToolbarStateMixin @@ -204,8 +338,8 @@ from pywry.inline import show_plotly, show_dataframe, show_tvchart, block, stop_ # Notebook detection from pywry import NotebookEnvironment, detect_notebook_environment, is_anywidget_available, should_use_inline_rendering -# Widget classes (PyWryWidget for notebooks) -from pywry import PyWryWidget, PyWryPlotlyWidget, PyWryAgGridWidget +# Widget classes (anywidget) +from pywry import PyWryWidget, PyWryPlotlyWidget, PyWryAgGridWidget, PyWryTVChartWidget # Widget protocol (for type checking and custom implementations) from pywry.widget_protocol import BaseWidget, NativeWindowHandle, is_base_widget @@ -217,7 +351,7 @@ from pywry import BrowserMode, get_lifecycle from pywry import PyWrySettings, SecuritySettings, WindowSettings, ThemeSettings, HotReloadSettings, TimeoutSettings, AssetSettings, LogSettings # Settings (require full path import) -from pywry.config import ServerSettings, DeploySettings +from pywry.config import ServerSettings, DeploySettings, OAuth2Settings, ChatSettings # Asset loading from pywry import AssetLoader, get_asset_loader @@ -229,7 +363,7 @@ from pywry import CallbackFunc, WidgetType, get_registry # Note: runtime is importable from pywry but not in __all__ from pywry import runtime -# State management (for deploy mode / horizontal scaling) +# State management (for deploy mode / horizontal scaling / at-rest encryption) from pywry.state import ( get_widget_store, get_event_bus, @@ -244,6 +378,12 @@ from pywry.state import ( UserSession, StateBackend, ) + +# OAuth2 authentication (public surface from pywry.auth) +from pywry.auth import ( + OAuthProvider, PKCEChallenge, TokenStore, + AuthFlowManager, SessionManager, OAuthCallbackServer, +) ``` ### PyWry Class @@ -618,11 +758,16 @@ Events follow the format `namespace:event-name`: | Namespace | Purpose | |-----------|---------| -| `pywry:*` | System events (initialization, results) | +| `pywry:*` | System events (initialization, results, theme, downloads, content updates) | | `plotly:*` | Plotly chart events | | `grid:*` | AgGrid table events | - -> **Note:** The `toolbar:` namespace is NOT reserved. Toolbar state response events use `toolbar:state-response` by convention. +| `tvchart:*` | TradingView Lightweight Charts events (datafeed, drawings, layouts, settings) | +| `chat:*` | Chat widget events (streaming updates, plans, permissions, modes, config) | +| `toolbar:*` | Toolbar state (`toolbar:request-state`, `toolbar:set-value`, `toolbar:state-response`, …) | +| `auth:*` | Authentication lifecycle (`auth:login`, `auth:logout`, `auth:state-change`) | +| `tray:*` | System tray icon / menu events | +| `menu:*` | Native window / app menu events | +| `modal:*` | Modal open / close / submit events | ### Pre-Registered Events @@ -684,6 +829,7 @@ Events follow the format `namespace:event-name`: | `pywry:ready` | Window/widget initialized | `{}` | | `pywry:result` | Data via `window.pywry.result()` | `any` | | `pywry:content-request` | Request content (load/reload) | `widget_type`, `window_label`, `reason` | +| `pywry:theme-update` | System theme changed (OS follow mode) | `theme: "light" \| "dark"` | | `pywry:disconnect` | Widget disconnected (inline mode) | `{}` | **System Events (Python → JS):** @@ -702,6 +848,66 @@ Events follow the format `namespace:event-name`: | `pywry:alert` | Show toast notification | `message`, `type?`, `title?`, `duration?` | | `pywry:refresh` | Refresh window content | `{}` | +**TradingView Events (JS → Python):** + +| Event | Trigger | Key Payload Fields | +|-------|---------|-------------------| +| `tvchart:crosshair` | Crosshair moved | `chartId`, `time`, `price`, `seriesId?` | +| `tvchart:click` | Chart clicked | `chartId`, `time`, `price`, `seriesId?` | +| `tvchart:visible-range-change` | User panned / zoomed | `chartId`, `from`, `to` | +| `tvchart:data-settled` | All series rendered (first frame settled) | `chartId`, `widget_type: "tvchart"` | +| `tvchart:datafeed-request` | Drawing-surface / UDF datafeed call | `chartId`, `kind`, `payload` | +| `tvchart:datafeed-subscribe` | Subscribe to bar updates | `chartId`, `symbol`, `resolution`, `listenerGuid` | +| `tvchart:datafeed-unsubscribe` | Unsubscribe from bar updates | `chartId`, `listenerGuid` | +| `tvchart:drawing-change` | User added / edited / removed a drawing | `chartId`, `drawings` | +| `tvchart:layout-save` | Layout saved | `chartId`, `name`, `layout` | +| `tvchart:settings-update` | Settings panel changed | `chartId`, `settings` | + +**TradingView Events (Python → JS):** + +| Event | Description | Key Payload Fields | +|-------|-------------|-------------------| +| `tvchart:update-data` | Replace series data | `data`, `chartId?`, `seriesId?` | +| `tvchart:append-bar` | Append or update the last bar | `bar`, `chartId?`, `seriesId?` | +| `tvchart:datafeed-response` | Answer a datafeed request | `chartId`, `kind`, `payload` | +| `tvchart:datafeed-bar-update` | Push streaming bar to subscribers | `chartId`, `listenerGuid`, `bar` | +| `tvchart:apply-indicator` | Add / update an indicator | `chartId`, `indicator`, `seriesId?` | +| `tvchart:set-visible-range` | Programmatic range change | `chartId`, `from`, `to` | +| `tvchart:load-layout` | Restore a saved layout | `chartId`, `layout` | +| `tvchart:apply-settings` | Apply settings panel state | `chartId`, `settings` | + +**Chat Events (JS → Python):** + +| Event | Trigger | Key Payload Fields | +|-------|---------|-------------------| +| `chat:user-message` | User sent a message | `threadId`, `message`, `content` | +| `chat:cancel` | User cancelled a streaming response | `threadId` | +| `chat:new-thread` | User requested a new thread | `{}` | +| `chat:switch-thread` | User switched threads | `threadId` | +| `chat:permission-response` | User answered a permission prompt | `requestId`, `outcome` | +| `chat:config-set` | User changed a settings item | `optionId`, `value` | +| `chat:mode-set` | User switched agent mode | `modeId` | +| `chat:slash-command` | User ran a slash command | `command`, `args` | +| `chat:context-request` | User opened a context source | `sourceId` | + +**Chat Events (Python → JS):** + +| Event | Description | Key Payload Fields | +|-------|-------------|-------------------| +| `chat:agent-message` | Streaming agent message delta | `threadId`, `delta`, `messageId` | +| `chat:thinking` | Thinking / reasoning chunk | `threadId`, `delta` | +| `chat:tool-call` | Tool call start / update / complete | `threadId`, `toolCallId`, `status`, `input?`, `output?` | +| `chat:artifact` | Attach / update an artifact | `threadId`, `artifact` | +| `chat:status` | Status string update | `threadId`, `status` | +| `chat:plan-update` | Plan / todo list change | `threadId`, `plan` | +| `chat:permission-request` | Prompt the user to approve/deny | `threadId`, `requestId`, `prompt`, `options` | +| `chat:mode-update` | Available modes / active mode | `threadId`, `modes`, `active` | +| `chat:config-update` | Available config options | `threadId`, `options` | +| `chat:commands-update` | Available slash commands | `threadId`, `commands` | +| `chat:citation` | Emit a citation | `threadId`, `citation` | + +> **Note:** Namespace events (`auth:*`, `tray:*`, `menu:*`, `modal:*`) follow the same pattern and are documented under their respective subsystems. + ### Toast Notifications (`pywry:alert`) PyWry provides a unified toast notification system across all rendering paths. @@ -1048,7 +1254,7 @@ app.emit("toolbar:set-values", {"values": {"select-1": "A", "toggle-1": True}}, ### Tauri Plugins -PyWry bundles 19 Tauri plugins via `pytauri_wheel`. By default, only `dialog` and `fs` are enabled. Developers can enable additional plugins through configuration — **no Rust compilation required**. +PyWry bundles the full Tauri plugin set via `pytauri_wheel`. By default, only `dialog` and `fs` are enabled. Developers can enable additional plugins through configuration — **no Rust compilation required**. #### Enabling Plugins @@ -1102,7 +1308,7 @@ export PYWRY_EXTRA_CAPABILITIES="shell:allow-execute" 1. `PyWrySettings.tauri_plugins` holds the list of plugin names to activate 2. The parent process passes this list to the subprocess via `PYWRY_TAURI_PLUGINS` env var 3. In `__main__.py`, `_load_plugins()` dynamically imports each `pytauri_plugins.` module and calls `.init()` -4. The `capabilities/default.toml` pre-grants `:default` permissions for all 19 plugins (unused permissions are harmless) +4. The `capabilities/default.toml` pre-grants `:default` permissions for every bundled plugin (unused permissions are harmless) 5. For fine-grained permission control, use `extra_capabilities` to add specific permission strings (e.g., `shell:allow-execute`) Each plugin has a `PLUGIN_*` compile-time feature flag in `pytauri_plugins` that is checked before initialization. If a plugin is not compiled into the bundled `pytauri_wheel`, a clear `RuntimeError` is raised. @@ -1290,6 +1496,175 @@ When authenticated, `window.__PYWRY_AUTH__` contains `{ user_id, roles, token_ty --- +## Chat Widget + +PyWry ships a streaming chat widget with a pluggable provider interface modelled on the Agent Client Protocol (ACP) session lifecycle: `initialize` → `new_session` → `prompt` loop → `cancel`. + +### Providers + +| Provider | Extra | Description | +|----------|-------|-------------| +| `OpenAIProvider` | `pywry[openai]` | OpenAI Responses / Chat Completions SDK | +| `AnthropicProvider` | `pywry[anthropic]` | Anthropic Messages SDK (streaming) | +| `MagenticProvider` | `pywry[magentic]` | magentic — any magentic-supported LLM | +| `CallbackProvider` | *(base)* | Wraps a sync / async Python callback | +| `StdioProvider` | `pywry[acp]` | Runs any ACP agent as a subprocess | +| `DeepagentProvider` | `pywry[deepagent]` | LangChain Deep Agents with built-in MCP adapters and ACP bridging | + +Select a provider by name with the factory: + +```python +from pywry.chat import get_provider + +provider = get_provider("anthropic", model="claude-opus-4-7", api_key="...") +``` + +### ChatManager + +```python +from pywry import PyWry, ChatManager, build_chat_html + +app = PyWry() +chat = ChatManager(provider=provider, app=app) +html = build_chat_html() # chat UI bootstrap +handle = app.show(html, callbacks=chat.callbacks()) +app.block() +``` + +`ChatManager` owns: + +- **Threads** — persisted via the configured state backend; switched with `chat:switch-thread`. +- **Artifacts** — `CodeArtifact`, `HtmlArtifact`, `ImageArtifact`, `JsonArtifact`, `MarkdownArtifact`, `PlotlyArtifact`, `TableArtifact`, `TradingViewArtifact`. Emitted via `chat:artifact`. +- **Plan / todo updates** — `PlanEntry` list streamed via `chat:plan-update`. +- **Permissions** — `chat:permission-request` prompts the UI; the user answer comes back on `chat:permission-response`. +- **Modes / config / commands** — `chat:mode-update`, `chat:config-update`, `chat:commands-update` surface whatever the underlying provider advertises. +- **Context sources** — register file / URL / custom sources that the user can attach to a prompt. + +### SettingsItem + +Settings appear in the chat settings panel and are round-tripped via `chat:config-update` / `chat:config-set`. They support text / select / toggle / secret inputs, validation, and per-option `help`. + +--- + +## TradingView Charts + +`pywry.tvchart` wraps TradingView Lightweight Charts with a full drawing surface, pluggable datafeed API, UDF adapter, streaming bar updates, compare overlays, compare-derivative indicators, savable layouts, and a themeable settings panel. + +### Quick Start + +```python +import pandas as pd +from pywry import PyWry, TVChartConfig + +app = PyWry() +df = pd.DataFrame(...) # columns: time / open / high / low / close / volume +app.show_tvchart(df, title="AAPL — Daily") +app.block() +``` + +### Datafeed API + +Implement `DatafeedProvider` to drive the chart from a quote server or any async source: + +```python +from pywry.tvchart import ( + DatafeedProvider, + TVChartDatafeedHistoryRequest, + TVChartDatafeedHistoryResponse, + TVChartDatafeedResolveRequest, + TVChartDatafeedResolveResponse, +) + +class MyDatafeed(DatafeedProvider): + async def resolve_symbol(self, req: TVChartDatafeedResolveRequest) -> TVChartDatafeedResolveResponse: + ... + async def get_history(self, req: TVChartDatafeedHistoryRequest) -> TVChartDatafeedHistoryResponse: + ... + # search_symbols, get_marks, get_timescale_marks, get_server_time, subscribe_bars, unsubscribe_bars + +app.show_tvchart(datafeed=MyDatafeed(), symbol="AAPL", resolution="1D") +``` + +### UDFAdapter + +Point the chart at any TradingView UDF-compatible HTTP endpoint: + +```python +from pywry.tvchart import UDFAdapter + +adapter = UDFAdapter(base_url="https://demo_feed.tradingview.com") +app.show_tvchart(datafeed=adapter, symbol="AAPL", resolution="1D") +``` + +### Indicators, Compare, Drawings + +- **Indicators** — overlay any Lightweight Charts indicator; compare-derivative indicators (`Spread`, `Ratio`, `Sum`, `Product`, `Correlation`) are computed across overlays. +- **Drawings** — trendlines, fib tools, text annotations, price notes, and brushes. All round-trip through `tvchart:drawing-change` and are persisted with layouts. +- **Layouts** — save the current chart (series, overlays, indicators, drawings, settings) with `tvchart:layout-save`; restore with `tvchart:load-layout`. + +### TVChartStateMixin + +The Python-side chart state surface is exposed via `TVChartStateMixin`, which is mixed into `PyWry`, `PyWryTVChartWidget`, and any user-defined widget that wants chart control. + +--- + +## MCP Server + +PyWry ships an MCP server that exposes widget management, chart / grid / tvchart control, events, chat driving, auth, and skills to any MCP client (Claude Desktop, Claude Code, Cursor, …). + +### Launching + +```bash +pip install 'pywry[mcp]' + +pywry mcp --transport stdio # default, for Claude Desktop / Claude Code +pywry mcp --transport http --host 0.0.0.0 # HTTP transport +pywry mcp install # write client config (interactive) +``` + +### Skills + +Each skill bundles tools + prompts + reference docs for a coherent area. Current skills: + +- `authentication` — OAuth2 flows and token management +- `autonomous_building` — long-running "build this dashboard" loops +- `chat` — low-level chat widget control +- `chat_agent` — drive a running chat session (select mode, send a prompt, observe) +- `component_reference` — Pydantic toolbar components with live previews +- `css_selectors` — `--pywry-*` variables and selector crib sheet +- `data_visualization` — Plotly / AgGrid / TradingView recipes +- `deploy` — deploy-mode scaling and state backend choices +- `events` — Python ↔ JS event catalogue +- `forms_and_inputs` — toolbar-driven form recipes +- `iframe` — notebook / browser / inline quirks +- `interactive_buttons` — button-centric patterns +- `jupyter` — anywidget + iframe behaviour in notebooks +- `modals` — `Modal` toolbar component recipes +- `native` — native-only features (menus, tray, dialogs, `WindowProxy`) +- `styling` — CSS variables, theming, hot reload +- `tvchart` — TradingView datafeed, UDF, drawings, indicators, layouts + +### Tools + +Tools are grouped under `widget_*`, `component_*`, `chart_*`, `grid_*`, `tvchart_*`, `chat_*`, `auth_*`, `events_*`, and `docs_*`. The low-level `send_event` tool dispatches any namespaced event directly. + +--- + +## State Backends + +PyWry's state layer powers deploy mode, notebook / browser mode, and any multi-window / multi-worker setup. The same `WidgetStore` / `EventBus` / `ConnectionRouter` / `SessionStore` ABCs are implemented by every backend. + +| Backend | Use case | Notes | +|---------|----------|-------| +| `memory` (default) | Single-process, development, notebooks | No external deps | +| `file` | Single-node persistence without a DB | JSON on disk | +| `sqlite` | Single-node with at-rest encryption | `pywry[sqlite]` (SQLCipher); keyed by `PYWRY_DEPLOY__SQLITE_KEY` | +| `redis` | Multi-worker / horizontally scaled | `redis >= 7.1.0` (client ships in base install) | + +Select a backend via `PYWRY_DEPLOY__STATE_BACKEND`, `pywry.toml` `[deploy]`, or `DeploySettings(state_backend=...)`. + +--- + ## Key Classes Reference ### Core Classes @@ -1513,7 +1888,7 @@ Events include `widget_type` for identification: ## CSS Theming -PyWry provides 60+ CSS variables for comprehensive customization, plus component ID targeting for precise styling. +PyWry is themed through `--pywry-*` CSS variables, with component-ID targeting for precise styling. The full variable reference lives in the [CSS docs](https://deeleeramone.github.io/PyWry/reference/css/). ### Theme Classes @@ -1606,8 +1981,8 @@ Target in CSS: `#save-btn { background: green; }` or `[data-event="app:save"] { ### Setup ```bash -git clone https://github.com/OpenBB-finance/OpenBB.git -cd pywry +git clone https://github.com/deeleeramone/PyWry.git +cd PyWry/pywry python -m venv venv source venv/bin/activate # or venv\Scripts\activate on Windows uv sync --all-extras --group dev diff --git a/pywry/README.md b/pywry/README.md index 0e40ff6..8cce3db 100644 --- a/pywry/README.md +++ b/pywry/README.md @@ -20,18 +20,28 @@ Python 3.10–3.14, virtual environment recommended. pip install pywry ``` +Core extras: + | Extra | When to use | |-------|-------------| | `pip install 'pywry[notebook]'` | Jupyter / anywidget integration | | `pip install 'pywry[auth]'` | OAuth2 and keyring-backed auth support | | `pip install 'pywry[freeze]'` | PyInstaller hook for standalone executables | | `pip install 'pywry[mcp]'` | Model Context Protocol server support | -| `pip install 'pywry[openai]'` | `OpenAIProvider` integration | -| `pip install 'pywry[anthropic]'` | `AnthropicProvider` integration | -| `pip install 'pywry[magentic]'` | `MagenticProvider` integration | +| `pip install 'pywry[sqlite]'` | Encrypted SQLite state backend (SQLCipher) | | `pip install 'pywry[all]'` | Everything above | -The chat UI itself is included in the base package. The provider extras only install optional third-party SDKs. +Chat provider extras: + +| Extra | When to use | +|-------|-------------| +| `pip install 'pywry[openai]'` | `OpenAIProvider` (OpenAI SDK) | +| `pip install 'pywry[anthropic]'` | `AnthropicProvider` (Anthropic SDK) | +| `pip install 'pywry[magentic]'` | `MagenticProvider` (any magentic-supported LLM) | +| `pip install 'pywry[acp]'` | `StdioProvider` (Agent Client Protocol subprocess) | +| `pip install 'pywry[deepagent]'` | `DeepagentProvider` (LangChain Deep Agents — includes MCP adapters and ACP) | + +The chat UI itself is included in the base package. Provider extras only install the matching third-party SDK. **Linux only** — install system webview dependencies first: @@ -101,12 +111,15 @@ app.block() ## Features -- **18 toolbar components** — `Button`, `Select`, `MultiSelect`, `TextInput`, `SecretInput`, `SliderInput`, `RangeInput`, `Toggle`, `Checkbox`, `RadioGroup`, `TabGroup`, `Marquee`, `Modal`, and more. All Pydantic models, 7 layout positions. -- **Two-way events** — `app.emit()` and `app.on()` bridge Python and JavaScript in both directions. Pre-wired Plotly and AgGrid events included. -- **Theming** — light/dark modes, 60+ CSS variables, hot reload during development. -- **Security** — token auth, CSP headers, `SecuritySettings.strict()` / `.permissive()` / `.localhost()` presets. `SecretInput` stores values server-side, never in HTML. -- **Standalone executables** — PyInstaller hook ships with `pywry[freeze]`. No `.spec` edits or `--hidden-import` flags required. -- **MCP server** — 25 tools, 8 skills, 20+ resources for AI agent integration. +- **Toolbar components** — `Button`, `Select`, `MultiSelect`, `TextInput`, `SecretInput`, `SliderInput`, `RangeInput`, `Toggle`, `Checkbox`, `RadioGroup`, `TabGroup`, `Marquee`, `Modal`, and more. All Pydantic models; position them around the content edges or inside the chart area. +- **Two-way events** — `app.emit()` and `app.on()` bridge Python and JavaScript in both directions. Pre-wired Plotly and AgGrid events included. +- **Chat** — streaming chat widget with threads, slash commands, artifacts, and pluggable providers: `OpenAIProvider`, `AnthropicProvider`, `MagenticProvider`, `CallbackProvider`, `StdioProvider` (ACP subprocess), and `DeepagentProvider` (LangChain Deep Agents). +- **TradingView charts** — extended Lightweight Charts integration with a full drawing surface (trendlines, fib tools, text annotations, price notes, brushes), pluggable datafeed API, UDF adapter for external quote servers, streaming bar updates, compare overlays, compare-derivative indicators (Spread / Ratio / Sum / Product / Correlation), savable layouts, and a themeable settings panel. +- **Theming** — light / dark / system modes, themeable via `--pywry-*` CSS variables, hot reload during development. +- **Security** — token auth, CSP headers, `SecuritySettings.strict()` / `.permissive()` / `.localhost()` presets. `SecretInput` stores values server-side, never in HTML. +- **State backends** — in-memory (default), Redis (multi-worker), or SQLite with SQLCipher encryption at rest. +- **Standalone executables** — PyInstaller hook ships with `pywry[freeze]`. No `.spec` edits or `--hidden-import` flags required. +- **MCP server** — drive widgets, charts, and dashboards from any Model Context Protocol client (Claude Desktop, Claude Code, Cursor, etc.). ## MCP Server diff --git a/pywry/docs/docs/components/chat/index.md b/pywry/docs/docs/components/chat/index.md index 7055983..e03fef4 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,310 @@ 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]'` | +| `DeepagentProvider` | LangChain Deep Agents (planning, MCP tools, skills) | `pip install 'pywry[deepagent]'` | + +See [Chat Providers](../../integrations/chat/chat-providers.md) for the reference API of each provider. + +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/installation.md b/pywry/docs/docs/getting-started/installation.md index cdb1ae9..56347e4 100644 --- a/pywry/docs/docs/getting-started/installation.md +++ b/pywry/docs/docs/getting-started/installation.md @@ -18,15 +18,18 @@ Core extras: | `pip install 'pywry[auth]'` | OAuth2 and secure token storage support | | `pip install 'pywry[freeze]'` | PyInstaller hook for frozen desktop apps | | `pip install 'pywry[mcp]'` | Model Context Protocol server support | +| `pip install 'pywry[sqlite]'` | Encrypted SQLite state backend (SQLCipher) | | `pip install 'pywry[all]'` | Every optional dependency above | -Provider SDK extras: +Chat provider extras: | Extra | Installs | |-------|----------| | `pip install 'pywry[openai]'` | OpenAI SDK for `OpenAIProvider` | | `pip install 'pywry[anthropic]'` | Anthropic SDK for `AnthropicProvider` | | `pip install 'pywry[magentic]'` | Magentic package for `MagenticProvider` | +| `pip install 'pywry[acp]'` | Agent Client Protocol for `StdioProvider` | +| `pip install 'pywry[deepagent]'` | LangChain Deep Agents for `DeepagentProvider` (includes MCP adapters and ACP) | Chat UI support is part of the base package. Provider extras only install third-party SDKs for the matching adapter classes. 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..4608e62 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) | @@ -169,14 +170,28 @@ python my_app.py Redis key structure: `{prefix}:widget:{widget_id}` (hash), `{prefix}:widgets:active` (set of active IDs). +### SQLite + +Widget and session state is persisted to a local SQLite database file. Suitable for single-host multi-worker deployments that don't want a Redis dependency. + +```bash +PYWRY_DEPLOY__STATE_BACKEND=sqlite \ +PYWRY_DEPLOY__SQLITE_PATH=~/.config/pywry/pywry.db \ +python my_app.py +``` + +- State persists across restarts on the same host. +- Multiple workers on one host can share the database via WAL journal mode. +- **Encrypted at rest via SQLCipher** when `pywry[sqlite]` is installed. The encryption key is sourced from `PYWRY_SQLITE_KEY` if set, otherwise from the OS keyring (`keyring`), otherwise derived from a per-host salt file. Falls back to plain SQLite (with a warning) when the `sqlcipher3` binding isn't available. + ## Detecting Deploy Mode ```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..0a80249 100644 --- a/pywry/pywry/mcp/handlers.py +++ b/pywry/pywry/mcp/handlers.py @@ -6,7 +6,9 @@ from __future__ import annotations import json +import logging import os +import time import uuid from collections.abc import Callable @@ -22,15 +24,34 @@ ) from .skills import get_skill, list_skills from .state import ( + capture_widget_events, get_app, get_widget, list_widget_ids, register_widget, remove_widget, + request_response, store_widget_config, ) +logger = logging.getLogger(__name__) + + +# Default set of tvchart events that are captured into the MCP events +# dict so agents can retrieve them via the ``get_events`` tool. +_TVCHART_CAPTURE_EVENTS = [ + "tvchart:click", + "tvchart:crosshair-move", + "tvchart:visible-range-change", + "tvchart:drawing-added", + "tvchart:drawing-deleted", + "tvchart:open-layout-request", + "tvchart:interval-change", + "tvchart:chart-type-change", +] + + # Type aliases EventsDict = dict[str, list[dict[str, Any]]] MakeCallback = Callable[[str], Callable[[Any, str, str], None]] @@ -356,6 +377,7 @@ def _handle_show_tvchart(ctx: HandlerContext) -> HandlerResult: widget_id = getattr(widget, "widget_id", None) or uuid.uuid4().hex register_widget(widget_id, widget) + capture_widget_events(widget, widget_id, ctx.events, _TVCHART_CAPTURE_EVENTS) if ctx.headless: from ..inline import _state as inline_state @@ -373,20 +395,832 @@ def _handle_show_tvchart(ctx: HandlerContext) -> HandlerResult: # ============================================================================= # Widget Manipulation Handlers # ============================================================================= -def _get_widget_or_error(widget_id: str) -> tuple[Any | None, HandlerResult | None]: - """Get widget by ID, returning error dict if not found.""" - widget = get_widget(widget_id) +def _resolve_widget_id(widget_id: str | None) -> tuple[str | None, HandlerResult | None]: + """Resolve the target widget id, defaulting to the sole registered widget. + + The MCP schema documents ``widget_id`` as required because + multi-widget servers genuinely need it to disambiguate. At dispatch + time, however, a single-widget server can resolve the id from its + own registry — every component has an id, the server already knows + it, the agent shouldn't have to repeat what the server knows. + + Resolution rules: + - ``widget_id`` provided → use it as-is. + - Missing AND exactly one widget registered → use that widget. + - Missing AND zero or many widgets → error listing the candidates. + """ + if widget_id: + return widget_id, None + ids = list_widget_ids() + if len(ids) == 1: + return ids[0], None + if not ids: + return None, { + "error": "widget_id is required (no widgets are registered yet).", + } + return None, { + "error": ( + "widget_id is required when multiple widgets exist. " + f"Registered widgets: {', '.join(ids)}." + ), + } + + +def _get_widget_or_error(widget_id: str | None) -> tuple[Any | None, HandlerResult | None]: + """Resolve a widget by id (auto-defaulting to the sole registered widget). + + Returns the widget instance plus ``None``, or ``None`` plus a + structured error dict listing the registered ids so the caller can + self-correct. + """ + resolved_id, error = _resolve_widget_id(widget_id) + if error is not None or resolved_id is None: + return None, error or {"error": "widget_id could not be resolved."} + widget = get_widget(resolved_id) if not widget: - return None, {"error": f"Widget not found: {widget_id}"} + ids = list_widget_ids() + return None, { + "error": ( + f"Widget not found: {resolved_id}." + + ( + f" Registered widgets: {', '.join(ids)}." + if ids + else " No widgets are registered yet." + ) + ), + } return widget, None +# ============================================================================= +# TVChart Handlers — every tvchart:* event exposed as a first-class tool +# ============================================================================= + + +def _fetch_tvchart_state(widget: Any, timeout: float = 1.5) -> dict[str, Any] | None: + """Round-trip ``tvchart:request-state`` and strip the correlation token. + + The frontend answers with ``{chartId, error: "not found"}`` when + the chart entry is mid-rebuild (destroy → recreate on a symbol or + interval mutation). Treat that as "state unavailable right now" + and return ``None``. + """ + response = request_response( + widget, + "tvchart:request-state", + "tvchart:state-response", + {}, + timeout=timeout, + ) + if response is None: + return None + response.pop("context", None) + if response.get("error"): + return None + return response + + +def _wait_for_data_settled( + widget: Any, + matcher: Callable[[dict[str, Any]], bool], + *, + timeout: float = 8.0, +) -> dict[str, Any] | None: + """Block until the frontend signals a data-response has fully settled. + + The ``tvchart:data-settled`` event is emitted by the data-response + handler after the destroy-recreate (symbol / interval change) or + the in-place update (compare / overlay add) completes — including + the 150ms tail for indicator re-add. Waiting on it is strictly + better than polling ``tvchart:request-state``: there's no race + with the rebuild window and the payload is the exact post-mutation + state snapshot. + + The matcher lets the caller ignore an unrelated concurrent + mutation's settled event (e.g. compare-add firing while we're + waiting for a symbol-change to settle). Returns the first + matching payload, or ``None`` on timeout. + """ + import threading as _threading + + result: dict[str, dict[str, Any] | None] = {"payload": None} + done = _threading.Event() + + def _listener(data: Any, _event_type: str = "", _label: str = "") -> None: + if done.is_set() or not isinstance(data, dict): + return + if data.get("error"): + return + if matcher(data): + result["payload"] = data + done.set() + + # Register an event listener, wait for a matching payload, tear it + # down. ``widget.on`` returns an unsubscribe callable on most + # PyWry backends; fall back to a best-effort untracked listener + # otherwise. + unsubscribe = None + try: + unsubscribe = widget.on("tvchart:data-settled", _listener) + except Exception: + logger.debug("widget.on('tvchart:data-settled') failed", exc_info=True) + try: + done.wait(timeout=timeout) + finally: + if callable(unsubscribe): + try: + unsubscribe() + except Exception: + logger.debug("unsubscribe failed", exc_info=True) + return result["payload"] + + +def _poll_tvchart_state( + widget: Any, + *, + matcher: Callable[[dict[str, Any]], bool], + total_timeout: float = 6.0, + poll_interval: float = 0.2, + settle_delay: float = 0.4, +) -> dict[str, Any] | None: + """Poll ``tvchart:request-state`` until the chart reflects a mutation. + + Many chart mutations kick off multi-hop async chains in the frontend + (symbol-search -> datafeed-resolve -> data-request -> data-response -> destroy-recreate). + The chart entry is genuinely unavailable during the destroy-recreate + window and the frontend answers with ``{error: "not found"}`` that + ``_fetch_tvchart_state`` already turns into ``None``. + + ``settle_delay`` is a short initial wait BEFORE the first poll so + we don't race into the rebuild window before it's even started — + otherwise the first fetch can return the still-alive pre-mutation + state, matcher fails against an unchanged symbol, and we burn the + poll budget waiting for a rebuild that already finished. + """ + if settle_delay > 0: + time.sleep(settle_delay) + deadline = time.monotonic() + total_timeout + latest: dict[str, Any] | None = None + while True: + state = _fetch_tvchart_state(widget, timeout=max(0.5, poll_interval * 4)) + if state is not None: + latest = state + if matcher(state): + return state + if time.monotonic() >= deadline: + return latest + time.sleep(poll_interval) + + +def _minimal_confirm_state(state: dict[str, Any] | None) -> dict[str, Any]: + """Reduce a full state snapshot to fields safe for a mutation result. + + Confirmation results must not carry raw OHLCV bars, per-bar timestamps, + drawings, or visible-range data. Exposing any of those tempts the + model into paraphrasing them into the reply (e.g. "last close: $...", + which the model then rounds, reformats, or invents outright if the + field is sparse). Keep to identity fields only — the chart UI is + what the user reads for prices. + """ + if not isinstance(state, dict): + return {} + compares = state.get("compareSymbols") + indicators_in = state.get("indicators") or [] + indicators_out: list[dict[str, Any]] = [] + if isinstance(indicators_in, list): + for ind in indicators_in: + if not isinstance(ind, dict): + continue + indicators_out.append( + { + k: ind.get(k) + for k in ("seriesId", "name", "type", "period", "secondarySymbol") + if ind.get(k) is not None + } + ) + out = { + "symbol": state.get("symbol") or None, + "interval": state.get("interval") or None, + "chartType": state.get("chartType") or None, + } + if isinstance(compares, dict) and compares: + out["compareSymbols"] = {k: str(v) for k, v in compares.items()} + if indicators_out: + out["indicators"] = indicators_out + return {k: v for k, v in out.items() if v is not None} + + +def _emit_tvchart( + ctx: HandlerContext, + event_type: str, + payload: dict[str, Any], + *, + extras: dict[str, Any] | None = None, +) -> HandlerResult: + """Shared helper: resolve widget, emit event, return a uniform result.""" + widget_id = ctx.args.get("widget_id") + resolved_id, error = _resolve_widget_id(widget_id) + if error is not None or resolved_id is None: + return error or {"error": "widget_id could not be resolved."} + widget = get_widget(resolved_id) + if not widget: + ids = list_widget_ids() + return { + "error": ( + f"Widget not found: {resolved_id}." + + ( + f" Registered widgets: {', '.join(ids)}." + if ids + else " No widgets are registered yet." + ) + ), + } + chart_id = ctx.args.get("chart_id") + merged = {k: v for k, v in (payload or {}).items() if v is not None} + if chart_id is not None: + merged["chartId"] = chart_id + widget.emit(event_type, merged) + result: HandlerResult = { + "widget_id": resolved_id, + "event_sent": True, + "event_type": event_type, + } + if extras: + result.update(extras) + return result + + +def _handle_tvchart_update_series(ctx: HandlerContext) -> HandlerResult: + return _emit_tvchart( + ctx, + "tvchart:update", + { + "bars": ctx.args.get("bars"), + "volume": ctx.args.get("volume"), + "seriesId": ctx.args.get("series_id"), + "fitContent": ctx.args.get("fit_content", True), + }, + ) + + +def _handle_tvchart_update_bar(ctx: HandlerContext) -> HandlerResult: + return _emit_tvchart( + ctx, + "tvchart:stream", + { + "bar": ctx.args.get("bar"), + "seriesId": ctx.args.get("series_id"), + }, + ) + + +def _handle_tvchart_add_series(ctx: HandlerContext) -> HandlerResult: + series_id = ctx.args["series_id"] + return _emit_tvchart( + ctx, + "tvchart:add-series", + { + "seriesId": series_id, + "bars": ctx.args.get("bars"), + "seriesType": ctx.args.get("series_type", "Line"), + "seriesOptions": ctx.args.get("series_options") or {}, + }, + extras={"series_id": series_id}, + ) + + +def _handle_tvchart_remove_series(ctx: HandlerContext) -> HandlerResult: + series_id = ctx.args["series_id"] + return _emit_tvchart( + ctx, + "tvchart:remove-series", + {"seriesId": series_id}, + extras={"series_id": series_id}, + ) + + +def _handle_tvchart_add_markers(ctx: HandlerContext) -> HandlerResult: + return _emit_tvchart( + ctx, + "tvchart:add-markers", + { + "markers": ctx.args.get("markers"), + "seriesId": ctx.args.get("series_id"), + }, + ) + + +def _handle_tvchart_add_price_line(ctx: HandlerContext) -> HandlerResult: + return _emit_tvchart( + ctx, + "tvchart:add-price-line", + { + "price": ctx.args.get("price"), + "color": ctx.args.get("color", "#2196F3"), + "lineWidth": ctx.args.get("line_width", 1), + "title": ctx.args.get("title", ""), + "seriesId": ctx.args.get("series_id"), + }, + ) + + +def _handle_tvchart_apply_options(ctx: HandlerContext) -> HandlerResult: + return _emit_tvchart( + ctx, + "tvchart:apply-options", + { + "chartOptions": ctx.args.get("chart_options"), + "seriesOptions": ctx.args.get("series_options"), + "seriesId": ctx.args.get("series_id"), + }, + ) + + +def _handle_tvchart_add_indicator(ctx: HandlerContext) -> HandlerResult: + return _emit_tvchart( + ctx, + "tvchart:add-indicator", + { + "name": ctx.args.get("name"), + "period": ctx.args.get("period"), + "color": ctx.args.get("color"), + "source": ctx.args.get("source"), + "method": ctx.args.get("method"), + "multiplier": ctx.args.get("multiplier"), + "maType": ctx.args.get("ma_type"), + "offset": ctx.args.get("offset"), + }, + ) + + +def _handle_tvchart_remove_indicator(ctx: HandlerContext) -> HandlerResult: + series_id = ctx.args["series_id"] + return _emit_tvchart( + ctx, + "tvchart:remove-indicator", + {"seriesId": series_id}, + extras={"series_id": series_id}, + ) + + +def _handle_tvchart_list_indicators(ctx: HandlerContext) -> HandlerResult: + widget_id = ctx.args.get("widget_id") + widget, error = _get_widget_or_error(widget_id) + if error is not None or widget is None: + return error or {"error": "widget_id could not be resolved."} + payload: dict[str, Any] = {} + chart_id = ctx.args.get("chart_id") + if chart_id is not None: + payload["chartId"] = chart_id + response = request_response( + widget, + "tvchart:list-indicators", + "tvchart:list-indicators-response", + payload, + timeout=float(ctx.args.get("timeout", 5.0)), + ) + if response is None: + return {"widget_id": widget_id, "error": "Indicator listing timed out"} + return { + "widget_id": widget_id, + "indicators": response.get("indicators", []), + "chartId": response.get("chartId"), + } + + +def _handle_tvchart_show_indicators(ctx: HandlerContext) -> HandlerResult: + return _emit_tvchart(ctx, "tvchart:show-indicators", {}) + + +def _handle_tvchart_symbol_search(ctx: HandlerContext) -> HandlerResult: + widget_id = ctx.args.get("widget_id") + resolved_id, error = _resolve_widget_id(widget_id) + if error is not None or resolved_id is None: + return error or {"error": "widget_id could not be resolved."} + widget = get_widget(resolved_id) + if not widget: + return {"error": f"Widget not found: {resolved_id}."} + + query = ctx.args.get("query") + auto_select = ctx.args.get("auto_select", True) + payload: dict[str, Any] = {"query": query, "autoSelect": auto_select} + chart_id = ctx.args.get("chart_id") + if chart_id is not None: + payload["chartId"] = chart_id + symbol_type = ctx.args.get("symbol_type") + if symbol_type: + payload["symbolType"] = symbol_type + exchange = ctx.args.get("exchange") + if exchange: + payload["exchange"] = exchange + # Capture the pre-emit symbol so we can tell whether the chart + # changed at all — even a fuzzy search ("microsoft" -> "MSFT") is a + # successful change, not a failure, and the note below must reflect + # that rather than complaining the literal query wasn't found. Only + # worth fetching when we're actually going to auto-commit a query. + will_confirm = bool(query) and bool(auto_select) + pre_symbol = "" + if will_confirm: + pre_state = _fetch_tvchart_state(widget, timeout=0.6) or {} + pre_symbol = str(pre_state.get("symbol") or "").upper() + payload = {k: v for k, v in payload.items() if v is not None} + widget.emit("tvchart:symbol-search", payload) + + result: HandlerResult = { + "widget_id": resolved_id, + "event_sent": True, + "event_type": "tvchart:symbol-search", + } + if not will_confirm: + # Just opened the dialog for the user — no mutation to confirm. + return result + + target = str(query).upper() + target_bare = target.rsplit(":", maxsplit=1)[-1].strip() if ":" in target else target + + def _matches(state: dict[str, Any]) -> bool: + current = str(state.get("symbol") or "").upper() + if not current: + return False + # Exact / bare-ticker match — or any change away from the + # pre-emit symbol. The latter covers fuzzy searches like + # "microsoft" -> "MSFT" where the query is a company name + # rather than a ticker; the chart still genuinely committed + # the user's intent and the tool result should reflect that. + if current in (target, target_bare): + return True + return bool(pre_symbol) and current != pre_symbol + + # Block until the frontend emits tvchart:data-settled for the main + # series — that fires AFTER the destroy-recreate and all post- + # mutation work (legend refresh, indicator re-add) has completed. + state = _wait_for_data_settled(widget, _matches) + if state is not None: + result["confirmed"] = True + # Identity fields only — no bars/raw data (the agent would + # paraphrase them into fabricated "last close: $..." text). + result.update(_minimal_confirm_state(state)) + else: + result["confirmed"] = False + result["reason"] = ( + f"Search for '{query}' did not land on the chart within the " + "timeout. No matching symbol was found or the chart is still " + "loading data." + ) + return result + + +def _build_compare_payload(ctx: HandlerContext) -> dict[str, Any]: + """Assemble the ``tvchart:compare`` event payload from handler args.""" + payload: dict[str, Any] = {} + query = ctx.args.get("query") + if query: + payload["query"] = query + payload["autoAdd"] = ctx.args.get("auto_add", True) + for src, dst in ( + ("chart_id", "chartId"), + ("symbol_type", "symbolType"), + ("exchange", "exchange"), + ): + value = ctx.args.get(src) + if value is not None and value != "": + payload[dst] = value + return payload + + +def _snapshot_compare_set(widget: Any) -> set[str]: + """Return the upper-cased tickers currently in ``state.compareSymbols``.""" + state = _fetch_tvchart_state(widget, timeout=0.6) or {} + compares = state.get("compareSymbols") or {} + if not isinstance(compares, dict): + return set() + return {str(s).upper() for s in compares.values()} + + +def _handle_tvchart_compare(ctx: HandlerContext) -> HandlerResult: + widget_id = ctx.args.get("widget_id") + resolved_id, error = _resolve_widget_id(widget_id) + if error is not None or resolved_id is None: + return error or {"error": "widget_id could not be resolved."} + widget = get_widget(resolved_id) + if not widget: + return {"error": f"Widget not found: {resolved_id}."} + + payload = _build_compare_payload(ctx) + query = ctx.args.get("query") + auto_add = ctx.args.get("auto_add", True) + + # Snapshot existing compares so fuzzy adds ("microsoft" → "MSFT") + # register as success. Only needed when we're actually going to + # confirm a mutation. + will_confirm = bool(query) and bool(auto_add) + pre_compare_set = _snapshot_compare_set(widget) if will_confirm else set() + + widget.emit("tvchart:compare", payload) + + result: HandlerResult = { + "widget_id": resolved_id, + "event_sent": True, + "event_type": "tvchart:compare", + } + if not will_confirm: + # Just opened the dialog for the user — no mutation to confirm. + return result + + target = str(query).upper() + target_bare = target.rsplit(":", maxsplit=1)[-1].strip() if ":" in target else target + + accepted_tickers = {target, target_bare} + + def _matches(state: dict[str, Any]) -> bool: + compares = state.get("compareSymbols") or {} + if not isinstance(compares, dict): + return False + current_set = {str(s).upper() for s in compares.values()} + # Exact or bare-ticker match on the newly-added compare. + if any(sym in accepted_tickers for sym in current_set): + return True + # Fuzzy match: any new compare that wasn't there before counts + # — "microsoft" committing as "MSFT" is still success. + return bool(current_set - pre_compare_set) + + # Block until the frontend emits tvchart:data-settled with the new + # compare in state — that fires AFTER the compare series is added + # and all post-mutation work has completed. Compare chains search + # → resolve → data-request → response → series add, so give it a + # generous window. + state = _wait_for_data_settled(widget, _matches, timeout=12.0) + if state is not None: + result["confirmed"] = True + result.update(_minimal_confirm_state(state)) + else: + result["confirmed"] = False + result["reason"] = ( + f"Compare symbol '{target}' did not land on the chart within the " + "timeout. No matching symbol was found or data is still loading." + ) + return result + + +def _handle_tvchart_change_interval(ctx: HandlerContext) -> HandlerResult: + widget_id = ctx.args.get("widget_id") + resolved_id, error = _resolve_widget_id(widget_id) + if error is not None or resolved_id is None: + return error or {"error": "widget_id could not be resolved."} + widget = get_widget(resolved_id) + if not widget: + return {"error": f"Widget not found: {resolved_id}."} + + value = ctx.args.get("value") + payload: dict[str, Any] = {"value": value} + chart_id = ctx.args.get("chart_id") + if chart_id is not None: + payload["chartId"] = chart_id + payload = {k: v for k, v in payload.items() if v is not None} + widget.emit("tvchart:interval-change", payload) + + result: HandlerResult = { + "widget_id": resolved_id, + "event_sent": True, + "event_type": "tvchart:interval-change", + } + if not value: + return result + + target = str(value).strip() + + def _matches(state: dict[str, Any]) -> bool: + current = str(state.get("interval") or "").strip() + if not current: + return False + + # Frontend may report "1D" where caller asked "D"; normalise both + # to a canonical comparison (strip leading "1" for a lone digit). + def _norm(s: str) -> str: + s = s.upper() + return s[1:] if s.startswith("1") and len(s) > 1 else s + + return _norm(current) == _norm(target) + + # Block until the frontend emits tvchart:data-settled reflecting + # the new interval — that fires AFTER the destroy-recreate and all + # post-mutation work has completed. + state = _wait_for_data_settled(widget, _matches) + if state is not None: + result["confirmed"] = True + result.update(_minimal_confirm_state(state)) + else: + result["confirmed"] = False + result["reason"] = f"Interval did not change to '{target}' within the timeout." + return result + + +def _handle_tvchart_set_visible_range(ctx: HandlerContext) -> HandlerResult: + return _emit_zoom_and_confirm( + ctx, + "tvchart:time-scale", + { + "visibleRange": { + "from": ctx.args.get("from_time"), + "to": ctx.args.get("to_time"), + }, + }, + ) + + +def _handle_tvchart_fit_content(ctx: HandlerContext) -> HandlerResult: + return _emit_zoom_and_confirm(ctx, "tvchart:time-scale", {"fitContent": True}) + + +def _handle_tvchart_time_range(ctx: HandlerContext) -> HandlerResult: + return _emit_zoom_and_confirm( + ctx, + "tvchart:time-range", + {"value": ctx.args.get("value")}, + ) + + +def _emit_zoom_and_confirm( + ctx: HandlerContext, + event_type: str, + payload: dict[str, Any], +) -> HandlerResult: + """Emit a zoom/range mutation and wait for the frontend's confirmation. + + The frontend emits ``tvchart:data-settled`` synchronously after + applying the timeScale/range call — that's the + confirm-operation-complete signal. + + No state polling: register the listener, emit the event, block on + ``threading.Event`` until the settled event fires (or timeout), + return the payload the frontend sent. If the event never fires + (frontend dropped it because the value was invalid, the chart was + mid-rebuild, etc.), we report ``confirmed: false`` rather than + silently claiming success. + """ + widget_id = ctx.args.get("widget_id") + resolved_id, error = _resolve_widget_id(widget_id) + if error is not None or resolved_id is None: + return error or {"error": "widget_id could not be resolved."} + widget = get_widget(resolved_id) + if not widget: + return {"error": f"Widget not found: {resolved_id}."} + + merged = {k: v for k, v in payload.items() if v is not None} + chart_id = ctx.args.get("chart_id") + if chart_id is not None: + merged["chartId"] = chart_id + widget.emit(event_type, merged) + + # Accept any settled event — the frontend emits one + # synchronously right after applying the zoom/range, so the + # first event on the bus IS the confirmation for this mutation. + state = _wait_for_data_settled(widget, lambda _s: True, timeout=4.0) + result: HandlerResult = { + "widget_id": resolved_id, + "event_sent": True, + "event_type": event_type, + } + if state is not None: + result["confirmed"] = True + result.update(_minimal_confirm_state(state)) + new_range = state.get("visibleRange") or state.get("visibleLogicalRange") + if new_range: + result["visibleRange"] = new_range + else: + result["confirmed"] = False + result["reason"] = ( + "Zoom/range change did not land on the chart within the " + "timeout — verify the ``value`` argument matches one of " + "the accepted presets (1D, 1W, 1M, 3M, 6M, 1Y, 5Y, YTD)." + ) + return result + + +def _handle_tvchart_time_range_picker(ctx: HandlerContext) -> HandlerResult: + return _emit_tvchart(ctx, "tvchart:time-range-picker", {}) + + +def _handle_tvchart_log_scale(ctx: HandlerContext) -> HandlerResult: + return _emit_tvchart( + ctx, + "tvchart:log-scale", + {"value": bool(ctx.args.get("value"))}, + ) + + +def _handle_tvchart_auto_scale(ctx: HandlerContext) -> HandlerResult: + return _emit_tvchart( + ctx, + "tvchart:auto-scale", + {"value": bool(ctx.args.get("value"))}, + ) + + +def _handle_tvchart_chart_type(ctx: HandlerContext) -> HandlerResult: + return _emit_tvchart( + ctx, + "tvchart:chart-type-change", + { + "value": ctx.args.get("value"), + "seriesId": ctx.args.get("series_id"), + }, + ) + + +def _handle_tvchart_drawing_tool(ctx: HandlerContext) -> HandlerResult: + mode = str(ctx.args.get("mode", "")).lower() + event_map = { + "cursor": "tvchart:tool-cursor", + "crosshair": "tvchart:tool-crosshair", + "magnet": "tvchart:tool-magnet", + "eraser": "tvchart:tool-eraser", + "visibility": "tvchart:tool-visibility", + "lock": "tvchart:tool-lock", + } + event_type = event_map.get(mode) + if event_type is None: + return {"error": f"Unknown drawing tool mode: {mode}"} + return _emit_tvchart(ctx, event_type, {}) + + +def _handle_tvchart_undo(ctx: HandlerContext) -> HandlerResult: + return _emit_tvchart(ctx, "tvchart:undo", {}) + + +def _handle_tvchart_redo(ctx: HandlerContext) -> HandlerResult: + return _emit_tvchart(ctx, "tvchart:redo", {}) + + +def _handle_tvchart_show_settings(ctx: HandlerContext) -> HandlerResult: + return _emit_tvchart(ctx, "tvchart:show-settings", {}) + + +def _handle_tvchart_toggle_dark_mode(ctx: HandlerContext) -> HandlerResult: + return _emit_tvchart( + ctx, + "tvchart:toggle-dark-mode", + {"value": bool(ctx.args.get("value"))}, + ) + + +def _handle_tvchart_screenshot(ctx: HandlerContext) -> HandlerResult: + return _emit_tvchart(ctx, "tvchart:screenshot", {}) + + +def _handle_tvchart_fullscreen(ctx: HandlerContext) -> HandlerResult: + return _emit_tvchart(ctx, "tvchart:fullscreen", {}) + + +def _handle_tvchart_save_layout(ctx: HandlerContext) -> HandlerResult: + return _emit_tvchart( + ctx, + "tvchart:save-layout", + {"name": ctx.args.get("name")}, + ) + + +def _handle_tvchart_open_layout(ctx: HandlerContext) -> HandlerResult: + return _emit_tvchart(ctx, "tvchart:open-layout", {}) + + +def _handle_tvchart_save_state(ctx: HandlerContext) -> HandlerResult: + widget_id = ctx.args.get("widget_id") + widget, error = _get_widget_or_error(widget_id) + if error is not None or widget is None: + return error or {"error": "widget_id could not be resolved."} + widget.emit("tvchart:save-state", {}) + return {"widget_id": widget_id, "event_sent": True, "event_type": "tvchart:save-state"} + + +def _handle_tvchart_request_state(ctx: HandlerContext) -> HandlerResult: + widget_id = ctx.args.get("widget_id") + widget, error = _get_widget_or_error(widget_id) + if error is not None or widget is None: + return error or {"error": "widget_id could not be resolved."} + payload: dict[str, Any] = {} + chart_id = ctx.args.get("chart_id") + if chart_id is not None: + payload["chartId"] = chart_id + response = request_response( + widget, + "tvchart:request-state", + "tvchart:state-response", + payload, + timeout=float(ctx.args.get("timeout", 5.0)), + ) + if response is None: + return {"widget_id": widget_id, "error": "State request timed out"} + # Strip the correlation token before returning. + response.pop("context", None) + return {"widget_id": widget_id, "state": response} + + def _handle_set_content(ctx: HandlerContext) -> HandlerResult: - widget_id = ctx.args["widget_id"] + widget_id = ctx.args.get("widget_id") widget, error = _get_widget_or_error(widget_id) - if error: - return error - assert widget is not None # Type guard + if error is not None or widget is None: + return error or {"error": "widget_id could not be resolved."} data = {"id": ctx.args["component_id"]} if "html" in ctx.args: @@ -399,11 +1233,10 @@ def _handle_set_content(ctx: HandlerContext) -> HandlerResult: def _handle_set_style(ctx: HandlerContext) -> HandlerResult: - widget_id = ctx.args["widget_id"] + widget_id = ctx.args.get("widget_id") widget, error = _get_widget_or_error(widget_id) - if error: - return error - assert widget is not None + if error is not None or widget is None: + return error or {"error": "widget_id could not be resolved."} widget.emit( "pywry:set-style", @@ -413,11 +1246,10 @@ def _handle_set_style(ctx: HandlerContext) -> HandlerResult: def _handle_show_toast(ctx: HandlerContext) -> HandlerResult: - widget_id = ctx.args["widget_id"] + widget_id = ctx.args.get("widget_id") widget, error = _get_widget_or_error(widget_id) - if error: - return error - assert widget is not None + if error is not None or widget is None: + return error or {"error": "widget_id could not be resolved."} widget.emit( "pywry:alert", @@ -431,22 +1263,20 @@ def _handle_show_toast(ctx: HandlerContext) -> HandlerResult: def _handle_update_theme(ctx: HandlerContext) -> HandlerResult: - widget_id = ctx.args["widget_id"] + widget_id = ctx.args.get("widget_id") widget, error = _get_widget_or_error(widget_id) - if error: - return error - assert widget is not None + if error is not None or widget is None: + return error or {"error": "widget_id could not be resolved."} widget.emit("pywry:update-theme", {"theme": ctx.args["theme"]}) return {"widget_id": widget_id, "theme": ctx.args["theme"]} def _handle_inject_css(ctx: HandlerContext) -> HandlerResult: - widget_id = ctx.args["widget_id"] + widget_id = ctx.args.get("widget_id") widget, error = _get_widget_or_error(widget_id) - if error: - return error - assert widget is not None + if error is not None or widget is None: + return error or {"error": "widget_id could not be resolved."} widget.emit( "pywry:inject-css", @@ -459,33 +1289,30 @@ def _handle_inject_css(ctx: HandlerContext) -> HandlerResult: def _handle_remove_css(ctx: HandlerContext) -> HandlerResult: - widget_id = ctx.args["widget_id"] + widget_id = ctx.args.get("widget_id") widget, error = _get_widget_or_error(widget_id) - if error: - return error - assert widget is not None + if error is not None or widget is None: + return error or {"error": "widget_id could not be resolved."} widget.emit("pywry:remove-css", {"id": ctx.args["style_id"]}) return {"widget_id": widget_id, "css_removed": True} def _handle_navigate(ctx: HandlerContext) -> HandlerResult: - widget_id = ctx.args["widget_id"] + widget_id = ctx.args.get("widget_id") widget, error = _get_widget_or_error(widget_id) - if error: - return error - assert widget is not None + if error is not None or widget is None: + return error or {"error": "widget_id could not be resolved."} widget.emit("pywry:navigate", {"url": ctx.args["url"]}) return {"widget_id": widget_id, "navigating_to": ctx.args["url"]} def _handle_download(ctx: HandlerContext) -> HandlerResult: - widget_id = ctx.args["widget_id"] + widget_id = ctx.args.get("widget_id") widget, error = _get_widget_or_error(widget_id) - if error: - return error - assert widget is not None + if error is not None or widget is None: + return error or {"error": "widget_id could not be resolved."} widget.emit( "pywry:download", @@ -499,11 +1326,10 @@ def _handle_download(ctx: HandlerContext) -> HandlerResult: def _handle_update_plotly(ctx: HandlerContext) -> HandlerResult: - widget_id = ctx.args["widget_id"] + widget_id = ctx.args.get("widget_id") widget, error = _get_widget_or_error(widget_id) - if error: - return error - assert widget is not None + if error is not None or widget is None: + return error or {"error": "widget_id could not be resolved."} fig_dict = json.loads(ctx.args["figure_json"]) @@ -521,11 +1347,10 @@ def _handle_update_plotly(ctx: HandlerContext) -> HandlerResult: def _handle_update_marquee(ctx: HandlerContext) -> HandlerResult: - widget_id = ctx.args["widget_id"] + widget_id = ctx.args.get("widget_id") widget, error = _get_widget_or_error(widget_id) - if error: - return error - assert widget is not None + if error is not None or widget is None: + return error or {"error": "widget_id could not be resolved."} ticker_update = ctx.args.get("ticker_update") if ticker_update: @@ -547,11 +1372,10 @@ def _handle_update_marquee(ctx: HandlerContext) -> HandlerResult: def _handle_update_ticker_item(ctx: HandlerContext) -> HandlerResult: from ..toolbar import TickerItem - widget_id = ctx.args["widget_id"] + widget_id = ctx.args.get("widget_id") widget, error = _get_widget_or_error(widget_id) - if error: - return error - assert widget is not None + if error is not None or widget is None: + return error or {"error": "widget_id could not be resolved."} ticker_item = TickerItem(ticker=ctx.args["ticker"]) event_type, payload = ticker_item.update_payload( @@ -572,17 +1396,31 @@ def _handle_update_ticker_item(ctx: HandlerContext) -> HandlerResult: def _handle_send_event(ctx: HandlerContext) -> HandlerResult: - widget_id = ctx.args["widget_id"] - widget, error = _get_widget_or_error(widget_id) - if error: - return error - assert widget is not None - - widget.emit(ctx.args["event_type"], ctx.args.get("data", {})) + event_type = ctx.args.get("event_type") + if not event_type: + return {"error": "event_type is required (e.g. 'tvchart:symbol-search')."} + widget_id = ctx.args.get("widget_id") + resolved_id, error = _resolve_widget_id(widget_id) + if error is not None or resolved_id is None: + return error or {"error": "widget_id could not be resolved."} + widget = get_widget(resolved_id) + if not widget: + ids = list_widget_ids() + return { + "error": ( + f"Widget not found: {resolved_id}." + + ( + f" Registered widgets: {', '.join(ids)}." + if ids + else " No widgets are registered yet." + ) + ), + } + widget.emit(event_type, ctx.args.get("data") or {}) return { - "widget_id": widget_id, + "widget_id": resolved_id, "event_sent": True, - "event_type": ctx.args["event_type"], + "event_type": event_type, } @@ -600,22 +1438,26 @@ def _handle_list_widgets(ctx: HandlerContext) -> HandlerResult: def _handle_get_events(ctx: HandlerContext) -> HandlerResult: - widget_id = ctx.args["widget_id"] - widget_events = ctx.events.get(widget_id, []) + resolved_id, error = _resolve_widget_id(ctx.args.get("widget_id")) + if error is not None or resolved_id is None: + return error or {"error": "widget_id could not be resolved."} + widget_events = ctx.events.get(resolved_id, []) if ctx.args.get("clear", False): - ctx.events[widget_id] = [] - return {"widget_id": widget_id, "events": widget_events} + ctx.events[resolved_id] = [] + return {"widget_id": resolved_id, "events": widget_events} def _handle_destroy_widget(ctx: HandlerContext) -> HandlerResult: - widget_id = ctx.args["widget_id"] - ctx.events.pop(widget_id, None) - remove_widget(widget_id) + resolved_id, error = _resolve_widget_id(ctx.args.get("widget_id")) + if error is not None or resolved_id is None: + return error or {"error": "widget_id could not be resolved."} + ctx.events.pop(resolved_id, None) + remove_widget(resolved_id) if ctx.headless: from ..inline import _state as inline_state - inline_state.widgets.pop(widget_id, None) - return {"widget_id": widget_id, "destroyed": True} + inline_state.widgets.pop(resolved_id, None) + return {"widget_id": resolved_id, "destroyed": True} # ============================================================================= @@ -644,7 +1486,10 @@ def _handle_get_component_source(ctx: HandlerContext) -> HandlerResult: def _handle_export_widget(ctx: HandlerContext) -> HandlerResult: - widget_id = ctx.args["widget_id"] + resolved_id, error = _resolve_widget_id(ctx.args.get("widget_id")) + if error is not None or resolved_id is None: + return error or {"error": "widget_id could not be resolved."} + widget_id = resolved_id code = export_widget_code(widget_id) if not code: return {"error": f"Widget not found or no config stored: {widget_id}"} @@ -691,7 +1536,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 +1574,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: @@ -803,11 +1644,13 @@ def _handle_create_chat_widget(ctx: HandlerContext) -> HandlerResult: def _handle_chat_send_message(ctx: HandlerContext) -> HandlerResult: - widget_id = ctx.args["widget_id"] - widget, error = _get_widget_or_error(widget_id) - if error: - return error - assert widget is not None + resolved_id, error = _resolve_widget_id(ctx.args.get("widget_id")) + if error is not None or resolved_id is None: + return error or {"error": "widget_id could not be resolved."} + widget, werror = _get_widget_or_error(resolved_id) + if werror is not None or widget is None: + return werror or {"error": f"Widget not found: {resolved_id}."} + widget_id = resolved_id text = ctx.args["text"] thread_id = ctx.args.get("thread_id") @@ -847,7 +1690,10 @@ def _handle_chat_send_message(ctx: HandlerContext) -> HandlerResult: def _handle_chat_stop_generation(ctx: HandlerContext) -> HandlerResult: - widget_id = ctx.args["widget_id"] + resolved_id, error = _resolve_widget_id(ctx.args.get("widget_id")) + if error is not None or resolved_id is None: + return error or {"error": "widget_id could not be resolved."} + widget_id = resolved_id thread_id = ctx.args.get("thread_id") widget_gens = _active_generations.get(widget_id, {}) @@ -885,15 +1731,17 @@ def _handle_chat_stop_generation(ctx: HandlerContext) -> HandlerResult: def _handle_chat_manage_thread(ctx: HandlerContext) -> HandlerResult: - widget_id = ctx.args["widget_id"] + resolved_id, error = _resolve_widget_id(ctx.args.get("widget_id")) + if error is not None or resolved_id is None: + return error or {"error": "widget_id could not be resolved."} + widget_id = resolved_id action = ctx.args["action"] thread_id = ctx.args.get("thread_id") title = ctx.args.get("title", "New Chat") - widget, error = _get_widget_or_error(widget_id) - if error: - return error - assert widget is not None + widget, werror = _get_widget_or_error(widget_id) + if werror is not None or widget is None: + return werror or {"error": f"Widget not found: {widget_id}."} handlers = { "create": _thread_create, @@ -973,14 +1821,13 @@ def _thread_list( def _handle_chat_register_command(ctx: HandlerContext) -> HandlerResult: - widget_id = ctx.args["widget_id"] + widget_id = ctx.args.get("widget_id") name = ctx.args["name"] description = ctx.args.get("description", "") widget, error = _get_widget_or_error(widget_id) - if error: - return error - assert widget is not None + if error is not None or widget is None: + return error or {"error": "widget_id could not be resolved."} if not name.startswith("/"): name = "/" + name @@ -997,7 +1844,10 @@ def _handle_chat_register_command(ctx: HandlerContext) -> HandlerResult: def _handle_chat_get_history(ctx: HandlerContext) -> HandlerResult: - widget_id = ctx.args["widget_id"] + resolved_id, error = _resolve_widget_id(ctx.args.get("widget_id")) + if error is not None or resolved_id is None: + return error or {"error": "widget_id could not be resolved."} + widget_id = resolved_id thread_id = ctx.args.get("thread_id") limit = ctx.args.get("limit", 50) before_id = ctx.args.get("before_id") @@ -1027,11 +1877,10 @@ def _handle_chat_get_history(ctx: HandlerContext) -> HandlerResult: def _handle_chat_update_settings(ctx: HandlerContext) -> HandlerResult: - widget_id = ctx.args["widget_id"] + widget_id = ctx.args.get("widget_id") widget, error = _get_widget_or_error(widget_id) - if error: - return error - assert widget is not None + if error is not None or widget is None: + return error or {"error": "widget_id could not be resolved."} settings: dict[str, Any] = {} for key in ("model", "temperature", "max_tokens", "system_prompt", "streaming"): @@ -1045,11 +1894,10 @@ def _handle_chat_update_settings(ctx: HandlerContext) -> HandlerResult: def _handle_chat_set_typing(ctx: HandlerContext) -> HandlerResult: - widget_id = ctx.args["widget_id"] + widget_id = ctx.args.get("widget_id") widget, error = _get_widget_or_error(widget_id) - if error: - return error - assert widget is not None + if error is not None or widget is None: + return error or {"error": "widget_id could not be resolved."} typing = ctx.args.get("typing", True) thread_id = ctx.args.get("thread_id") @@ -1072,6 +1920,39 @@ def _handle_chat_set_typing(ctx: HandlerContext) -> HandlerResult: "show_plotly": _handle_show_plotly, "show_dataframe": _handle_show_dataframe, "show_tvchart": _handle_show_tvchart, + # TVChart — first-class tools for every chart operation + "tvchart_update_series": _handle_tvchart_update_series, + "tvchart_update_bar": _handle_tvchart_update_bar, + "tvchart_add_series": _handle_tvchart_add_series, + "tvchart_remove_series": _handle_tvchart_remove_series, + "tvchart_add_markers": _handle_tvchart_add_markers, + "tvchart_add_price_line": _handle_tvchart_add_price_line, + "tvchart_apply_options": _handle_tvchart_apply_options, + "tvchart_add_indicator": _handle_tvchart_add_indicator, + "tvchart_remove_indicator": _handle_tvchart_remove_indicator, + "tvchart_list_indicators": _handle_tvchart_list_indicators, + "tvchart_show_indicators": _handle_tvchart_show_indicators, + "tvchart_symbol_search": _handle_tvchart_symbol_search, + "tvchart_compare": _handle_tvchart_compare, + "tvchart_change_interval": _handle_tvchart_change_interval, + "tvchart_set_visible_range": _handle_tvchart_set_visible_range, + "tvchart_fit_content": _handle_tvchart_fit_content, + "tvchart_time_range": _handle_tvchart_time_range, + "tvchart_time_range_picker": _handle_tvchart_time_range_picker, + "tvchart_log_scale": _handle_tvchart_log_scale, + "tvchart_auto_scale": _handle_tvchart_auto_scale, + "tvchart_chart_type": _handle_tvchart_chart_type, + "tvchart_drawing_tool": _handle_tvchart_drawing_tool, + "tvchart_undo": _handle_tvchart_undo, + "tvchart_redo": _handle_tvchart_redo, + "tvchart_show_settings": _handle_tvchart_show_settings, + "tvchart_toggle_dark_mode": _handle_tvchart_toggle_dark_mode, + "tvchart_screenshot": _handle_tvchart_screenshot, + "tvchart_fullscreen": _handle_tvchart_fullscreen, + "tvchart_save_layout": _handle_tvchart_save_layout, + "tvchart_open_layout": _handle_tvchart_open_layout, + "tvchart_save_state": _handle_tvchart_save_state, + "tvchart_request_state": _handle_tvchart_request_state, # Widget Manipulation "set_content": _handle_set_content, "set_style": _handle_set_style, @@ -1109,6 +1990,37 @@ def _handle_chat_set_typing(ctx: HandlerContext) -> HandlerResult: # ============================================================================= # Main Entry Point # ============================================================================= +def _check_required_args(name: str, args: dict[str, Any]) -> str | None: + """Return an error for any schema-required args the caller omitted. + + The per-tool ``inputSchema.required`` list is the canonical source. + Without this gate a handler would silently emit an event with + ``None`` values (e.g. ``tvchart_change_interval`` with no ``value`` + still fires ``tvchart:interval-change`` with an empty payload; the + frontend drops it and the agent sees ``event_sent: true`` and + assumes the chart changed). ``widget_id`` is deliberately excluded + here — ``_resolve_widget_id`` auto-fills it from the sole registered + widget on single-widget servers. + """ + from .tools import get_tools + + for tool in get_tools(): + if tool.name != name: + continue + required = tool.inputSchema.get("required", []) or [] + missing = [ + p for p in required if p != "widget_id" and (args.get(p) is None or args.get(p) == "") + ] + if missing: + return ( + f"Missing required argument(s) for {name}: " + f"{', '.join(missing)}. Re-invoke the tool with all " + "required fields populated." + ) + return None + return None + + async def handle_tool( name: str, args: dict[str, Any], @@ -1136,6 +2048,10 @@ async def handle_tool( headless = os.environ.get("PYWRY_HEADLESS", "0") == "1" ctx = HandlerContext(args, events, make_callback, headless) + error_msg = _check_required_args(name, args) + if error_msg: + return {"error": error_msg} + handler = _HANDLERS.get(name) if handler: return handler(ctx) diff --git a/pywry/pywry/mcp/server.py b/pywry/pywry/mcp/server.py index 77c71cd..bbca862 100644 --- a/pywry/pywry/mcp/server.py +++ b/pywry/pywry/mcp/server.py @@ -83,18 +83,23 @@ def callback(data: Any, event_type: str, label: str = "") -> None: def _create_tool_function( tool_name: str, schema: dict[str, Any], handle_tool: Any, events: EventsDict ) -> Callable[..., Any]: - """Dynamically create a function with the right signature for the tool schema.""" + """Dynamically create a function with the right signature for the tool schema. + + Every JSON-schema property becomes a keyword parameter with a + ``=None`` default. The server auto-resolves missing arguments + from registered state (e.g. filling ``widget_id`` from the sole + registered widget), so a model that forgets to pass ``widget_id`` + on a single-widget server still gets a successful call instead + of a validation error the user has to recover from manually. + + The hand-written JSON schema's ``required`` array is still used + as documentation — it appears in FastMCP's tool description so + callers know which arguments matter — but enforcement happens + in the handler where we have the registry context. + """ properties = schema.get("properties", {}) - required = set(schema.get("required", [])) - - # Build function parameters - params = [] - for prop_name in properties: - if prop_name in required: - params.append(f"{prop_name}=None") # Will be validated by MCP - else: - params.append(f"{prop_name}=None") + params = [f"{p}=None" for p in properties] params_str = ", ".join(params) if params else "" # Build the function code diff --git a/pywry/pywry/mcp/skills/__init__.py b/pywry/pywry/mcp/skills/__init__.py index 1accf8a..4b9316d 100644 --- a/pywry/pywry/mcp/skills/__init__.py +++ b/pywry/pywry/mcp/skills/__init__.py @@ -5,14 +5,23 @@ Available Skills ---------------- +- component_reference: Authoritative reference for every widget/event signature +- interactive_buttons: Auto-wired button callback patterns +- autonomous_building: End-to-end autonomous widget building - native: Desktop window via PyWry/WRY/Tauri (Rust WebView) - jupyter: Inline widgets in Jupyter notebook cells (iframe in cell output) - iframe: Embedded widgets in external web pages - deploy: Production multi-user SSE server +- authentication: OAuth2 / OIDC sign-in and RBAC for PyWry apps - css_selectors: Targeting elements for updates - styling: Theme variables and CSS customization - data_visualization: Charts, tables, live data patterns - forms_and_inputs: User input collection and validation +- modals: Overlay dialogs (settings, confirmations, forms) +- chat: Creating chat widgets (widget-builder perspective) +- chat_agent: Operating inside a chat widget (agent perspective) +- tvchart: Driving a TradingView chart via MCP tools (agent perspective) +- events: PyWry event bus, request/response round-trips, tool-result flow """ from __future__ import annotations @@ -54,6 +63,10 @@ "name": "Production Deploy Mode", "description": "Multi-user SSE server for production deployments", }, + "authentication": { + "name": "Authentication & OAuth2", + "description": "Add OAuth2 / OIDC sign-in (Google, GitHub, Microsoft, custom) and RBAC to PyWry apps", + }, "css_selectors": { "name": "CSS Selectors", "description": "Targeting elements with selectors for set_content/set_style", @@ -78,6 +91,18 @@ "name": "Chat Component", "description": "Conversational chat widget with streaming, threads, slash commands, stop-generation, and LLM provider integration", }, + "chat_agent": { + "name": "Chat Agent Operating Manual", + "description": "How an agent operates INSIDE a running chat widget: reading @-context attachments, widget_id routing, tool-result cards, edit/resend flow, reply style", + }, + "tvchart": { + "name": "TradingView Chart — Agent Reference", + "description": "Drive a live tvchart widget through MCP: symbol/interval/chart-type, indicators (including compare-derivative Spread/Ratio/Sum/Product/Correlation), markers, price lines, compares, drawings, layouts, state reads", + }, + "events": { + "name": "PyWry Event System", + "description": "Namespaced events, widget_id vs componentId, request/response correlation via context token, how mutating tools poll state, get_events capture buffer", + }, } @@ -140,7 +165,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/mcp/skills/chat_agent/SKILL.md b/pywry/pywry/mcp/skills/chat_agent/SKILL.md new file mode 100644 index 0000000..39fe6a9 --- /dev/null +++ b/pywry/pywry/mcp/skills/chat_agent/SKILL.md @@ -0,0 +1,189 @@ +--- +description: How an agent operates inside a PyWry chat widget — reading user messages, attachments, @-context, tool-call result cards, edit/resend, settings changes. +--- + +# Chat — Agent Operating Manual + +> **You are running INSIDE a PyWry chat widget.** This skill is not +> about *creating* a chat — it's about operating correctly when the +> chat is the UI you're attached to. + +## Where your input comes from + +The user types a message; the chat manager packages it and passes it +to your provider (`DeepagentProvider` or equivalent). You receive: + +- **text** — the user's literal message +- **attachments** — any `@` context the user inlined, expanded + into a block prepended to the message +- **thread history** — the running conversation stored against a + `session_id` / `thread_id` keyed checkpointer + +Your reply is streamed token-by-token into the UI. Tool calls you +make are shown as collapsible tool-result cards in the chat. + +## The `@` attachment format + +When the user types `@chart` (or any other registered context +source), the chat manager prepends a block to the message like: + +``` +--- Attached: chart --- +widget_id: chart +<...any additional component context...> +--- End Attached --- + + +``` + +The first line after the marker is ALWAYS `widget_id: ` for +widget attachments. Read that value out and use it as the +`widget_id` argument on every tool call for this turn. Never +guess — the attachment is the source of truth. + +If the user references a widget without attaching it, either: + +1. Call `list_widgets()` to look it up by name. +2. Ask the user to attach it (`"Type @chart so I know which widget + you mean."`). + +Do NOT invent a widget_id. + +## Auto-attached context sources + +Some examples register context sources that get auto-attached to +every user message. In that case you'll see the `--- Attached ---` +block even when the user didn't explicitly type `@`. Treat +it the same way — read `widget_id` and use it. + +## Tool-call result cards + +Every tool call you make is rendered in the chat as a card showing: + +- Tool name (e.g. `tvchart_symbol_search`) +- Status — spinner while running, ✓ on success, ✗ on failure +- Collapsible payload: arguments in, result out + +The user sees this UI. That means: + +- **Don't repeat tool output as prose.** If the tool returned the + new symbol, saying "I called tvchart_symbol_search with query=MSFT + and it returned MSFT" is noise — the card already shows it. + Short confirmation ("Switched to MSFT.") is enough. +- **Don't fabricate pseudo-tool output in prose.** Never write + markdown like "Updated Chart State: { symbol: ..., lastUpdated: + ... }" — the user will read it as if it came from a tool, and it + didn't. Call the tool. + +## Settings changes + +The chat panel has a settings menu. When the user changes a setting +(model, temperature, etc.), your provider's `on_settings_change` +callback fires. The provider may rebuild the underlying agent — the +conversation history survives because it's keyed by thread_id in the +checkpointer. + +As the agent, you don't invoke settings changes yourself; the UI +does. Just continue the conversation across the rebuild. + +## Edit and resend + +The user can click "Edit" on their own prior message to rewrite it, +or "Resend" to re-fire a prior message with the current state. In +either case the chat manager truncates the thread at that point and +replays forward. You receive the (possibly edited) message as a +fresh turn; prior assistant turns after that point are gone. + +## Multi-step work — ALWAYS use `write_todos` + +If the user's message asks for two or more distinct actions (e.g. +"switch to MSFT and go weekly", "add a 50 SMA and a 200 SMA"), +follow this flow: + +1. Call `write_todos` with one entry per action, all in `pending` + status. This renders as a plan card above the chat input. + +2. For each step in order, issue BOTH tool calls in the SAME + model response (parallel tool calls on one assistant message): + - the tool for the step, AND + - `write_todos` with that step flipped to `completed`, every + prior step kept `completed`, every remaining step kept + `pending`. + + Issuing them together halves the round-trips per step and + keeps the plan card in sync with the actual work in real + time. Do NOT split them across two turns. + +3. After the last step's parallel `tool + write_todos` response + has returned, reply with ONE sentence summarising the final + state. + +You MUST complete every step in the SAME turn. Do not stop after +the first tool call. Do not emit a summary reply before every +`pending` step is `completed`. + +### Error handling — FAIL FAST + +If a tool returns `confirmed: false` or an `error`, STOP THE PLAN. +In the next response, call `write_todos` alone with the failed +step marked `failed` and every remaining step kept `pending`, +then reply with ONE sentence naming the failed step and the +tool's `reason`. Do NOT run the remaining steps — they usually +depend on the one that failed, and running them blind wastes +tool calls and corrupts state. + +Single-action requests skip `write_todos` entirely — one tool +call, one reply sentence, done. + +## Reply style — terse, direct, no ceremony + +These rules are load-bearing. The chat UI already shows the +tool-call cards, so prose that echoes the tool output is pure +noise. + +- **One or two sentences.** Report what happened. "Added SPY as + a compare series." "Switched to MSFT on the weekly." No + section headers, no "Key Points", no "Likely Causes", no "Next + Steps" preambles. +- **Call tools through the protocol, never as text.** Writing + `tvchart_request_state(widget_id="chart")` in your reply is a + hallucinated tool call — it does nothing. If you want to call a + tool, invoke it. +- **No A/B/C multi-choice prompts.** If you genuinely need input, + ask one plain-English question. If a retry is obvious, retry — + don't ask permission. +- **Don't restate tool arguments back to the user.** The card + shows them. Saying "Widget ID: chart (matches your attachment)" + is filler. +- **Don't speculate about failure modes.** If a mutation tool + returned a `note`, relay it in one sentence. Don't paste a + troubleshooting guide. Don't spin three hypotheses. +- **No pseudo-JSON blocks, no tables, no "Response Format: choose + one" footers.** Plain sentences. +- **Numbers and state only from actual tool returns in this + turn.** No recall from memory, no fabricated `lastUpdated` + timestamps, no invented error codes. +- **Relay `note` fields literally**, but one sentence — never a + paragraph of interpretation. + +## Thread and session lifecycle + +The provider threads conversation history through a LangGraph +checkpointer. Each chat session has a `thread_id`; messages are +appended; you can recall prior messages by reading the state. You +don't need to manage this — just reply to the current turn. + +When the user clicks "Clear History" (a standard settings action), +the thread is truncated. Don't reference content from before the +truncation — you won't have it. + +## Don'ts + +- Don't invent `widget_id` values. +- Don't summarise tool output as prose when the UI already shows + the card. +- Don't produce pseudo-JSON "state" blocks in replies. +- Don't reply with "Tool Call: tvchart_symbol_search(...)" as text + — invoke it through the tool-calling protocol. +- Don't assume settings-change means start over — the thread + persists. diff --git a/pywry/pywry/mcp/skills/events/SKILL.md b/pywry/pywry/mcp/skills/events/SKILL.md new file mode 100644 index 0000000..8d9f655 --- /dev/null +++ b/pywry/pywry/mcp/skills/events/SKILL.md @@ -0,0 +1,151 @@ +--- +description: The PyWry event system — namespaced events, request/response round-trips, widget IDs, component IDs, and how tool results flow back to the agent. +--- + +# PyWry Event System — Agent Reference + +> **The event bus is the plumbing underneath every MCP tool.** You +> rarely need to think about it — the typed tools wrap emit + wait + +> state-poll for you — but when you reach for `send_event` or +> interpret tool results, this is how it works. + +## Event names are namespaced + +Every event has the form `namespace:event-name`, e.g.: + +- `tvchart:symbol-search` — ask the chart to open symbol search +- `tvchart:state-response` — chart's reply with its current state +- `tvchart:data-request` — chart asks Python for bars +- `tvchart:data-response` — Python delivers bars +- `toolbar:request-state` — ask a toolbar component for its value +- `toolbar:state-response` — component's reply +- `chat:user-message` — user typed something +- `chat:ai-response` — model produced a token +- `pywry:update-theme` — dark/light mode change + +Never emit an event with a name that doesn't match `namespace:event-name` +— the framework rejects it. + +## Widget IDs vs component IDs + +**widget_id** — identifies the top-level PyWry widget (a chart, a grid, +a chat panel, a dashboard). Every MCP tool takes `widget_id` as an +argument because all events route to the widget first. + +**componentId** — identifies a child *inside* a widget (a specific +toolbar button, a marquee ticker slot, a chart pane). Component IDs +are scoped to their containing widget. + +When you call `send_event(widget_id, event_type, data)`, the +`widget_id` picks the target widget; anything identifying a specific +component goes in the `data` payload (typically as `data.componentId` +or `data.chartId`). + +## Request / response pattern + +Some events are fire-and-forget (e.g. `tvchart:symbol-search` — +"please do this"). Others are request/response round-trips where the +caller wants a reply (e.g. `tvchart:request-state` → `tvchart:state-response`). + +The framework correlates request/response with a `context` token: + +1. Emitter generates a random `context` token. +2. Emitter injects it into the request payload. +3. Listener sees the request, attaches the same `context` to its + response, and emits the response event. +4. Emitter sees the matching `context` on the response and wakes up. + +All of this is handled inside `request_response()` in +`pywry.mcp.state` — you never construct tokens yourself. Typed MCP +tools that need a reply (`tvchart_request_state`, +`tvchart_list_indicators`) use this under the hood and return the +stripped response (no `context` token) in their tool result. + +## How tool results reach the agent + +``` +Agent MCP Server PyWry Widget (JS) + │ │ │ + │ tool call ──────► │ │ + │ │ widget.emit() ──────► │ + │ │ │ (updates chart) + │ │ │ + │ │ ◄───── bridge.emit() │ + │ │ (state-response) │ + │ │ │ + │ ◄──── tool result │ │ + │ (includes state) │ │ +``` + +Mutating tools (`tvchart_symbol_search`, `tvchart_change_interval`) +poll `tvchart:request-state` after emitting the mutation, wait for the +chart to actually reflect the change, and return the real post-change +state in the tool result. The `state` field in the tool result +contains the SAME structure as a direct call to +`tvchart_request_state`. + +If the mutation didn't settle in time, the result contains a `note` +field explaining the discrepancy — relay the note to the user, do +not invent state. + +## Emitting events from tools — `send_event` + +Only reach for `send_event` when no typed MCP tool exists for the +target event. It's a raw passthrough with no state polling: + +``` +send_event(widget_id, event_type, data) + → { "widget_id": ..., "event_sent": true, "event_type": ... } +``` + +Example — apply a rare tvchart option with no typed wrapper: + +``` +send_event( + widget_id="chart", + event_type="tvchart:apply-options", + data={"chartOptions": {"timeScale": {"secondsVisible": False}}}, +) +``` + +The returned `event_sent: true` means the event was successfully +handed to the widget — it does NOT mean the JS handler ran +successfully. For confirmation, follow up with +`tvchart_request_state` to read the new state. + +## Event capture (`get_events`) + +Some events fire from the widget *to* Python (e.g. the user clicked a +bar, moved the crosshair). These are automatically captured into a +per-widget event buffer and can be retrieved with: + +``` +get_events(widget_id, event_types=[...], clear=True) + → { "events": [{ "event_type": ..., "data": ..., "label": ... }, ...] } +``` + +Default captured events for charts: + +- `tvchart:click` +- `tvchart:crosshair-move` +- `tvchart:visible-range-change` +- `tvchart:drawing-added` +- `tvchart:drawing-deleted` +- `tvchart:open-layout-request` +- `tvchart:interval-change` +- `tvchart:chart-type-change` + +Use `get_events` if the user asks "what did I just click" or "what +was the last drawing I added". + +## Don'ts + +- Do NOT synthesise event payloads. Only report event data the + framework actually handed you. +- Do NOT emit events whose name doesn't match `ns:name` — they're + rejected. +- Do NOT emit to a widget id that isn't registered — the tool will + return an error listing the registered widgets; correct and retry. +- Do NOT assume `event_sent: true` means the downstream JS succeeded. + When it matters, follow up with `tvchart_request_state` (or the + relevant state query) to confirm. diff --git a/pywry/pywry/mcp/skills/tvchart/SKILL.md b/pywry/pywry/mcp/skills/tvchart/SKILL.md new file mode 100644 index 0000000..193dd17 --- /dev/null +++ b/pywry/pywry/mcp/skills/tvchart/SKILL.md @@ -0,0 +1,244 @@ +--- +description: Drive a live TradingView Lightweight Charts widget end-to-end via PyWry MCP tools — symbol, interval, indicators, markers, price lines, layouts, state. +--- + +# TradingView Chart — Agent Reference + +> **Use this when an agent needs to read or mutate a live `tvchart` +> widget.** Every action is an MCP tool call on the PyWry FastMCP +> server — there are no local helpers, no side channels, no custom +> tools. Pick the typed tool that matches the user's intent, pass the +> required arguments, and quote the tool's return values in your +> reply. + +## Every tool takes `widget_id` + +`widget_id` identifies which chart to operate on. On a single-chart +server the framework auto-resolves it from the registry; on a +multi-chart server you must pass it explicitly. Read the value from +the user's `@` attachment (the chat prepends `--- Attached: + ---\nwidget_id: `) or call `list_widgets()` to enumerate. + +## Reading chart state — always via `tvchart_request_state` + +Never report symbol / interval / indicators / bars / last close from +memory. Call the tool, quote the return. + +``` +tvchart_request_state(widget_id) + → { + "widget_id": "chart", + "state": { + "symbol": "AAPL", + "interval": "1D", + "series": [{ "seriesId": "main", "bars": [...], ... }], + "indicators": [...], + "visibleRange": { "from": ..., "to": ... }, + "chartType": "Candles", + ... + } + } +``` + +When the user asks "what's on the chart", "what's the current price", +"what indicators are applied", call this and quote from `state`. + +## Mutating tools — all confirm the change + +Every mutation returns the real post-change state. The model never has +to guess whether the change took effect. If the mutation didn't land +within the settle window, the tool includes a `note` field — relay +it to the user. + +### Symbol change + +``` +tvchart_symbol_search(widget_id, query, auto_select=True, + symbol_type=None, exchange=None) + → { "widget_id": "chart", "symbol": "MSFT", "state": {...} } +``` + +Use this to switch the ticker. `auto_select=True` commits the +selection; `auto_select=False` just opens the search dialog for the +user. The tool polls chart state until the symbol actually changes +to the target (up to ~6s) so the return reflects reality. + +Pass `symbol_type` to narrow the datafeed search to a specific +security class — values come from the datafeed, typically `equity`, +`etf`, `index`, `mutualfund`, `future`, `cryptocurrency`, `currency`. +Use it whenever the user's query is ambiguous: `SPY` with +`symbol_type="etf"` resolves to the SPDR S&P 500 ETF instead of +picking a near-prefix equity like `SPYM`. `exchange` narrows to a +specific venue the same way. Both are case-insensitive; unknown +values are silently dropped rather than erroring. + +### Interval / timeframe + +``` +tvchart_change_interval(widget_id, value) + → { "widget_id": "chart", "interval": "1W", "state": {...} } +``` + +Valid values: `1m 3m 5m 15m 30m 45m 1h 2h 3h 4h 1d 1w 1M 3M 6M 12M`. +Tool confirms the change via state polling. + +### Indicators + +``` +tvchart_add_indicator(widget_id, name, period=..., color=..., ...) +tvchart_remove_indicator(widget_id, series_id) +tvchart_list_indicators(widget_id) +``` + +Supported names: `SMA`, `EMA`, `WMA`, `RSI`, `ATR`, `VWAP`, +`Bollinger Bands`, plus the rest of the built-in library. `period` +defaults to a sensible value per indicator; override when the user +asks. + +#### Compare-derivative indicators + +`Spread`, `Ratio`, `Sum`, `Product`, `Correlation` require a +**secondary series** — a second ticker to spread/ratio/etc. against +the main series. The flow is two steps: + +1. Call `tvchart_compare(widget_id, query="")` to add the + secondary ticker as a compare series and confirm it landed in + `state.compareSymbols`. +2. Call `tvchart_add_indicator(widget_id, name="Spread", ...)`. The + chart picks up the most recent compare series as the secondary + automatically; pass `source` / `method` / `multiplier` to tune. + +State reporting for these indicators: + +- `state.indicators[i].type` is `"spread"` / `"ratio"` / etc. +- `state.indicators[i].secondarySeriesId` — the compare seriesId. +- `state.indicators[i].secondarySymbol` — the ticker it resolves to + (this is what the user actually cares about when you describe the + indicator). +- `state.indicatorSourceSymbols` — the compare-series map restricted + to indicator inputs; these are NOT user-facing compares (they're + hidden from the Compare panel). Don't conflate with + `state.compareSymbols` when listing "what's compared on the chart". + +If the user asks "what's on the chart" for a chart with a Spread +against MSFT, quote it as `Spread(AAPL, MSFT)` using the indicator's +`secondarySymbol`, not the raw seriesId. + +### Chart type / rendering + +``` +tvchart_chart_type(widget_id, value) + # value ∈ { "Candles", "Line", "Heikin Ashi", "Bars", "Area" } + +tvchart_log_scale(widget_id, value) # true / false +tvchart_auto_scale(widget_id, value) +``` + +### Visible range / zoom + +``` +tvchart_set_visible_range(widget_id, from_time, to_time) + # times are Unix seconds + +tvchart_fit_content(widget_id) +tvchart_time_range(widget_id, value) # "1D", "5D", "1M", "6M", "YTD", "1Y", "5Y", "All" +tvchart_time_range_picker(widget_id) # opens custom picker UI +``` + +### Markers and price lines + +``` +tvchart_add_markers(widget_id, markers) + # markers = [{ time, position, color, shape, text }, ...] + # position: "aboveBar" | "belowBar" | "inBar" + # shape: "arrowUp" | "arrowDown" | "circle" | "square" + +tvchart_add_price_line(widget_id, price, title="", color="#2196F3", line_width=1) +``` + +Use markers for signals / events on specific bars. Use price lines +for support / resistance / targets (horizontal lines across the whole +chart). + +### Drawing tools + +``` +tvchart_drawing_tool(widget_id, tool) + # tool ∈ { "trendline", "horizontal", "rectangle", "brush", "eraser", "cursor", ... } +``` + +### History and layout + +``` +tvchart_undo(widget_id) +tvchart_redo(widget_id) +tvchart_save_layout(widget_id, name) +tvchart_open_layout(widget_id, name) +tvchart_save_state(widget_id) +``` + +### Misc UI + +``` +tvchart_show_indicators(widget_id) # open indicator panel +tvchart_show_settings(widget_id) +tvchart_screenshot(widget_id) +tvchart_fullscreen(widget_id) +tvchart_toggle_dark_mode(widget_id) +``` + +### Adding a compare overlay + +``` +tvchart_compare(widget_id, query, auto_add=True, + symbol_type=None, exchange=None) + → { "widget_id": "chart", "compareSymbols": { "compare-spy": "SPY" }, + "state": {...} } +``` + +`query` is the ticker to add. Pass `symbol_type` to disambiguate: +`SPY` without it may resolve to `SPYM` (a near-prefix equity); +`symbol_type="etf"` routes it to the SPDR ETF. `exchange` narrows +to a specific venue. Both are case-insensitive and silently +dropped when the datafeed doesn't know the value. + +The tool polls `state.compareSymbols` for up to ~10s (compares +require a full datafeed round-trip) before reporting a `note`. +Calling `tvchart_compare(widget_id)` with no `query` just opens +the dialog for the user — no state confirmation. + +## Series and bar updates (non-datafeed mode) + +Use these only when the chart is NOT in datafeed mode (the datafeed +manages its own streams). In datafeed mode, new symbol / interval +data is fetched automatically via `tvchart:data-request` — don't try +to push bars yourself. + +``` +tvchart_update_series(widget_id, series_id, bars, volume=None) +tvchart_update_bar(widget_id, series_id, bar) # live tick +tvchart_add_series(widget_id, series_id, bars, series_type="Line", series_options={...}) +tvchart_remove_series(widget_id, series_id) +tvchart_apply_options(widget_id, chart_options=..., series_id=..., series_options=...) +``` + +## Last-resort escape hatch + +If no typed tool exists for a specific event, use: + +``` +send_event(widget_id, event_type, data) +``` + +Event types are namespaced `tvchart:`. This is a raw +passthrough — prefer the typed tools above in every other case. + +## Don'ts + +- Do NOT fabricate chart state, bars, or timestamps. Call the tool. +- Do NOT emit "Updated Chart State" or "Chart Update Response" + pseudo-JSON blocks in replies — only quote real tool returns. +- Do NOT guess a `widget_id` — read it from the attachment or call + `list_widgets()`. +- Do NOT call `send_event` when a typed tool already exists for the + event. diff --git a/pywry/pywry/mcp/state.py b/pywry/pywry/mcp/state.py index 292d6c7..0fbf7ed 100644 --- a/pywry/pywry/mcp/state.py +++ b/pywry/pywry/mcp/state.py @@ -6,6 +6,10 @@ from __future__ import annotations +import contextlib +import threading +import uuid + from typing import TYPE_CHECKING, Any @@ -21,6 +25,12 @@ # Widget configurations for export _widget_configs: dict[str, dict[str, Any]] = {} +# Request/response correlation for tools that round-trip an event pair +# through the widget. Keyed by request_id. +_pending_responses: dict[str, dict[str, Any]] = {} +_pending_events: dict[str, threading.Event] = {} +_pending_lock = threading.Lock() + def get_app() -> PyWry: """Get or create the global PyWry app instance. @@ -144,3 +154,123 @@ def remove_widget(widget_id: str) -> bool: _widget_configs.pop(widget_id, None) return True return False + + +# ============================================================================= +# Request/response correlation +# ============================================================================= + + +def request_response( + widget: Any, + request_event: str, + response_event: str, + payload: dict[str, Any], + *, + correlation_key: str = "context", + response_correlation_key: str | None = None, + timeout: float = 5.0, +) -> dict[str, Any] | None: + """Emit a request event and block until the matching response arrives. + + Registers a one-shot handler on ``response_event`` that matches by a + correlation token, generates the token, injects it into the payload + under ``correlation_key``, emits ``request_event``, and waits up to + ``timeout`` seconds for the response. + + Parameters + ---------- + widget : Any + The widget whose ``on``/``emit`` methods drive the round-trip. + request_event : str + Name of the Python→JS event to emit. + response_event : str + Name of the JS→Python event to listen for. + payload : dict + Request payload. A correlation token is injected under + ``correlation_key``; any existing value is overwritten. + correlation_key : str + Field name to use for the correlation token in the request + payload (defaults to ``"context"``, matching PyWry convention). + response_correlation_key : str or None + Field name to read the correlation token from in the response. + Defaults to ``correlation_key``. + timeout : float + Maximum seconds to wait for the response. + + Returns + ------- + dict or None + The response data, or ``None`` if the response didn't arrive + within ``timeout``. + """ + request_id = uuid.uuid4().hex + response_correlation_key = response_correlation_key or correlation_key + evt = threading.Event() + + with _pending_lock: + _pending_events[request_id] = evt + + def _listener(data: Any, _event_type: str = "", _label: str = "") -> None: + if not isinstance(data, dict): + return + token = data.get(response_correlation_key) + if isinstance(token, str) and token == request_id: + with _pending_lock: + _pending_responses[request_id] = data + pending = _pending_events.get(request_id) + if pending: + pending.set() + + try: + widget.on(response_event, _listener) + except Exception: + with _pending_lock: + _pending_events.pop(request_id, None) + raise + + merged_payload = dict(payload or {}) + merged_payload[correlation_key] = request_id + widget.emit(request_event, merged_payload) + + received = evt.wait(timeout) + with _pending_lock: + response = _pending_responses.pop(request_id, None) + _pending_events.pop(request_id, None) + return response if received else None + + +def capture_widget_events( + widget: Any, + widget_id: str, + events: dict[str, list[dict[str, Any]]], + event_names: list[str], +) -> None: + """Register handlers that store incoming events in the MCP ``events`` dict. + + Used at widget creation time to populate ``ctx.events[widget_id]`` + with chart/drawing/tool-activity events so agents can retrieve them + via the ``get_events`` tool. + + Parameters + ---------- + widget : Any + The widget whose ``on`` method will register listeners. + widget_id : str + Key under which events are bucketed in ``events``. + events : dict + The MCP-server-wide events dict (mutated in place). + event_names : list[str] + Event names to capture. Each incoming event becomes an entry + in ``events[widget_id]`` tagged with ``event`` and ``data``. + """ + for name in event_names: + + def _make_handler(ev_name: str) -> Any: + def _handler(data: Any, _event_type: str = "", _label: str = "") -> None: + events.setdefault(widget_id, []).append({"event": ev_name, "data": data}) + + return _handler + + with contextlib.suppress(Exception): + widget.on(name, _make_handler(name)) diff --git a/pywry/pywry/mcp/tools.py b/pywry/pywry/mcp/tools.py index 3f21964..0e75652 100644 --- a/pywry/pywry/mcp/tools.py +++ b/pywry/pywry/mcp/tools.py @@ -461,6 +461,604 @@ def get_tools() -> list[Tool]: }, ), # ===================================================================== + # TVChart — first-class tools for every chart operation. Every tool + # accepts the owning ``widget_id`` plus an optional ``chart_id`` for + # multi-chart widgets (defaults to the first chart). + # ===================================================================== + Tool( + name="tvchart_update_series", + description="""Replace the bar data for a chart series. + +Emits ``tvchart:update`` with ``{bars, volume?, fitContent?, chartId?, seriesId?}``. +Time values are Unix epoch seconds. Use ``series_id`` to target a specific +series (defaults to the main OHLCV series).""", + inputSchema={ + "type": "object", + "properties": { + "widget_id": {"type": "string"}, + "bars": { + "type": "array", + "items": {"type": "object"}, + "description": "Bar objects with time/open/high/low/close/volume fields", + }, + "volume": { + "type": "array", + "items": {"type": "object"}, + "description": "Optional separate volume points {time,value,color?}", + }, + "series_id": {"type": "string"}, + "chart_id": {"type": "string"}, + "fit_content": {"type": "boolean", "default": True}, + }, + "required": ["widget_id", "bars"], + }, + ), + Tool( + name="tvchart_update_bar", + description="""Stream a single real-time bar update. + +Emits ``tvchart:stream`` with the merged bar payload. If the bar's time +matches the most recent bar the chart updates that bar; otherwise a new +bar is appended. Volume colour is auto-derived from open/close unless +provided.""", + inputSchema={ + "type": "object", + "properties": { + "widget_id": {"type": "string"}, + "bar": { + "type": "object", + "description": "Bar dict with time/open/high/low/close/volume", + }, + "series_id": {"type": "string"}, + "chart_id": {"type": "string"}, + }, + "required": ["widget_id", "bar"], + }, + ), + Tool( + name="tvchart_add_series", + description="""Add a pre-computed overlay series to the chart. + +Emits ``tvchart:add-series``. Use this for any series whose values you +already computed in Python (custom indicators, compare symbols, forecasts, +etc.). For the built-in indicator engine (SMA/EMA/RSI/BB/…) use +``tvchart_add_indicator`` instead.""", + inputSchema={ + "type": "object", + "properties": { + "widget_id": {"type": "string"}, + "series_id": {"type": "string"}, + "bars": { + "type": "array", + "items": {"type": "object"}, + "description": "Series data points — shape depends on series_type", + }, + "series_type": { + "type": "string", + "enum": ["Line", "Area", "Histogram", "Baseline", "Candlestick", "Bar"], + "default": "Line", + }, + "series_options": {"type": "object"}, + "chart_id": {"type": "string"}, + }, + "required": ["widget_id", "series_id", "bars"], + }, + ), + Tool( + name="tvchart_remove_series", + description="""Remove a series or overlay by id. + +Emits ``tvchart:remove-series``. Works for any series added via +``tvchart_add_series`` or ``tvchart_add_indicator``.""", + inputSchema={ + "type": "object", + "properties": { + "widget_id": {"type": "string"}, + "series_id": {"type": "string"}, + "chart_id": {"type": "string"}, + }, + "required": ["widget_id", "series_id"], + }, + ), + Tool( + name="tvchart_add_markers", + description="""Add buy/sell or event markers at specific bars. + +Emits ``tvchart:add-markers``. Each marker is ``{time, position, color, +shape, text}`` where ``position`` is ``"aboveBar"`` or ``"belowBar"`` and +``shape`` is one of ``"arrowUp"``, ``"arrowDown"``, ``"circle"``, etc.""", + inputSchema={ + "type": "object", + "properties": { + "widget_id": {"type": "string"}, + "markers": { + "type": "array", + "items": {"type": "object"}, + "description": "List of marker dicts", + }, + "series_id": {"type": "string"}, + "chart_id": {"type": "string"}, + }, + "required": ["widget_id", "markers"], + }, + ), + Tool( + name="tvchart_add_price_line", + description="""Draw a horizontal price line (support/resistance/target). + +Emits ``tvchart:add-price-line``.""", + inputSchema={ + "type": "object", + "properties": { + "widget_id": {"type": "string"}, + "price": {"type": "number"}, + "color": {"type": "string", "default": "#2196F3"}, + "line_width": {"type": "integer", "default": 1}, + "title": {"type": "string", "default": ""}, + "series_id": {"type": "string"}, + "chart_id": {"type": "string"}, + }, + "required": ["widget_id", "price"], + }, + ), + Tool( + name="tvchart_apply_options", + description="""Apply chart-level or series-level option patches. + +Emits ``tvchart:apply-options``. ``chart_options`` patches the chart +(layout/grid/crosshair/timeScale); ``series_options`` patches the +specified series (colour, lineWidth, priceScaleId, etc.).""", + inputSchema={ + "type": "object", + "properties": { + "widget_id": {"type": "string"}, + "chart_options": {"type": "object"}, + "series_options": {"type": "object"}, + "series_id": {"type": "string"}, + "chart_id": {"type": "string"}, + }, + "required": ["widget_id"], + }, + ), + Tool( + name="tvchart_add_indicator", + description="""Add a built-in technical indicator to the chart. + +Emits ``tvchart:add-indicator``. The indicator is computed natively by +the charting engine from the current bar data. Supports legend, +undo/redo, and subplot panes automatically. + +Valid ``name`` values: +- Moving averages: ``SMA``, ``EMA``, ``WMA``, ``SMA (50)``, ``SMA (200)``, + ``EMA (12)``, ``EMA (26)``, ``Moving Average`` +- Momentum: ``RSI``, ``Momentum`` +- Volatility: ``Bollinger Bands``, ``ATR`` +- Volume: ``VWAP``, ``Volume SMA`` +- Lightweight Examples: ``Average Price``, ``Median Price``, ``Weighted Close``, + ``Percent Change``, ``Correlation``, ``Spread``, ``Ratio``, ``Sum``, ``Product``""", + inputSchema={ + "type": "object", + "properties": { + "widget_id": {"type": "string"}, + "name": {"type": "string"}, + "period": { + "type": "integer", + "description": "Lookback period (0 uses the indicator default)", + }, + "color": {"type": "string", "description": "Hex colour (empty = auto-assign)"}, + "source": { + "type": "string", + "description": "OHLC source: close/open/high/low/hl2/hlc3/ohlc4", + }, + "method": { + "type": "string", + "description": "For Moving Average: SMA/EMA/WMA", + }, + "multiplier": {"type": "number", "description": "Bollinger Bands multiplier"}, + "ma_type": {"type": "string", "description": "Bollinger Bands MA type"}, + "offset": { + "type": "integer", + "description": "Bar offset for indicator shifting", + }, + "chart_id": {"type": "string"}, + }, + "required": ["widget_id", "name"], + }, + ), + Tool( + name="tvchart_remove_indicator", + description="""Remove a built-in indicator by series id. + +Emits ``tvchart:remove-indicator``. Grouped indicators (e.g. the three +Bollinger bands) are removed together, and subplot panes are cleaned up +automatically.""", + inputSchema={ + "type": "object", + "properties": { + "widget_id": {"type": "string"}, + "series_id": {"type": "string"}, + "chart_id": {"type": "string"}, + }, + "required": ["widget_id", "series_id"], + }, + ), + Tool( + name="tvchart_list_indicators", + description="""Return the list of active built-in indicators. + +Synchronously round-trips ``tvchart:list-indicators`` → +``tvchart:list-indicators-response`` and returns the decoded response +(``{indicators: [{seriesId, name, type, period, color, group}]}``).""", + inputSchema={ + "type": "object", + "properties": { + "widget_id": {"type": "string"}, + "chart_id": {"type": "string"}, + "timeout": {"type": "number", "default": 5.0}, + }, + "required": ["widget_id"], + }, + ), + Tool( + name="tvchart_show_indicators", + description="Open the indicator picker panel. Emits ``tvchart:show-indicators``.", + inputSchema={ + "type": "object", + "properties": { + "widget_id": {"type": "string"}, + "chart_id": {"type": "string"}, + }, + "required": ["widget_id"], + }, + ), + Tool( + name="tvchart_symbol_search", + description="""Open the symbol search dialog, optionally pre-filling it. + +Emits ``tvchart:symbol-search``. When ``query`` is set the datafeed +search runs with that query and — if ``auto_select`` (default true) — +the exact-ticker match (or the first result otherwise) is selected as +soon as results arrive. ``symbol_type`` and ``exchange`` narrow the +datafeed search to a specific security class or venue — e.g. +``symbol_type="etf"`` ensures ``SPY`` resolves to the SPDR ETF rather +than a near-prefix match like ``SPYM``.""", + inputSchema={ + "type": "object", + "properties": { + "widget_id": {"type": "string"}, + "query": {"type": "string"}, + "auto_select": {"type": "boolean", "default": True}, + "symbol_type": { + "type": "string", + "description": ( + "Security class filter (datafeed-provided values — " + "typically one of 'equity', 'etf', 'index', " + "'mutualfund', 'future', 'cryptocurrency', " + "'currency'). Case-insensitive." + ), + }, + "exchange": { + "type": "string", + "description": "Exchange filter (datafeed-provided values). Case-insensitive.", + }, + "chart_id": {"type": "string"}, + }, + "required": ["widget_id"], + }, + ), + Tool( + name="tvchart_compare", + description="""Add a symbol as an overlay compare series on the chart. + +Emits ``tvchart:compare``. When ``query`` is set the compare panel +runs a datafeed search and — if ``auto_add`` (default true) — adds the +exact-ticker match (or the first result otherwise) to the chart. The +tool polls chart state until the new compare series appears in +``state.compareSymbols`` and returns the confirmed state; if the match +doesn't commit in time, the result includes a ``note``. Omit +``query`` to just open the panel for the user. ``symbol_type`` and +``exchange`` narrow the datafeed search — e.g. ``symbol_type="etf"`` +routes ``SPY`` to the SPDR ETF rather than a near-prefix match.""", + inputSchema={ + "type": "object", + "properties": { + "widget_id": {"type": "string"}, + "query": { + "type": "string", + "description": "Ticker / name to search and auto-add as a compare series.", + }, + "auto_add": { + "type": "boolean", + "default": True, + "description": "If true, auto-commit the matching result. If false, just open the dialog.", + }, + "symbol_type": { + "type": "string", + "description": ( + "Security class filter (datafeed-provided values — " + "typically one of 'equity', 'etf', 'index', " + "'mutualfund', 'future', 'cryptocurrency', " + "'currency'). Case-insensitive." + ), + }, + "exchange": { + "type": "string", + "description": "Exchange filter (datafeed-provided values). Case-insensitive.", + }, + "chart_id": {"type": "string"}, + }, + "required": ["widget_id"], + }, + ), + Tool( + name="tvchart_change_interval", + description="""Change the chart timeframe / bar interval. + +Emits ``tvchart:interval-change``. Valid intervals match the chart's +``supported_resolutions``. Typical values: ``1m 3m 5m 15m 30m 45m 1h +2h 3h 4h 1d 1w 1M 3M 6M 12M``.""", + inputSchema={ + "type": "object", + "properties": { + "widget_id": {"type": "string"}, + "value": {"type": "string", "description": "Interval (e.g. '5m', '1d')"}, + "chart_id": {"type": "string"}, + }, + "required": ["widget_id", "value"], + }, + ), + Tool( + name="tvchart_set_visible_range", + description="""Set the chart's visible time range. + +Emits ``tvchart:time-scale`` with ``{visibleRange: {from, to}}``. Times +are Unix epoch seconds.""", + inputSchema={ + "type": "object", + "properties": { + "widget_id": {"type": "string"}, + "from_time": {"type": "integer"}, + "to_time": {"type": "integer"}, + "chart_id": {"type": "string"}, + }, + "required": ["widget_id", "from_time", "to_time"], + }, + ), + Tool( + name="tvchart_fit_content", + description="Fit all bars to the visible area. Emits ``tvchart:time-scale`` with ``{fitContent: true}``.", + inputSchema={ + "type": "object", + "properties": { + "widget_id": {"type": "string"}, + "chart_id": {"type": "string"}, + }, + "required": ["widget_id"], + }, + ), + Tool( + name="tvchart_time_range", + description="""Zoom to a preset time range. + +Emits ``tvchart:time-range``. Typical values: ``1D 1W 1M 3M 6M 1Y 5Y YTD``.""", + inputSchema={ + "type": "object", + "properties": { + "widget_id": {"type": "string"}, + "value": {"type": "string"}, + "chart_id": {"type": "string"}, + }, + "required": ["widget_id", "value"], + }, + ), + Tool( + name="tvchart_time_range_picker", + description="Open the date-range picker dialog. Emits ``tvchart:time-range-picker``.", + inputSchema={ + "type": "object", + "properties": { + "widget_id": {"type": "string"}, + "chart_id": {"type": "string"}, + }, + "required": ["widget_id"], + }, + ), + Tool( + name="tvchart_log_scale", + description="Toggle the logarithmic price scale. Emits ``tvchart:log-scale``.", + inputSchema={ + "type": "object", + "properties": { + "widget_id": {"type": "string"}, + "value": {"type": "boolean"}, + "chart_id": {"type": "string"}, + }, + "required": ["widget_id", "value"], + }, + ), + Tool( + name="tvchart_auto_scale", + description="Toggle auto-scale on the price axis. Emits ``tvchart:auto-scale``.", + inputSchema={ + "type": "object", + "properties": { + "widget_id": {"type": "string"}, + "value": {"type": "boolean"}, + "chart_id": {"type": "string"}, + }, + "required": ["widget_id", "value"], + }, + ), + Tool( + name="tvchart_chart_type", + description="""Change the main series chart type. + +Emits ``tvchart:chart-type-change``. Valid values: ``Candles``, +``Hollow Candles``, ``Heikin Ashi``, ``Bars``, ``Line``, ``Area``, +``Baseline``, ``Histogram``.""", + inputSchema={ + "type": "object", + "properties": { + "widget_id": {"type": "string"}, + "value": {"type": "string"}, + "series_id": {"type": "string"}, + "chart_id": {"type": "string"}, + }, + "required": ["widget_id", "value"], + }, + ), + Tool( + name="tvchart_drawing_tool", + description="""Activate a drawing tool or toggle drawing-layer state. + +Emits one of ``tvchart:tool-cursor``, ``tvchart:tool-crosshair``, +``tvchart:tool-magnet``, ``tvchart:tool-eraser``, +``tvchart:tool-visibility``, ``tvchart:tool-lock`` depending on ``mode``.""", + inputSchema={ + "type": "object", + "properties": { + "widget_id": {"type": "string"}, + "mode": { + "type": "string", + "enum": ["cursor", "crosshair", "magnet", "eraser", "visibility", "lock"], + }, + "chart_id": {"type": "string"}, + }, + "required": ["widget_id", "mode"], + }, + ), + Tool( + name="tvchart_undo", + description="Undo the last chart action. Emits ``tvchart:undo``.", + inputSchema={ + "type": "object", + "properties": { + "widget_id": {"type": "string"}, + "chart_id": {"type": "string"}, + }, + "required": ["widget_id"], + }, + ), + Tool( + name="tvchart_redo", + description="Redo the last undone chart action. Emits ``tvchart:redo``.", + inputSchema={ + "type": "object", + "properties": { + "widget_id": {"type": "string"}, + "chart_id": {"type": "string"}, + }, + "required": ["widget_id"], + }, + ), + Tool( + name="tvchart_show_settings", + description="Open the chart settings modal. Emits ``tvchart:show-settings``.", + inputSchema={ + "type": "object", + "properties": { + "widget_id": {"type": "string"}, + "chart_id": {"type": "string"}, + }, + "required": ["widget_id"], + }, + ), + Tool( + name="tvchart_toggle_dark_mode", + description="Toggle the chart's dark/light theme. Emits ``tvchart:toggle-dark-mode``.", + inputSchema={ + "type": "object", + "properties": { + "widget_id": {"type": "string"}, + "value": {"type": "boolean", "description": "true = dark, false = light"}, + "chart_id": {"type": "string"}, + }, + "required": ["widget_id", "value"], + }, + ), + Tool( + name="tvchart_screenshot", + description="Take a screenshot of the chart. Emits ``tvchart:screenshot``.", + inputSchema={ + "type": "object", + "properties": { + "widget_id": {"type": "string"}, + "chart_id": {"type": "string"}, + }, + "required": ["widget_id"], + }, + ), + Tool( + name="tvchart_fullscreen", + description="Toggle chart fullscreen mode. Emits ``tvchart:fullscreen``.", + inputSchema={ + "type": "object", + "properties": { + "widget_id": {"type": "string"}, + "chart_id": {"type": "string"}, + }, + "required": ["widget_id"], + }, + ), + Tool( + name="tvchart_save_layout", + description="""Save the current chart layout (indicators + drawings). + +Emits ``tvchart:save-layout``.""", + inputSchema={ + "type": "object", + "properties": { + "widget_id": {"type": "string"}, + "name": {"type": "string"}, + "chart_id": {"type": "string"}, + }, + "required": ["widget_id"], + }, + ), + Tool( + name="tvchart_open_layout", + description="Open the layout picker dialog. Emits ``tvchart:open-layout``.", + inputSchema={ + "type": "object", + "properties": { + "widget_id": {"type": "string"}, + "chart_id": {"type": "string"}, + }, + "required": ["widget_id"], + }, + ), + Tool( + name="tvchart_save_state", + description="""Request a full state export from every chart in the widget. + +Emits ``tvchart:save-state``. Use ``tvchart_request_state`` for a +synchronous single-chart snapshot.""", + inputSchema={ + "type": "object", + "properties": { + "widget_id": {"type": "string"}, + }, + "required": ["widget_id"], + }, + ), + Tool( + name="tvchart_request_state", + description="""Read a single chart's full state synchronously. + +Round-trips ``tvchart:request-state`` → ``tvchart:state-response`` and +returns the decoded state object (``{chartId, theme, series, +visibleRange, rawData, drawings, indicators}``).""", + inputSchema={ + "type": "object", + "properties": { + "widget_id": {"type": "string"}, + "chart_id": {"type": "string"}, + "timeout": {"type": "number", "default": 5.0}, + }, + "required": ["widget_id"], + }, + ), + # ===================================================================== # Widget Manipulation # ===================================================================== Tool( 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..98c988f 100644 --- a/pywry/pywry/state/_factory.py +++ b/pywry/pywry/state/_factory.py @@ -10,6 +10,7 @@ import uuid from functools import lru_cache +from pathlib import Path from typing import TYPE_CHECKING @@ -73,7 +74,7 @@ def get_state_backend() -> StateBackend: Returns ------- StateBackend - The configured backend (MEMORY or REDIS). + The configured backend (MEMORY, REDIS, or SQLITE). Notes ----- @@ -83,6 +84,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 @@ -135,6 +138,21 @@ def _get_deploy_settings() -> DeploySettings: return DeploySettings() +_DEFAULT_SQLITE_PATH = "~/.config/pywry/pywry.db" + + +def _resolve_sqlite_path(settings: DeploySettings) -> str: + """Return the configured SQLite path with ``~`` expanded. + + ``sqlite3`` / SQLCipher do not expand ``~`` themselves, so we normalise + here even though every bundled ``SqliteStateBackend`` subclass also + expands in its own ``__init__`` — cheaper than hunting down a future + caller that forgets. + """ + raw = getattr(settings, "sqlite_path", None) or _DEFAULT_SQLITE_PATH + return str(Path(raw).expanduser()) + + if TYPE_CHECKING: from pywry.config import DeploySettings @@ -168,6 +186,12 @@ 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=_resolve_sqlite_path(settings)) + return MemoryWidgetStore() @@ -199,6 +223,9 @@ def get_event_bus() -> EventBus: pool_size=settings.redis_pool_size, ) + if backend == StateBackend.SQLITE: + return MemoryEventBus() + return MemoryEventBus() @@ -230,6 +257,9 @@ def get_connection_router() -> ConnectionRouter: pool_size=settings.redis_pool_size, ) + if backend == StateBackend.SQLITE: + return MemoryConnectionRouter() + return MemoryConnectionRouter() @@ -262,6 +292,12 @@ 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=_resolve_sqlite_path(settings)) + return MemorySessionStore() @@ -294,6 +330,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=_resolve_sqlite_path(settings)) + return MemoryChatStore() diff --git a/pywry/pywry/state/base.py b/pywry/pywry/state/base.py index 070cada..73c2c5a 100644 --- a/pywry/pywry/state/base.py +++ b/pywry/pywry/state/base.py @@ -4,8 +4,10 @@ horizontal scaling via Redis or other external stores. """ -# pylint: disable=unnecessary-ellipsis -# Ellipsis (...) is the standard Python idiom for abstract method bodies +# pylint: disable=unnecessary-ellipsis,unused-argument +# Ellipsis (...) is the standard Python idiom for abstract method bodies; +# ABC defaults accept the full interface signature even when the no-op +# body doesn't reference every parameter. from __future__ import annotations @@ -657,6 +659,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..82e407c --- /dev/null +++ b/pywry/pywry/state/sqlite.py @@ -0,0 +1,905 @@ +"""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__) + + +def _load_sqlcipher() -> Any: + """Return the ``sqlcipher3`` / ``pysqlcipher3`` ``dbapi2`` module, or ``None``. + + Both packages expose the same DB-API 2.0 surface as stdlib + ``sqlite3``. ``sqlcipher3`` is the actively-maintained fork; the + legacy ``pysqlcipher3`` is checked as a fallback for environments + that still pin it. ``importlib`` is used so mypy doesn't complain + about the alternative import paths missing stubs at runtime — + neither package ships a ``py.typed`` marker. + """ + import importlib + + for name in ("sqlcipher3", "pysqlcipher3"): + try: + module = importlib.import_module(f"{name}.dbapi2") + except ImportError: + continue + return module + return None + + +_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) + + conn: sqlite3.Connection + if self._encrypted and self._key: + sqlcipher = _load_sqlcipher() + if sqlcipher is not None: + conn = sqlcipher.connect(str(self._db_path)) + conn.execute(f"PRAGMA key = '{self._key}'") + logger.debug("Opened encrypted SQLite database at %s", self._db_path) + else: + logger.warning( + "sqlcipher3 / pysqlcipher3 not installed — database will " + "NOT be encrypted. Install with: pip install sqlcipher3" + ) + 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/mixin.py b/pywry/pywry/tvchart/mixin.py index 4297d1b..9e6e85f 100644 --- a/pywry/pywry/tvchart/mixin.py +++ b/pywry/pywry/tvchart/mixin.py @@ -9,7 +9,7 @@ import logging -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, Literal from ..state_mixins import EmittingWidget @@ -228,6 +228,197 @@ def remove_indicator( payload["chartId"] = chart_id self.emit("tvchart:remove-series", payload) + def add_builtin_indicator( + self, + name: str, + period: int | None = None, + *, + color: str | None = None, + source: str | None = None, + method: str | None = None, + multiplier: float | None = None, + ma_type: str | None = None, + offset: int | None = None, + chart_id: str | None = None, + ) -> None: + """Add a built-in indicator computed on the JS frontend. + + Uses the full indicator engine: legend integration, undo/redo, + subplot panes, and Bollinger Bands band-fill rendering. + + Available indicators (by name): + Moving Average (pick SMA / EMA / WMA / HMA / VWMA via + ``method``), Ichimoku Cloud, Bollinger Bands, Keltner + Channels, ATR, Historical Volatility, Parabolic SAR, RSI, + MACD, Stochastic, Williams %R, CCI, ADX, Aroon, VWAP, + Volume SMA, Accumulation/Distribution, Volume Profile + Fixed Range, Volume Profile Visible Range. + + Parameters + ---------- + name : str + Indicator name from the catalog (e.g. ``"Moving Average"``, + ``"RSI"``, ``"MACD"``). + period : int, optional + Lookback period. Falls back to the catalog default. + color : str, optional + Hex colour. Auto-assigned from the palette when omitted. + source : str, optional + OHLC source: ``"close"``, ``"open"``, ``"high"``, ``"low"``, + ``"hl2"``, ``"hlc3"``, ``"ohlc4"``. + method : str, optional + Moving average method for the Moving Average indicator: + ``"SMA"``, ``"EMA"``, ``"WMA"``, ``"HMA"``, or ``"VWMA"``. + multiplier : float, optional + Bollinger Bands / Keltner Channels standard-deviation (or + ATR) multiplier (default 2). + ma_type : str, optional + Bollinger Bands moving-average type (default ``"SMA"``). + offset : int, optional + Bar offset for indicator shifting. + chart_id : str, optional + Target chart instance ID. + """ + payload: dict[str, Any] = {"name": name} + if period is not None: + payload["period"] = period + if color is not None: + payload["color"] = color + if source is not None: + payload["source"] = source + if method is not None: + payload["method"] = method + if multiplier is not None: + payload["multiplier"] = multiplier + if ma_type is not None: + payload["maType"] = ma_type + if offset is not None: + payload["offset"] = offset + if chart_id is not None: + payload["chartId"] = chart_id + self.emit("tvchart:add-indicator", payload) + + def add_volume_profile( + self, + mode: Literal["fixed", "visible"] = "visible", + *, + bucket_count: int = 24, + from_index: int | None = None, + to_index: int | None = None, + placement: Literal["right", "left"] = "right", + width_percent: float = 25.0, + value_area_pct: float = 0.70, + show_poc: bool = True, + show_value_area: bool = True, + up_color: str | None = None, + down_color: str | None = None, + poc_color: str | None = None, + chart_id: str | None = None, + ) -> None: + """Add a Volume Profile overlay pinned to the price pane edge. + + Renders a volume-by-price histogram: one horizontal row per price + bucket, bar length proportional to net volume traded at that + level, split into up-volume and down-volume portions. A + Point-of-Control (POC) line marks the bucket with the highest + volume; the value area (default 70%) is drawn in a deeper + colour. + + Parameters + ---------- + mode : {"fixed", "visible"} + ``"fixed"`` buckets a specific bar-index range. + ``"visible"`` tracks the current viewport and recomputes on + every pan/zoom. + bucket_count : int + Number of price buckets (default 24). + from_index, to_index : int, optional + Inclusive bar-index bounds for fixed mode. Defaults to the + full bar set if omitted. + placement : {"right", "left"} + Which side of the pane the histogram is pinned to. + width_percent : float + Maximum histogram width as a percentage of the pane width + (default 25). + value_area_pct : float + Fraction of volume that defines the Value Area (default 0.70). + show_poc, show_value_area : bool + Toggle the POC line and Value Area colouring. + up_color, down_color, poc_color : str, optional + CSS colours for up-volume bars, down-volume bars, and the POC + line. + chart_id : str, optional + Target chart instance ID. + """ + name = "Volume Profile Fixed Range" if mode == "fixed" else "Volume Profile Visible Range" + payload: dict[str, Any] = { + "name": name, + "period": int(bucket_count), + "placement": placement, + "widthPercent": float(width_percent), + "valueAreaPct": float(value_area_pct), + "showPOC": bool(show_poc), + "showValueArea": bool(show_value_area), + } + if mode == "fixed" and from_index is not None and to_index is not None: + payload["fromIndex"] = int(from_index) + payload["toIndex"] = int(to_index) + if up_color is not None: + payload["upColor"] = up_color + if down_color is not None: + payload["downColor"] = down_color + if poc_color is not None: + payload["pocColor"] = poc_color + if chart_id is not None: + payload["chartId"] = chart_id + self.emit("tvchart:add-indicator", payload) + + def remove_builtin_indicator( + self, + series_id: str, + chart_id: str | None = None, + ) -> None: + """Remove a built-in indicator by its series ID. + + Handles grouped indicators (e.g. Bollinger Bands upper/mid/lower + are removed together), subplot pane cleanup, and undo/redo. + + Parameters + ---------- + series_id : str + The indicator series ID (e.g. ``"ind_sma_1713200000"``). + chart_id : str, optional + Target chart instance ID. + """ + payload: dict[str, Any] = {"seriesId": series_id} + if chart_id is not None: + payload["chartId"] = chart_id + self.emit("tvchart:remove-indicator", payload) + + def list_indicators( + self, + chart_id: str | None = None, + context: dict[str, Any] | None = None, + ) -> None: + """Request the list of active built-in indicators. + + The frontend replies with a ``tvchart:list-indicators-response`` + event containing an ``indicators`` array. + + Parameters + ---------- + chart_id : str, optional + Target chart instance ID. + context : dict, optional + Opaque context echoed back in the response. + """ + payload: dict[str, Any] = {} + if chart_id is not None: + payload["chartId"] = chart_id + if context is not None: + payload["context"] = context + self.emit("tvchart:list-indicators", payload) + def add_marker( self, markers: list[dict[str, Any]], 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 abec9c0..0cbe1f4 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,61 @@ 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) + asset_listener_js = """ + 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); + } + }); + 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); + } + }); + """ + + combined_handlers = toolbar_handlers_js + "\n" + asset_listener_js + base_esm = _WIDGET_ESM.replace("__TOOLBAR_HANDLERS__", combined_handlers) + + return f""" +{toast_js} + +{scrollbar_js} + +{base_esm} + +{chat_handlers_js} +""" + + @lru_cache(maxsize=1) def _get_pywry_base_css() -> str: """Load pywry base CSS for widget theming, including toast styles.""" @@ -1913,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..b72a58e 100644 --- a/pywry/ruff.toml +++ b/pywry/ruff.toml @@ -45,22 +45,21 @@ select = [ ] ignore = [ - "E501", - "D100", - "D104", - "D105", - "D107", - "D203", - "D213", - "S101", - "TRY003", - "PLR0913", - "PLR2004", - "D401", - "B008", - "N818", - "PERF203", - "PLC0415", + "E501", # line-length — handled by `line-length = 100` + "D100", # missing docstring in public module + "D104", # missing docstring in public package + "D105", # missing docstring in magic method + "D107", # missing docstring in __init__ + "D203", # 1 blank line before class docstring (conflicts with D211) + "D213", # multi-line summary should start at the second line (conflicts with D212) + "TRY003", # long messages outside exception class + "PLR0913", # too many arguments — relaxed for Pydantic constructors + "PLR2004", # magic value used in comparison + "D401", # first-line imperative mood + "B008", # function call in argument defaults — Pydantic Field() + "N818", # exception name must end with Error (we follow stdlib naming) + "PERF203", # try/except in a loop is intentional in a few dispatch paths + "PLC0415", # import-outside-toplevel — used for lazy optional imports ] fixable = ["ALL"] @@ -73,16 +72,21 @@ unfixable = [ dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$" [lint.per-file-ignores] +# Rule ignores below are deliberately scoped to the test suite so they +# can't mask real issues in shipped code. Each entry explains why the +# rule is inappropriate under ``tests/`` specifically. "tests/**/*.py" = [ - "S101", - "D", - "PLR2004", - "S104", - "S105", - "S106", - "S310", - "ARG", - "ASYNC240", + "S101", # `assert` is the primary test mechanism + "D", # pydocstyle rules do not apply to test fixtures / cases + "PLR2004", # magic numbers are expected in assertions + "S104", # binding to 0.0.0.0 in integration tests (testcontainers, etc.) + "S105", # hard-coded "password" / secret fixtures + "S106", # hard-coded passwords in test kwargs (e.g. OAuth fixtures) + "S108", # hard-coded /tmp paths in fixtures + "S310", # unverified urllib calls against fixture URLs + "ARG", # unused fixture / mock arguments are idiomatic in pytest + "ASYNC240", # deliberately blocking calls in async tests (e.g. assertions) + "PERF401", # readability beats micro-optimisation in tests ] "__init__.py" = [ @@ -91,6 +95,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_alerts.py b/pywry/tests/test_alerts.py index 79708c9..d7591e3 100644 --- a/pywry/tests/test_alerts.py +++ b/pywry/tests/test_alerts.py @@ -781,9 +781,17 @@ def test_cancel_button_triggers_callback(self) -> None: title="Cancel Callback Test", ) + # Clear any toasts the previous test (confirm-button) may have + # left attached — when tests share a page across the class, + # a stale ``.pywry-toast--confirm`` selector can find the + # old toast's DOM node (``toastDismissed=False``) or steer + # the click to the wrong button. script = """ (function() { var container = document.querySelector('.pywry-widget'); + var existing = container.querySelectorAll('.pywry-toast--confirm'); + existing.forEach(function(el) { el.remove(); }); + var cancelled = false; PYWRY_TOAST.confirm({ @@ -794,23 +802,23 @@ def test_cancel_button_triggers_callback(self) -> None: }); setTimeout(function() { - var cancelBtn = document.querySelector('.pywry-toast__btn--cancel'); + var cancelBtn = container.querySelector('.pywry-toast__btn--cancel'); if (cancelBtn) { cancelBtn.click(); } setTimeout(function() { - var toastGone = document.querySelector('.pywry-toast--confirm') === null; + var toastGone = container.querySelector('.pywry-toast--confirm') === null; pywry.result({ cancelled: cancelled, toastDismissed: toastGone }); - }, 100); - }, 100); + }, 200); + }, 200); })(); """ - result = wait_for_result(label, script, timeout=3.0) + result = wait_for_result(label, script, timeout=5.0) assert result is not None assert result["cancelled"] is True assert result["toastDismissed"] is True diff --git a/pywry/tests/test_chat.py b/pywry/tests/test_chat.py index 4bb90f3..fd4d4bd 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 +# ============================================================================= + + +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_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 + 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,235 @@ 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_openai_provider_name_resolves(self) -> None: + pytest.importorskip("openai") + 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 + provider = get_provider("openai", api_key="sk-test") + assert type(provider).__name__ == "OpenAIProvider" - def test_both_context_and_file_attach(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_context=True, - enable_file_attach=True, - file_accept_types=[".csv"], - ) - assert "pywry-chat-mention-popup" in html - assert "pywry-chat-attach-btn" in html - assert "pywry-chat-drop-overlay" in html + with pytest.raises(ValueError, match="Unknown provider"): + get_provider("nonexistent") # ============================================================================= -# Content Part Tests +# Session Primitives Tests # ============================================================================= -class TestContentParts: - """Test ChatContentPart types.""" +class TestSessionPrimitives: + """Test ACP session models.""" - def test_text_part(self) -> None: - part = TextPart(text="hello") - assert part.type == "text" - assert part.text == "hello" + def test_session_mode(self) -> None: + from pywry.chat.session import SessionMode - 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" + mode = SessionMode(id="code", name="Code Mode", description="Write code") + assert mode.id == "code" + assert mode.name == "Code Mode" - 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_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 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 # ============================================================================= -# Provider Tests (import only, no API calls) +# Update Types Tests # ============================================================================= -class TestProviderFactory: - """Test provider factory function.""" +class TestUpdateTypes: + """Test SessionUpdate models.""" - def test_callback_provider(self) -> None: - from pywry.chat_providers import get_provider + def test_agent_message_update(self) -> None: + from pywry.chat.updates import AgentMessageUpdate - provider = get_provider("callback") - assert provider is not None + u = AgentMessageUpdate(text="Hello") + assert u.session_update == "agent_message" + assert u.text == "Hello" - def test_unknown_provider_raises(self) -> None: - from pywry.chat_providers import get_provider + def test_tool_call_update(self) -> None: + from pywry.chat.updates import ToolCallUpdate - with pytest.raises(ValueError, match="Unknown provider"): - get_provider("nonexistent") + 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" + + +# ============================================================================= +# Artifact Tests +# ============================================================================= + + +class TestArtifacts: + """Test artifact models.""" + + def test_code_artifact(self) -> None: + from pywry.chat.artifacts import CodeArtifact - 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 = CodeArtifact(title="example.py", content="x = 42", language="python") + assert a.artifact_type == "code" + + def test_tradingview_artifact(self) -> None: + from pywry.chat.artifacts import TradingViewArtifact, TradingViewSeries + + 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..bd77ea5 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()) + assert mgr.active_thread_id # has a default thread + def test_requires_handler_or_provider(self): + with pytest.raises(ValueError, match="Either"): + ChatManager() -# ============================================================================= -# callbacks() and toolbar() Tests -# ============================================================================= - - -class TestCallbacksAndToolbar: - """Test callbacks() and toolbar() public methods.""" - - def test_callbacks_keys(self, manager): + def test_callbacks_returns_expected_keys(self, manager): cbs = manager.callbacks() expected = { "chat:user-message", @@ -377,3018 +370,541 @@ def test_callbacks_keys(self, manager): "chat:request-state", "chat:todo-clear", "chat:input-response", + "chat:edit-message", + "chat:resend-from", } 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 + 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" -# ============================================================================= -# send_message() Tests -# ============================================================================= + 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 from code" + 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" -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) +class TestChatManagerHandlerDispatch: + """Test handler invocation and stream processing.""" + def test_echo_handler(self, widget): + mgr = ChatManager(handler=echo_handler) + mgr.bind(widget) + 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"] == "Hello!" - assert events[0]["threadId"] == thread_id + assert any("Echo: hello" in e.get("text", "") for e in events) - # 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!" + 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") + # Should have streaming chunks + done + assert len(chunks) > 0 + done_chunks = [c for c in chunks if c.get("done")] + assert len(done_chunks) >= 1 - 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 + 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) + mgr = ChatManager(handler=slow_handler) + mgr.bind(widget) + 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 -# ============================================================================= -# _on_user_message Tests -# ============================================================================= +class TestChatManagerThreads: + """Test thread CRUD operations.""" -class TestOnUserMessage: - """Test _on_user_message event handler.""" + def test_create_thread(self, bound_manager, widget): + 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_empty_text_ignored(self, bound_manager, widget): - bound_manager._on_user_message({"text": ""}, "", "") - assert len(widget.events) == 0 + def test_switch_thread(self, bound_manager, widget): + 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_stores_user_message(self, bound_manager): + def test_delete_thread(self, bound_manager, widget): + bound_manager._on_thread_create({"title": "To Delete"}, "", "") 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) - - def test_handler_runs_in_thread(self, widget): - """Verify the handler is called and produces output.""" - mgr = ChatManager(handler=echo_handler) - mgr.bind(widget) - tid = mgr.active_thread_id + bound_manager._on_thread_delete({"threadId": tid}, "", "") + assert tid not in bound_manager.threads - mgr._on_user_message({"text": "Hello", "threadId": tid}, "", "") - # Wait for background thread to finish - time.sleep(0.5) + def test_rename_thread(self, bound_manager, widget): + tid = bound_manager.active_thread_id + bound_manager._on_thread_rename({"threadId": tid, "title": "Renamed"}, "", "") + events = widget.get_events("chat:update-thread-list") + assert len(events) >= 1 - # 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"] +class TestChatManagerState: + """Test state management.""" -# ============================================================================= -# _handle_complete Tests -# ============================================================================= + 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 + 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) -class TestHandleComplete: - """Test _handle_complete sends a full message.""" + def test_settings_change(self, bound_manager, widget): + callback = MagicMock() + 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") - def test_emits_and_stores(self, bound_manager, widget): + def test_slash_command_clear(self, bound_manager, widget): tid = bound_manager.active_thread_id - bound_manager._handle_complete("Full response", "msg_001", tid) - - 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" + bound_manager.send_message("test") + assert len(bound_manager.threads[tid]) == 1 + bound_manager._on_slash_command_event({"command": "/clear", "threadId": tid}, "", "") + assert len(bound_manager.threads[tid]) == 0 # ============================================================================= -# _handle_stream Tests +# Edit / Resend 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" +def _seed_thread(mgr: ChatManager) -> tuple[str, list[dict[str, Any]]]: + """Populate the manager's active thread with a four-message conversation.""" + tid = mgr.active_thread_id + msgs = [ + {"id": "msg_user_1", "role": "user", "text": "first question"}, + {"id": "msg_asst_1", "role": "assistant", "text": "first answer"}, + {"id": "msg_user_2", "role": "user", "text": "second question"}, + {"id": "msg_asst_2", "role": "assistant", "text": "second answer"}, + ] + mgr._threads[tid] = list(msgs) + return tid, msgs - 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 +class TestTruncateThreadAt: + """Direct unit tests for the _truncate_thread_at helper.""" - # Full text stored - msgs = bound_manager._threads[tid] - assert msgs[0]["text"] == "Hello World" + def test_keep_target_drops_messages_after(self, bound_manager): + tid, _ = _seed_thread(bound_manager) + removed, removed_ids = bound_manager._truncate_thread_at( + tid, "msg_user_2", keep_target=True + ) + kept_ids = [m["id"] for m in bound_manager._threads[tid]] + assert kept_ids == ["msg_user_1", "msg_asst_1", "msg_user_2"] + assert removed_ids == ["msg_asst_2"] + assert len(removed) == 1 - def test_text_chunk_response(self, bound_manager, widget): - tid = bound_manager.active_thread_id - cancel = threading.Event() + def test_drop_target_removes_message_and_after(self, bound_manager): + tid, _ = _seed_thread(bound_manager) + removed, removed_ids = bound_manager._truncate_thread_at( + tid, "msg_user_2", keep_target=False + ) + kept_ids = [m["id"] for m in bound_manager._threads[tid]] + assert kept_ids == ["msg_user_1", "msg_asst_1"] + assert removed_ids == ["msg_user_2", "msg_asst_2"] + assert len(removed) == 2 - def gen(): - yield TextChunkResponse(text="Chunk1") - yield TextChunkResponse(text="Chunk2") + def test_unknown_message_id_no_op(self, bound_manager): + tid, msgs = _seed_thread(bound_manager) + removed, removed_ids = bound_manager._truncate_thread_at(tid, "ghost", keep_target=True) + assert removed == [] + assert removed_ids == [] + # Thread untouched + assert [m["id"] for m in bound_manager._threads[tid]] == [m["id"] for m in msgs] - 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" +class TestEditMessage: + """Tests for _on_edit_message — replace text + truncate + regenerate.""" - def test_status_response(self, bound_manager, widget): - tid = bound_manager.active_thread_id - cancel = threading.Event() + def test_edit_emits_messages_deleted(self, bound_manager, widget): + tid, _ = _seed_thread(bound_manager) + bound_manager._on_edit_message( + {"messageId": "msg_user_2", "threadId": tid, "text": "REVISED"}, + "chat:edit-message", + "", + ) + time.sleep(0.2) # allow background thread + deletions = widget.get_events("chat:messages-deleted") + assert deletions, "expected at least one chat:messages-deleted event" + d = deletions[0] + assert d["editedMessageId"] == "msg_user_2" + assert d["editedText"] == "REVISED" + # Only the trailing assistant reply should be removed + assert d["messageIds"] == ["msg_asst_2"] + + def test_edit_replaces_user_message_text(self, bound_manager, widget): + tid, _ = _seed_thread(bound_manager) + bound_manager._on_edit_message( + {"messageId": "msg_user_2", "threadId": tid, "text": "REVISED"}, + "chat:edit-message", + "", + ) + time.sleep(0.2) + thread = bound_manager._threads[tid] + # Find the edited user message + edited = next(m for m in thread if m.get("id") == "msg_user_2") + assert edited["text"] == "REVISED" + + def test_edit_unknown_message_is_noop(self, bound_manager, widget): + tid, msgs = _seed_thread(bound_manager) + bound_manager._on_edit_message( + {"messageId": "ghost", "threadId": tid, "text": "x"}, + "chat:edit-message", + "", + ) + # No deletion event, no thread mutation + assert widget.get_events("chat:messages-deleted") == [] + assert [m["id"] for m in bound_manager._threads[tid]] == [m["id"] for m in msgs] - def gen(): - yield StatusResponse(text="Searching...") - yield "result" + def test_edit_empty_text_is_noop(self, bound_manager, widget): + tid, _ = _seed_thread(bound_manager) + bound_manager._on_edit_message( + {"messageId": "msg_user_2", "threadId": tid, "text": " "}, + "chat:edit-message", + "", + ) + assert widget.get_events("chat:messages-deleted") == [] - 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..." +class TestResendFrom: + """Tests for _on_resend_from — drop target + everything after, regenerate.""" - def test_tool_call_response(self, bound_manager, widget): - tid = bound_manager.active_thread_id - cancel = threading.Event() + def test_resend_keeps_target_and_drops_only_later_messages(self, bound_manager, widget): + tid, _ = _seed_thread(bound_manager) + bound_manager._on_resend_from( + {"messageId": "msg_user_2", "threadId": tid}, + "chat:resend-from", + "", + ) + time.sleep(0.2) + deletions = widget.get_events("chat:messages-deleted") + assert deletions + d = deletions[0] + # The target user message stays — only the assistant reply (and + # any subsequent turns) are dropped so "Resend" doesn't read as + # "your message was erased". + assert d["messageIds"] == ["msg_asst_2"] + # No edited-message flags — the user message text is unchanged, + # so the frontend doesn't need to re-render its content. + assert "editedMessageId" not in d + assert "editedText" not in d + # Server-side thread keeps the target user message in place. + surviving_ids = [m["id"] for m in bound_manager._threads[tid]] + assert "msg_user_2" in surviving_ids + assert "msg_asst_2" not in surviving_ids + + def test_resend_re_runs_handler_with_same_text(self, bound_manager, widget): + tid, _ = _seed_thread(bound_manager) + bound_manager._on_resend_from( + {"messageId": "msg_user_2", "threadId": tid}, + "chat:resend-from", + "", + ) + time.sleep(0.3) + # Echo handler returns "Echo: " — verify the assistant reply + # came back for the resent prompt. + replies = widget.get_events("chat:assistant-message") + assert any("Echo: second question" in r.get("text", "") for r in replies) + + def test_resend_unknown_message_is_noop(self, bound_manager, widget): + tid, msgs = _seed_thread(bound_manager) + bound_manager._on_resend_from( + {"messageId": "ghost", "threadId": tid}, + "chat:resend-from", + "", + ) + assert widget.get_events("chat:messages-deleted") == [] + # Original thread is intact + assert [m["id"] for m in bound_manager._threads[tid]] == [m["id"] for m in msgs] - def gen(): - yield ToolCallResponse(tool_id="tc1", name="search", arguments={"q": "test"}) + def test_resend_targeting_assistant_message_is_noop(self, bound_manager, widget): + """Only user messages can be resent; assistant ids are ignored.""" + tid, msgs = _seed_thread(bound_manager) + bound_manager._on_resend_from( + {"messageId": "msg_asst_1", "threadId": tid}, + "chat:resend-from", + "", + ) + assert widget.get_events("chat:messages-deleted") == [] + assert [m["id"] for m in bound_manager._threads[tid]] == [m["id"] for m in msgs] - 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" +class TestUserMessageStoresId: + """The frontend-generated messageId must round-trip into thread storage.""" - def test_tool_result_response(self, bound_manager, widget): + def test_user_message_uses_provided_id(self, bound_manager): 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 + bound_manager._on_user_message( + {"messageId": "msg_provided_42", "text": "hi", "threadId": tid}, + "chat:user-message", + "", + ) + time.sleep(0.2) + first = bound_manager._threads[tid][0] + assert first["id"] == "msg_provided_42" + assert first["role"] == "user" - def test_citation_response(self, bound_manager, widget): + def test_user_message_generates_id_if_absent(self, bound_manager): 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" + bound_manager._on_user_message( + {"text": "hi", "threadId": tid}, + "chat:user-message", + "", + ) + time.sleep(0.2) + first = bound_manager._threads[tid][0] + assert first["id"].startswith("msg_") - def test_artifact_response(self, bound_manager, widget): + def test_assistant_message_carries_id(self, bound_manager): tid = bound_manager.active_thread_id - cancel = threading.Event() + bound_manager._on_user_message( + {"text": "hi", "threadId": tid}, + "chat:user-message", + "", + ) + time.sleep(0.3) + # Echo handler completes synchronously; assistant message should be in thread + msgs = bound_manager._threads[tid] + asst = [m for m in msgs if m.get("role") == "assistant"] + assert asst + assert asst[0]["id"].startswith("msg_") - def gen(): - yield ArtifactResponse(title="code.py", content="print(1)", language="python") - bound_manager._handle_stream(gen(), "msg_001", tid, cancel) +class TestWidgetAttachmentCarriesWidgetId: + """Every @-mention attachment must surface the widget_id explicitly so an + LLM agent reading the @-context can use it directly in MCP tool calls.""" - artifacts = widget.get_events("chat:artifact") - assert len(artifacts) == 1 - assert artifacts[0]["title"] == "code.py" - assert artifacts[0]["language"] == "python" + def _make_mgr(self) -> ChatManager: + m = ChatManager(handler=echo_handler, enable_context=True) + m.bind(FakeWidget()) + return m - def test_cancellation(self, bound_manager, widget): - tid = bound_manager.active_thread_id - cancel = threading.Event() + def test_registered_source_with_getdata_content_carries_widget_id(self): + mgr = self._make_mgr() + mgr.register_context_source("chart", "chart") + att = mgr._resolve_widget_attachment( + "chart", content="symbol: AAPL\ninterval: 1d", name="chart" + ) + assert att is not None + # The attachment content always starts with widget_id: + first_line = att.content.splitlines()[0] + assert first_line == "widget_id: chart" + # The original getData payload is preserved after the header + assert "symbol: AAPL" in att.content + assert att.source == "chart" + assert att.name == "@chart" + + def test_registered_source_without_getdata_still_carries_widget_id(self): + mgr = self._make_mgr() + mgr.register_context_source("chart", "chart") + att = mgr._resolve_widget_attachment("chart") + assert att is not None + # Even with no JS-side getData payload, widget_id is in the content + assert "widget_id: chart" in att.content + assert att.source == "chart" + + def test_unregistered_widget_id_still_yields_attachment_with_id(self): + mgr = self._make_mgr() + # No register_context_source call — but the user mentioned an unknown + # widgetId from the frontend. The attachment must still surface it. + att = mgr._resolve_widget_attachment("some-other-widget") + assert att is not None + assert "widget_id: some-other-widget" in att.content + assert att.source == "some-other-widget" - def gen(): - yield "partial " - cancel.set() - yield "ignored" + def test_resolve_attachments_dispatches_to_widget_helper(self): + mgr = self._make_mgr() + mgr.register_context_source("chart", "chart") + attachments = mgr._resolve_attachments( + [ + { + "type": "widget", + "widgetId": "chart", + "name": "chart", + "content": "symbol: AAPL", + }, + ] + ) + assert len(attachments) == 1 + att = attachments[0] + assert att.type == "widget" + assert att.source == "chart" + # widget_id header is the bridge between @-context and MCP tool calls + assert att.content.splitlines()[0] == "widget_id: chart" + + +class TestRegisteredContextAutoAttaches: + """Every registered context source rides along on every user message + automatically — the agent never has to remember a widget id between + turns and the user never has to repeat @.""" + + def _make_mgr(self) -> ChatManager: + m = ChatManager(handler=echo_handler, enable_context=True) + m.bind(FakeWidget()) + return m + + def test_no_auto_attach_when_context_disabled(self): + m = ChatManager(handler=echo_handler, enable_context=False) + m.bind(FakeWidget()) + m.register_context_source("chart", "chart") + merged = m._auto_attach_context_sources([]) + assert merged == [] + + def test_no_auto_attach_when_no_sources_registered(self): + mgr = self._make_mgr() + merged = mgr._auto_attach_context_sources([]) + assert merged == [] + + def test_registered_source_is_auto_attached(self): + mgr = self._make_mgr() + mgr.register_context_source("chart", "chart") + merged = mgr._auto_attach_context_sources([]) + assert len(merged) == 1 + att = merged[0] + assert att.source == "chart" + assert att.auto_attached is True + assert "widget_id: chart" in att.content + + def test_explicit_mention_takes_precedence(self): + mgr = self._make_mgr() + mgr.register_context_source("chart", "chart") + explicit = Attachment( + type="widget", + name="@chart", + content="widget_id: chart\n\nsymbol: AAPL", + source="chart", + auto_attached=False, + ) + merged = mgr._auto_attach_context_sources([explicit]) + # Only the explicit mention survives — no duplicate + assert len(merged) == 1 + assert merged[0] is explicit + assert merged[0].auto_attached is False + + def test_auto_attach_runs_in_user_message_flow(self): + """End-to-end: a user message with NO explicit @-mention should + still cause the registered chart context to ride along.""" + mgr = self._make_mgr() + mgr.register_context_source("chart", "chart") + captured: list[Any] = [] - bound_manager._handle_stream(gen(), "msg_001", tid, cancel) + def handler(messages, ctx): + captured.append(list(ctx.attachments)) + return "ok" - chunks = widget.get_events("chat:stream-chunk") - # First chunk + done-with-stopped - done_chunks = [c for c in chunks if c.get("done")] - assert any(c.get("stopped") for c in done_chunks) + mgr._handler = handler + mgr._on_user_message( + {"text": "hi", "threadId": mgr.active_thread_id}, + "chat:user-message", + "", + ) + time.sleep(0.3) + assert captured, "handler was never invoked" + attachments = captured[0] + # Exactly one auto-attached widget — the chart + assert len(attachments) == 1 + att = attachments[0] + assert att.source == "chart" + assert att.auto_attached is True + assert "widget_id: chart" in att.content + + def test_inject_context_skips_ui_card_for_auto_attachments(self, widget): + """Auto-attached context must NOT spam an ``attach_widget`` card + on every turn — only explicit @-mentions get the visible card.""" + mgr = ChatManager(handler=echo_handler, enable_context=True) + mgr.bind(widget) + mgr.register_context_source("chart", "chart") + mgr._on_user_message( + {"text": "hi", "threadId": mgr.active_thread_id}, + "chat:user-message", + "", + ) + time.sleep(0.3) + # No attach_widget tool-call cards from the auto-attach + attach_cards = [ + d + for e, d in widget.events + if e == "chat:tool-call" and d.get("name", "").startswith("attach_") + ] + assert attach_cards == [] - 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 - - # Thinking is NOT in the stored text - msgs = bound_manager._threads[tid] - assert msgs[0]["text"] == "Answer" - - def test_rich_handler_all_types(self, widget): - """Verify rich_handler emits all protocol types.""" - mgr = ChatManager(handler=rich_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 -# ============================================================================= - - -class TestThreadCRUD: - """Test thread create, switch, delete, rename.""" - - 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" - - 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 - - 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 - - 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 - - -# ============================================================================= -# _on_settings_change_event Tests -# ============================================================================= - - -class TestSettingsChange: - """Test settings change handler.""" - - 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): - 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 -# ============================================================================= - - -class TestSlashCommand: - """Test slash command handler.""" - - def test_builtin_clear(self, bound_manager, widget): - tid = bound_manager.active_thread_id - bound_manager._threads[tid] = [{"role": "user", "text": "hi"}] - - 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 test_inject_context_includes_widget_id_in_user_message(self, widget): + """The widget_id header must be embedded in the user-message text + the agent receives so the agent can read it directly.""" + mgr = ChatManager(handler=echo_handler, enable_context=True) + mgr.bind(widget) + mgr.register_context_source("chart", "chart") + captured: list[str] = [] def handler(messages, ctx): - received["ctx"] = ctx + captured.append(messages[-1].get("text", "")) 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._handler = handler mgr._on_user_message( - { - "text": "analyze", - "attachments": [ - {"type": "file", "name": "data.csv", "content": "x,y\n1,2"}, - ], - }, + {"text": "switch to MSFT", "threadId": mgr.active_thread_id}, "chat:user-message", "", ) - time.sleep(0.5) - - assert received["results"] == ["x,y\n1,2"] + time.sleep(0.3) + assert captured + injected = captured[0] + # Both the @-context block and the original user text + assert "widget_id: chart" in injected + assert "switch to MSFT" in injected diff --git a/pywry/tests/test_chat_protocol.py b/pywry/tests/test_chat_protocol.py new file mode 100644 index 0000000..4ec651b --- /dev/null +++ b/pywry/tests/test_chat_protocol.py @@ -0,0 +1,756 @@ +"""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) + # In-progress statuses go out as ``chat:tool-call`` (creates the + # collapsible tool card in the UI); terminal statuses go out as + # ``chat:tool-result`` (fills the card's result body). Same + # ``toolCallId`` links the two events. + start_events = widget.get_events("chat:tool-call") + result_events = widget.get_events("chat:tool-result") + assert len(start_events) == 1 + assert start_events[0]["status"] == "in_progress" + assert start_events[0]["toolCallId"] == "c1" + assert len(result_events) == 1 + assert result_events[0]["status"] == "completed" + assert result_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) + # Non-terminal statuses (pending / in_progress) emit chat:tool-call; + # terminal statuses (completed / failed) emit chat:tool-result. + start_events = widget.get_events("chat:tool-call") + result_events = widget.get_events("chat:tool-result") + assert [e["status"] for e in start_events] == ["pending", "in_progress"] + assert [e["status"] for e in result_events] == ["completed"] + assert all(e["toolCallId"] == "c1" for e in start_events + result_events) + assert all(e["kind"] == "read" for e in start_events + result_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) + # Failure routes through chat:tool-result with isError=True. + result_events = widget.get_events("chat:tool-result") + assert len(result_events) == 1 + assert result_events[0]["status"] == "failed" + assert result_events[0]["isError"] is True + assert result_events[0]["toolCallId"] == "c2" + + +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..732f6b8 --- /dev/null +++ b/pywry/tests/test_deepagent_provider.py @@ -0,0 +1,508 @@ +"""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_write_todos_langgraph_command_output_produces_plan_update(self): + """Deep Agents' ``write_todos`` returns a LangGraph ``Command`` with + ``update={"todos": [...]}`` — the extractor must pull the list out + of that shape, not just the legacy plain-JSON list. + """ + + class _Command: + def __init__(self, update: dict) -> None: + self.update = update + + todos = [ + {"content": "Switch ticker to BTC-USD", "status": "completed"}, + {"content": "Change interval to 1m", "status": "in_progress"}, + ] + events = [ + make_event("on_tool_start", name="write_todos", run_id="tc9"), + make_event( + "on_tool_end", + name="write_todos", + run_id="tc9", + data={"output": _Command(update={"todos": 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 [e.content for e in plan_updates[0].entries] == [ + "Switch ticker to BTC-USD", + "Change interval to 1m", + ] + assert [e.status for e in plan_updates[0].entries] == ["completed", "in_progress"] + # The plan card IS the visualization — no raw Command repr should + # double-render as a tool-call card. + tool_completed = [ + u + for u in updates + if isinstance(u, ToolCallUpdate) and u.status == "completed" and u.name == "write_todos" + ] + assert tool_completed == [] + + @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) + + +# ============================================================================= +# MCP integration / recursion_limit / truncate_session +# ============================================================================= + + +class TestDeepagentProviderConstructor: + def test_default_recursion_limit_is_50(self): + provider = DeepagentProvider(model="openai:gpt-4o") + assert provider._recursion_limit == 50 + + def test_custom_recursion_limit(self): + provider = DeepagentProvider(model="openai:gpt-4o", recursion_limit=200) + assert provider._recursion_limit == 200 + + def test_mcp_servers_default_empty(self): + provider = DeepagentProvider(model="openai:gpt-4o") + assert provider._mcp_servers == {} + assert provider._mcp_tools == [] + + def test_mcp_servers_stored_on_init(self): + servers = { + "pywry": {"transport": "streamable_http", "url": "http://127.0.0.1:8765/mcp"}, + } + provider = DeepagentProvider(model="openai:gpt-4o", mcp_servers=servers) + assert provider._mcp_servers == servers + + +class TestRecursionLimitInPromptConfig: + @pytest.mark.asyncio + async def test_recursion_limit_passed_in_config(self): + captured: dict = {} + + class _Capturing: + def astream_events(self, _input, config, version="v2"): + captured["config"] = config + + async def _empty(): + if False: + yield + + return _empty() + + provider = DeepagentProvider( + agent=_Capturing(), + auto_checkpointer=False, + auto_store=False, + recursion_limit=42, + ) + await provider.initialize(ClientCapabilities()) + sid = await provider.new_session("/tmp") + async for _ in provider.prompt(sid, [TextPart(text="hi")]): + pass + assert captured["config"]["recursion_limit"] == 42 + assert captured["config"]["configurable"]["thread_id"] + + +class TestNewSessionMcpServers: + @pytest.mark.asyncio + async def test_new_session_merges_stdio_descriptor(self): + provider = DeepagentProvider( + agent=FakeAgent([]), + auto_checkpointer=False, + auto_store=False, + ) + await provider.initialize(ClientCapabilities()) + await provider.new_session( + "/tmp", + mcp_servers=[ + {"name": "fs", "command": "uvx", "args": ["mcp-server-filesystem", "/tmp"]}, + ], + ) + assert "fs" in provider._mcp_servers + entry = provider._mcp_servers["fs"] + assert entry["transport"] == "stdio" + assert entry["command"] == "uvx" + assert entry["args"] == ["mcp-server-filesystem", "/tmp"] + # Forces a rebuild on next prompt + assert provider._agent is None + assert provider._mcp_tools == [] + + @pytest.mark.asyncio + async def test_new_session_merges_http_descriptor(self): + provider = DeepagentProvider( + agent=FakeAgent([]), + auto_checkpointer=False, + auto_store=False, + ) + await provider.initialize(ClientCapabilities()) + await provider.new_session( + "/tmp", + mcp_servers=[ + {"name": "pywry", "url": "http://127.0.0.1:8765/mcp"}, + ], + ) + entry = provider._mcp_servers["pywry"] + assert entry["transport"] == "streamable_http" + assert entry["url"] == "http://127.0.0.1:8765/mcp" + + @pytest.mark.asyncio + async def test_new_session_no_mcp_keeps_existing_agent(self): + agent = FakeAgent([]) + provider = DeepagentProvider( + agent=agent, + auto_checkpointer=False, + auto_store=False, + ) + await provider.initialize(ClientCapabilities()) + await provider.new_session("/tmp") + # Without mcp_servers param the agent is preserved + assert provider._agent is agent + + +class TestLoadMcpTools: + def test_returns_empty_when_no_servers_configured(self): + provider = DeepagentProvider(model="openai:gpt-4o") + assert provider._load_mcp_tools() == [] + + +class TestTruncateSession: + def test_no_op_when_checkpointer_missing(self): + provider = DeepagentProvider( + model="openai:gpt-4o", + auto_checkpointer=False, + auto_store=False, + ) + # Should not raise even without a checkpointer + provider.truncate_session("session-1", []) + + def test_calls_delete_thread_when_available(self): + deleted: list[str] = [] + + class _Saver: + def delete_thread(self, thread_id: str) -> None: + deleted.append(thread_id) + + provider = DeepagentProvider( + model="openai:gpt-4o", + checkpointer=_Saver(), + auto_checkpointer=False, + auto_store=False, + ) + provider._sessions["sess-1"] = "thread-A" + provider.truncate_session("sess-1", []) + assert deleted == ["thread-A"] + + def test_falls_back_to_dict_storage_pop(self): + class _DictSaver: + def __init__(self) -> None: + self.storage: dict[str, dict] = {"thread-A": {"x": 1}, "thread-B": {"y": 2}} + + saver = _DictSaver() + provider = DeepagentProvider( + model="openai:gpt-4o", + checkpointer=saver, + auto_checkpointer=False, + auto_store=False, + ) + provider._sessions["sess-1"] = "thread-A" + provider.truncate_session("sess-1", []) + assert "thread-A" not in saver.storage + assert "thread-B" in saver.storage # other threads untouched + + +class TestAutoCheckpointerInBuildAgent: + """The auto-checkpointer must be set up by _build_agent so callers that + bypass the async initialize() still get conversation persistence.""" + + def test_build_agent_creates_checkpointer_when_missing(self, monkeypatch): + # Pre-empt the actual create_deep_agent import; we only care about + # the side-effect on self._checkpointer. + provider = DeepagentProvider( + model="openai:gpt-4o", + auto_checkpointer=True, + ) + assert provider._checkpointer is None + + # Patch create_deep_agent to a stub so _build_agent doesn't need + # the real deepagents package. + import sys + import types + + fake_module = types.ModuleType("deepagents") + fake_module.create_deep_agent = lambda **kwargs: object() + monkeypatch.setitem(sys.modules, "deepagents", fake_module) + + provider._build_agent() + # Checkpointer was set as a side-effect + assert provider._checkpointer is not None + + def test_build_agent_does_not_overwrite_existing_checkpointer(self, monkeypatch): + sentinel = object() + provider = DeepagentProvider( + model="openai:gpt-4o", + checkpointer=sentinel, + auto_checkpointer=True, + ) + + import sys + import types + + fake_module = types.ModuleType("deepagents") + fake_module.create_deep_agent = lambda **kwargs: object() + monkeypatch.setitem(sys.modules, "deepagents", fake_module) + + provider._build_agent() + assert provider._checkpointer is sentinel diff --git a/pywry/tests/test_mcp_state_helpers.py b/pywry/tests/test_mcp_state_helpers.py new file mode 100644 index 0000000..e774bc9 --- /dev/null +++ b/pywry/tests/test_mcp_state_helpers.py @@ -0,0 +1,234 @@ +"""Unit tests for the MCP state-module helpers. + +Covers: +- ``request_response`` — the request/response correlation helper used + by tvchart_list_indicators / tvchart_request_state. +- ``capture_widget_events`` — passive listener wiring used at chart + creation to bucket JS-side events into the MCP events dict. +""" + +# pylint: disable=protected-access + +from __future__ import annotations + +import threading + +from typing import Any +from unittest.mock import MagicMock + +import pytest + +from pywry.mcp.state import capture_widget_events, request_response + + +class _FakeWidget: + """Minimal widget surface that mimics ``on``/``emit`` behaviour.""" + + def __init__(self) -> None: + self.handlers: dict[str, list[Any]] = {} + self.emitted: list[tuple[str, dict[str, Any]]] = [] + + def on(self, event: str, handler: Any) -> None: + self.handlers.setdefault(event, []).append(handler) + + def emit(self, event: str, payload: dict[str, Any]) -> None: + self.emitted.append((event, payload)) + + +# --------------------------------------------------------------------------- +# request_response +# --------------------------------------------------------------------------- + + +def test_request_response_round_trip_returns_matching_payload() -> None: + """A response with the matching correlation token is returned to the caller.""" + widget = _FakeWidget() + + def emit(event: str, payload: dict[str, Any]) -> None: + widget.emitted.append((event, payload)) + # Echo back via the registered listener + for handler in widget.handlers.get("widget:state-response", []): + handler({"context": payload["context"], "value": 42}, "", "") + + widget.emit = emit # type: ignore[assignment] + out = request_response( + widget, + "widget:state-request", + "widget:state-response", + {}, + timeout=1.0, + ) + assert out is not None + assert out["value"] == 42 + # The request received the auto-generated correlation token + assert "context" in widget.emitted[0][1] + + +def test_request_response_returns_none_on_timeout() -> None: + widget = _FakeWidget() + out = request_response( + widget, + "widget:state-request", + "widget:state-response", + {}, + timeout=0.05, + ) + assert out is None + + +def test_request_response_ignores_mismatched_correlation_tokens() -> None: + """Stray responses with a different correlation token must not unblock the wait.""" + widget = _FakeWidget() + + def emit(event: str, payload: dict[str, Any]) -> None: + widget.emitted.append((event, payload)) + # Send a response with the WRONG token — caller must time out + for handler in widget.handlers.get("widget:state-response", []): + handler({"context": "wrong-token", "value": 99}, "", "") + + widget.emit = emit # type: ignore[assignment] + out = request_response( + widget, + "widget:state-request", + "widget:state-response", + {}, + timeout=0.1, + ) + assert out is None + + +def test_request_response_supports_custom_correlation_keys() -> None: + widget = _FakeWidget() + + def emit(event: str, payload: dict[str, Any]) -> None: + widget.emitted.append((event, payload)) + for handler in widget.handlers.get("widget:state-response", []): + handler({"requestId": payload["requestId"], "ok": True}, "", "") + + widget.emit = emit # type: ignore[assignment] + out = request_response( + widget, + "widget:state-request", + "widget:state-response", + {}, + correlation_key="requestId", + timeout=1.0, + ) + assert out is not None + assert out["ok"] is True + + +def test_request_response_concurrent_requests_isolated() -> None: + """Two concurrent round-trips must each receive their own response.""" + widget = _FakeWidget() + pending: dict[str, threading.Event] = {} + + def emit(event: str, payload: dict[str, Any]) -> None: + token = payload["context"] + widget.emitted.append((event, payload)) + # Deliver the response on a worker thread — the listener filters + # by correlation token so cross-talk is not allowed. + evt = pending.setdefault(token, threading.Event()) + + def _deliver() -> None: + evt.wait(0.05) + for handler in widget.handlers.get("widget:state-response", []): + handler({"context": token, "echo": token}, "", "") + + threading.Thread(target=_deliver, daemon=True).start() + # Release the deliverer immediately + evt.set() + + widget.emit = emit # type: ignore[assignment] + a = request_response(widget, "req", "widget:state-response", {}, timeout=1.0) + b = request_response(widget, "req", "widget:state-response", {}, timeout=1.0) + assert a is not None and b is not None + assert a["echo"] != b["echo"] + + +def test_request_response_ignores_non_dict_responses() -> None: + widget = _FakeWidget() + + def emit(event: str, payload: dict[str, Any]) -> None: + widget.emitted.append((event, payload)) + # Non-dict response — listener must reject it + for handler in widget.handlers.get("widget:state-response", []): + handler("not-a-dict", "", "") + # Then a real one + for handler in widget.handlers.get("widget:state-response", []): + handler({"context": payload["context"], "ok": True}, "", "") + + widget.emit = emit # type: ignore[assignment] + out = request_response(widget, "req", "widget:state-response", {}, timeout=1.0) + assert out == {"context": out["context"], "ok": True} + + +# --------------------------------------------------------------------------- +# capture_widget_events +# --------------------------------------------------------------------------- + + +def test_capture_widget_events_buckets_events_by_widget_id() -> None: + widget = _FakeWidget() + events: dict[str, list[dict[str, Any]]] = {} + capture_widget_events(widget, "chart-1", events, ["tvchart:click", "tvchart:drawing-added"]) + + # Simulate the JS frontend firing events + widget.handlers["tvchart:click"][0]({"x": 1, "y": 2}, "", "") + widget.handlers["tvchart:drawing-added"][0]({"id": "d-1"}, "", "") + + assert events["chart-1"] == [ + {"event": "tvchart:click", "data": {"x": 1, "y": 2}}, + {"event": "tvchart:drawing-added", "data": {"id": "d-1"}}, + ] + + +def test_capture_widget_events_registers_handler_per_event_name() -> None: + widget = _FakeWidget() + events: dict[str, list[dict[str, Any]]] = {} + capture_widget_events(widget, "chart-1", events, ["a", "b", "c"]) + assert set(widget.handlers.keys()) == {"a", "b", "c"} + + +def test_capture_widget_events_ignores_widget_on_failure() -> None: + """If widget.on raises for one event the others should still register.""" + widget = MagicMock() + calls: list[str] = [] + + def fake_on(event: str, _handler: Any) -> None: + calls.append(event) + if event == "boom": + raise RuntimeError("nope") + + widget.on = fake_on + events: dict[str, list[dict[str, Any]]] = {} + capture_widget_events(widget, "chart-1", events, ["ok-1", "boom", "ok-2"]) + assert calls == ["ok-1", "boom", "ok-2"] + + +def test_capture_widget_events_separate_widget_buckets() -> None: + widget_a = _FakeWidget() + widget_b = _FakeWidget() + events: dict[str, list[dict[str, Any]]] = {} + capture_widget_events(widget_a, "chart-A", events, ["tvchart:click"]) + capture_widget_events(widget_b, "chart-B", events, ["tvchart:click"]) + widget_a.handlers["tvchart:click"][0]({"x": 1}, "", "") + widget_b.handlers["tvchart:click"][0]({"x": 2}, "", "") + assert events["chart-A"] == [{"event": "tvchart:click", "data": {"x": 1}}] + assert events["chart-B"] == [{"event": "tvchart:click", "data": {"x": 2}}] + + +# --------------------------------------------------------------------------- +# pytest setup — ensure fixtures don't leak the global pending dicts +# --------------------------------------------------------------------------- + + +@pytest.fixture(autouse=True) +def _clear_pending() -> None: + from pywry.mcp import state as mcp_state + + mcp_state._pending_responses.clear() + mcp_state._pending_events.clear() + yield + mcp_state._pending_responses.clear() + mcp_state._pending_events.clear() diff --git a/pywry/tests/test_mcp_tvchart_tools.py b/pywry/tests/test_mcp_tvchart_tools.py new file mode 100644 index 0000000..0aa5d6c --- /dev/null +++ b/pywry/tests/test_mcp_tvchart_tools.py @@ -0,0 +1,676 @@ +"""Unit tests for the 32 first-class TVChart MCP tools. + +Each handler resolves a registered widget, emits a tvchart:* event with +a derived payload, and returns a uniform result dict. These tests +verify the schema → handler → emit chain for every tool. +""" + +# pylint: disable=protected-access + +from __future__ import annotations + +from typing import Any +from unittest.mock import MagicMock + +import pytest + +from pywry.mcp import handlers as mcp_handlers, state as mcp_state +from pywry.mcp.handlers import HandlerContext + + +def _make_ctx(args: dict[str, Any]) -> HandlerContext: + return HandlerContext( + args=args, + events={}, + make_callback=lambda _wid: lambda *_a, **_kw: None, + headless=False, + ) + + +def _find_emit(widget: MagicMock, event_name: str) -> tuple[str, dict[str, Any]] | None: + """Return the (name, payload) of the first ``widget.emit(event_name, ...)`` call. + + Handlers that confirm mutations via state polling may emit helper + events (``tvchart:request-state``) before or after the mutation + event; tests that care about the mutation payload should locate + it by name rather than by positional index. + """ + for call in widget.emit.call_args_list: + args = call[0] + if args and args[0] == event_name: + name = args[0] + payload = args[1] if len(args) > 1 else {} + return name, payload + return None + + +@pytest.fixture +def widget(monkeypatch: pytest.MonkeyPatch) -> MagicMock: + """A fresh widget mock registered under ``"chart"`` for the duration of a test. + + Mutation handlers (``tvchart_symbol_search`` / ``_compare`` / + ``_change_interval``) block on the real ``tvchart:data-settled`` + round-trip, which never fires against a mocked widget. Patch + ``_wait_for_data_settled`` to return ``None`` immediately so tests + exercise the emit + result-shape contract without waiting on a + real frontend. Tests that want to assert the confirmed-success + path should patch it themselves with a stand-in state. + """ + mcp_state._widgets.clear() + w = MagicMock() + mcp_state._widgets["chart"] = w + monkeypatch.setattr( + mcp_handlers, + "_wait_for_data_settled", + lambda *_a, **_kw: None, + ) + monkeypatch.setattr( + mcp_handlers, + "_fetch_tvchart_state", + lambda *_a, **_kw: None, + ) + yield w + mcp_state._widgets.clear() + + +# --------------------------------------------------------------------------- +# Tool catalog / dispatch coverage +# --------------------------------------------------------------------------- + + +def test_every_tvchart_tool_has_a_handler() -> None: + """No tvchart_* tool may ship without a dispatch entry.""" + from pywry.mcp.tools import get_tools + + schema_names = {t.name for t in get_tools() if t.name.startswith("tvchart_")} + handler_names = {n for n in mcp_handlers._HANDLERS if n.startswith("tvchart_")} + assert schema_names == handler_names + assert len(schema_names) == 32 # guard against accidental drops + + +def test_every_tvchart_tool_requires_widget_id() -> None: + """Every tvchart_* tool must take widget_id.""" + from pywry.mcp.tools import get_tools + + for tool in get_tools(): + if not tool.name.startswith("tvchart_"): + continue + required = tool.inputSchema.get("required", []) + assert "widget_id" in required, f"{tool.name} missing widget_id" + + +def test_unknown_widget_returns_error(widget: MagicMock) -> None: + """Resolving an unregistered widget yields a clear error listing + the registered widgets so the caller can recover.""" + ctx = _make_ctx({"widget_id": "ghost"}) + result = mcp_handlers._handle_tvchart_undo(ctx) + assert "error" in result + assert "ghost" in result["error"] + assert "chart" in result["error"] # the registered widget id + widget.emit.assert_not_called() + + +def test_missing_widget_id_with_single_widget_auto_resolves(widget: MagicMock) -> None: + """When exactly one widget is registered, missing ``widget_id`` is + a no-op — the framework resolves it from the registry, the agent + doesn't need to repeat what the server already knows.""" + ctx = _make_ctx({}) # no widget_id at all + result = mcp_handlers._handle_tvchart_undo(ctx) + assert "error" not in result + assert result["widget_id"] == "chart" + widget.emit.assert_called_once() + + +def test_missing_widget_id_with_multiple_widgets_returns_error() -> None: + """When multiple widgets are registered, the call is genuinely + ambiguous and the handler returns a clear error listing the + candidates so the agent can self-correct.""" + mcp_state._widgets.clear() + mcp_state._widgets["chart_a"] = MagicMock() + mcp_state._widgets["chart_b"] = MagicMock() + try: + ctx = _make_ctx({}) # no widget_id, ambiguous + result = mcp_handlers._handle_tvchart_undo(ctx) + assert "error" in result + assert "widget_id" in result["error"].lower() + assert "chart_a" in result["error"] + assert "chart_b" in result["error"] + finally: + mcp_state._widgets.clear() + + +def test_missing_widget_id_with_no_widgets_returns_error() -> None: + """With nothing registered, the handler tells the caller that no + widgets exist instead of dumping a stack trace.""" + mcp_state._widgets.clear() + ctx = _make_ctx({}) + result = mcp_handlers._handle_tvchart_undo(ctx) + assert "error" in result + assert "widget_id" in result["error"].lower() + assert "no widgets" in result["error"].lower() + + +def test_send_event_missing_widget_id_with_single_widget_auto_resolves(widget: MagicMock) -> None: + """The generic send_event tool also benefits from auto-resolution.""" + ctx = _make_ctx({"event_type": "tvchart:symbol-search", "data": {"query": "MSFT"}}) + result = mcp_handlers._handle_send_event(ctx) + assert "error" not in result + widget.emit.assert_called_once() + + +def test_send_event_missing_event_type_returns_error(widget: MagicMock) -> None: + ctx = _make_ctx({"widget_id": "chart"}) # no event_type + result = mcp_handlers._handle_send_event(ctx) + assert "error" in result + assert "event_type" in result["error"].lower() + widget.emit.assert_not_called() + + +# --------------------------------------------------------------------------- +# Data & series +# --------------------------------------------------------------------------- + + +def test_update_series_emits_tvchart_update(widget: MagicMock) -> None: + bars = [{"time": 1, "open": 1, "high": 2, "low": 0.5, "close": 1.5}] + ctx = _make_ctx({"widget_id": "chart", "bars": bars, "series_id": "main"}) + out = mcp_handlers._handle_tvchart_update_series(ctx) + assert out["event_type"] == "tvchart:update" + widget.emit.assert_called_once() + event_name, payload = widget.emit.call_args[0] + assert event_name == "tvchart:update" + assert payload["bars"] == bars + assert payload["seriesId"] == "main" + assert payload["fitContent"] is True + + +def test_update_series_passes_chart_id_when_set(widget: MagicMock) -> None: + ctx = _make_ctx( + { + "widget_id": "chart", + "bars": [], + "chart_id": "alt-chart", + "fit_content": False, + } + ) + mcp_handlers._handle_tvchart_update_series(ctx) + payload = widget.emit.call_args[0][1] + assert payload["chartId"] == "alt-chart" + assert payload["fitContent"] is False + + +def test_update_bar_emits_tvchart_stream(widget: MagicMock) -> None: + bar = {"time": 1, "open": 1, "high": 2, "low": 0.5, "close": 1.5, "volume": 100} + mcp_handlers._handle_tvchart_update_bar(_make_ctx({"widget_id": "chart", "bar": bar})) + name, payload = widget.emit.call_args[0] + assert name == "tvchart:stream" + assert payload["bar"] == bar + + +def test_add_series_emits_tvchart_add_series(widget: MagicMock) -> None: + bars = [{"time": 1, "value": 10}, {"time": 2, "value": 11}] + ctx = _make_ctx( + { + "widget_id": "chart", + "series_id": "overlay-1", + "bars": bars, + "series_type": "Line", + "series_options": {"color": "#f00"}, + } + ) + out = mcp_handlers._handle_tvchart_add_series(ctx) + assert out["series_id"] == "overlay-1" + name, payload = widget.emit.call_args[0] + assert name == "tvchart:add-series" + assert payload == { + "seriesId": "overlay-1", + "bars": bars, + "seriesType": "Line", + "seriesOptions": {"color": "#f00"}, + } + + +def test_remove_series_emits_tvchart_remove_series(widget: MagicMock) -> None: + out = mcp_handlers._handle_tvchart_remove_series( + _make_ctx({"widget_id": "chart", "series_id": "overlay-1"}) + ) + assert out["series_id"] == "overlay-1" + name, payload = widget.emit.call_args[0] + assert name == "tvchart:remove-series" + assert payload == {"seriesId": "overlay-1"} + + +def test_add_markers_emits_tvchart_add_markers(widget: MagicMock) -> None: + markers = [ + {"time": 1, "position": "aboveBar", "color": "#f00", "shape": "arrowDown", "text": "sell"} + ] + mcp_handlers._handle_tvchart_add_markers( + _make_ctx({"widget_id": "chart", "markers": markers, "series_id": "main"}) + ) + name, payload = widget.emit.call_args[0] + assert name == "tvchart:add-markers" + assert payload["markers"] == markers + assert payload["seriesId"] == "main" + + +def test_add_price_line_uses_defaults(widget: MagicMock) -> None: + mcp_handlers._handle_tvchart_add_price_line(_make_ctx({"widget_id": "chart", "price": 170.5})) + name, payload = widget.emit.call_args[0] + assert name == "tvchart:add-price-line" + assert payload["price"] == 170.5 + assert payload["color"] == "#2196F3" + assert payload["lineWidth"] == 1 + assert payload["title"] == "" + + +def test_apply_options_strips_none_keys(widget: MagicMock) -> None: + chart_options = {"timeScale": {"secondsVisible": False}} + mcp_handlers._handle_tvchart_apply_options( + _make_ctx({"widget_id": "chart", "chart_options": chart_options}) + ) + name, payload = widget.emit.call_args[0] + assert name == "tvchart:apply-options" + assert payload == {"chartOptions": chart_options} + + +# --------------------------------------------------------------------------- +# Built-in indicators +# --------------------------------------------------------------------------- + + +def test_add_indicator_passes_all_options(widget: MagicMock) -> None: + ctx = _make_ctx( + { + "widget_id": "chart", + "name": "Bollinger Bands", + "period": 20, + "color": "#ff0", + "source": "close", + "method": "SMA", + "multiplier": 2.0, + "ma_type": "SMA", + "offset": 0, + } + ) + mcp_handlers._handle_tvchart_add_indicator(ctx) + name, payload = widget.emit.call_args[0] + assert name == "tvchart:add-indicator" + assert payload == { + "name": "Bollinger Bands", + "period": 20, + "color": "#ff0", + "source": "close", + "method": "SMA", + "multiplier": 2.0, + "maType": "SMA", + "offset": 0, + } + + +def test_add_indicator_omits_unset_optionals(widget: MagicMock) -> None: + mcp_handlers._handle_tvchart_add_indicator(_make_ctx({"widget_id": "chart", "name": "RSI"})) + payload = widget.emit.call_args[0][1] + assert payload == {"name": "RSI"} + + +def test_remove_indicator_emits_remove(widget: MagicMock) -> None: + out = mcp_handlers._handle_tvchart_remove_indicator( + _make_ctx({"widget_id": "chart", "series_id": "ind_sma_99"}) + ) + assert out["series_id"] == "ind_sma_99" + payload = widget.emit.call_args[0][1] + assert payload == {"seriesId": "ind_sma_99"} + + +def test_show_indicators_takes_no_payload(widget: MagicMock) -> None: + mcp_handlers._handle_tvchart_show_indicators(_make_ctx({"widget_id": "chart"})) + name, payload = widget.emit.call_args[0] + assert name == "tvchart:show-indicators" + assert payload == {} + + +def test_list_indicators_round_trip_returns_inventory(widget: MagicMock) -> None: + """list_indicators emits a request and waits for the response event.""" + captured_listener: dict[str, Any] = {} + + def fake_on(event: str, handler: Any) -> None: + captured_listener["event"] = event + captured_listener["handler"] = handler + + fake_payload = { + "context": None, + "indicators": [ + { + "seriesId": "ind_sma_1", + "name": "SMA", + "type": "sma", + "period": 50, + "color": "#2196F3", + } + ], + } + + def fake_emit(event: str, payload: dict[str, Any]) -> None: + # Simulate the JS frontend echoing the correlation token back + token = payload.get("context") + fake_payload["context"] = token + captured_listener["handler"](fake_payload, "", "") + + widget.on = fake_on + widget.emit = fake_emit + + out = mcp_handlers._handle_tvchart_list_indicators( + _make_ctx({"widget_id": "chart", "timeout": 1.0}) + ) + assert captured_listener["event"] == "tvchart:list-indicators-response" + assert out["indicators"][0]["name"] == "SMA" + + +def test_list_indicators_returns_error_on_timeout(widget: MagicMock) -> None: + widget.on = MagicMock() + widget.emit = MagicMock() # never echoes back + out = mcp_handlers._handle_tvchart_list_indicators( + _make_ctx({"widget_id": "chart", "timeout": 0.05}) + ) + assert "error" in out + + +# --------------------------------------------------------------------------- +# Symbol / interval / view +# --------------------------------------------------------------------------- + + +def test_symbol_search_with_query_and_auto_select(widget: MagicMock) -> None: + mcp_handlers._handle_tvchart_symbol_search( + _make_ctx({"widget_id": "chart", "query": "MSFT", "auto_select": True}) + ) + hit = _find_emit(widget, "tvchart:symbol-search") + assert hit is not None, "mutation event was never emitted" + _, payload = hit + assert payload == {"query": "MSFT", "autoSelect": True} + + +def test_symbol_search_default_no_query(widget: MagicMock) -> None: + mcp_handlers._handle_tvchart_symbol_search(_make_ctx({"widget_id": "chart"})) + hit = _find_emit(widget, "tvchart:symbol-search") + assert hit is not None, "mutation event was never emitted" + _, payload = hit + # auto_select defaults to True even without query + assert payload == {"autoSelect": True} + # No query → no mutation to confirm → no polling → only the mutation emit. + assert widget.emit.call_count == 1 + + +def test_compare_without_query_just_opens_dialog(widget: MagicMock) -> None: + """No query → no mutation to confirm, single emit, empty payload.""" + mcp_handlers._handle_tvchart_compare(_make_ctx({"widget_id": "chart"})) + hit = _find_emit(widget, "tvchart:compare") + assert hit is not None, "compare event was never emitted" + _, payload = hit + assert payload == {} + # No query → no pre-state snapshot, no polling → only the mutation emit. + assert widget.emit.call_count == 1 + + +def test_compare_with_query_emits_query_and_auto_add(widget: MagicMock) -> None: + """With a query, the handler emits it so the frontend auto-adds the match.""" + mcp_handlers._handle_tvchart_compare( + _make_ctx({"widget_id": "chart", "query": "GOOGL", "auto_add": True}) + ) + hit = _find_emit(widget, "tvchart:compare") + assert hit is not None + _, payload = hit + assert payload == {"query": "GOOGL", "autoAdd": True} + + +def test_compare_passes_symbol_type_and_exchange_filters(widget: MagicMock) -> None: + """``symbol_type`` / ``exchange`` narrow the datafeed search so SPY + resolves to the ETF instead of a near-prefix like SPYM.""" + mcp_handlers._handle_tvchart_compare( + _make_ctx( + { + "widget_id": "chart", + "query": "SPY", + "symbol_type": "etf", + "exchange": "NYSEARCA", + } + ) + ) + hit = _find_emit(widget, "tvchart:compare") + assert hit is not None + _, payload = hit + assert payload == { + "query": "SPY", + "autoAdd": True, + "symbolType": "etf", + "exchange": "NYSEARCA", + } + + +def test_symbol_search_passes_symbol_type_and_exchange_filters(widget: MagicMock) -> None: + """Same filter plumbing on ``tvchart_symbol_search`` for main-ticker changes.""" + mcp_handlers._handle_tvchart_symbol_search( + _make_ctx( + { + "widget_id": "chart", + "query": "SPY", + "auto_select": True, + "symbol_type": "etf", + "exchange": "NYSEARCA", + } + ) + ) + hit = _find_emit(widget, "tvchart:symbol-search") + assert hit is not None + _, payload = hit + assert payload == { + "query": "SPY", + "autoSelect": True, + "symbolType": "etf", + "exchange": "NYSEARCA", + } + + +def test_change_interval_passes_value(widget: MagicMock) -> None: + mcp_handlers._handle_tvchart_change_interval(_make_ctx({"widget_id": "chart", "value": "5m"})) + hit = _find_emit(widget, "tvchart:interval-change") + assert hit is not None + _, payload = hit + assert payload == {"value": "5m"} + + +def test_set_visible_range_packs_from_to(widget: MagicMock) -> None: + mcp_handlers._handle_tvchart_set_visible_range( + _make_ctx({"widget_id": "chart", "from_time": 100, "to_time": 200}) + ) + name, payload = widget.emit.call_args[0] + assert name == "tvchart:time-scale" + assert payload == {"visibleRange": {"from": 100, "to": 200}} + + +def test_fit_content_emits_fit_flag(widget: MagicMock) -> None: + mcp_handlers._handle_tvchart_fit_content(_make_ctx({"widget_id": "chart"})) + payload = widget.emit.call_args[0][1] + assert payload == {"fitContent": True} + + +def test_time_range_passes_preset_value(widget: MagicMock) -> None: + mcp_handlers._handle_tvchart_time_range(_make_ctx({"widget_id": "chart", "value": "1Y"})) + name, payload = widget.emit.call_args[0] + assert name == "tvchart:time-range" + assert payload == {"value": "1Y"} + + +def test_time_range_picker_emits_open_event(widget: MagicMock) -> None: + mcp_handlers._handle_tvchart_time_range_picker(_make_ctx({"widget_id": "chart"})) + name, payload = widget.emit.call_args[0] + assert name == "tvchart:time-range-picker" + assert payload == {} + + +def test_log_scale_coerces_value_to_bool(widget: MagicMock) -> None: + mcp_handlers._handle_tvchart_log_scale(_make_ctx({"widget_id": "chart", "value": 1})) + payload = widget.emit.call_args[0][1] + assert payload == {"value": True} + + +def test_auto_scale_emits_event(widget: MagicMock) -> None: + mcp_handlers._handle_tvchart_auto_scale(_make_ctx({"widget_id": "chart", "value": False})) + name, payload = widget.emit.call_args[0] + assert name == "tvchart:auto-scale" + assert payload == {"value": False} + + +# --------------------------------------------------------------------------- +# Chart type +# --------------------------------------------------------------------------- + + +def test_chart_type_emits_change(widget: MagicMock) -> None: + mcp_handlers._handle_tvchart_chart_type( + _make_ctx({"widget_id": "chart", "value": "Heikin Ashi", "series_id": "main"}) + ) + name, payload = widget.emit.call_args[0] + assert name == "tvchart:chart-type-change" + assert payload == {"value": "Heikin Ashi", "seriesId": "main"} + + +# --------------------------------------------------------------------------- +# Drawing tools / undo / redo +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + ("mode", "expected_event"), + [ + ("cursor", "tvchart:tool-cursor"), + ("crosshair", "tvchart:tool-crosshair"), + ("magnet", "tvchart:tool-magnet"), + ("eraser", "tvchart:tool-eraser"), + ("visibility", "tvchart:tool-visibility"), + ("lock", "tvchart:tool-lock"), + ], +) +def test_drawing_tool_dispatch(widget: MagicMock, mode: str, expected_event: str) -> None: + mcp_handlers._handle_tvchart_drawing_tool(_make_ctx({"widget_id": "chart", "mode": mode})) + assert widget.emit.call_args[0][0] == expected_event + + +def test_drawing_tool_unknown_mode_returns_error(widget: MagicMock) -> None: + out = mcp_handlers._handle_tvchart_drawing_tool( + _make_ctx({"widget_id": "chart", "mode": "ruler"}) + ) + assert "error" in out + widget.emit.assert_not_called() + + +def test_undo_emits_event(widget: MagicMock) -> None: + mcp_handlers._handle_tvchart_undo(_make_ctx({"widget_id": "chart"})) + assert widget.emit.call_args[0][0] == "tvchart:undo" + + +def test_redo_emits_event(widget: MagicMock) -> None: + mcp_handlers._handle_tvchart_redo(_make_ctx({"widget_id": "chart"})) + assert widget.emit.call_args[0][0] == "tvchart:redo" + + +# --------------------------------------------------------------------------- +# Chart chrome +# --------------------------------------------------------------------------- + + +def test_show_settings_emits_event(widget: MagicMock) -> None: + mcp_handlers._handle_tvchart_show_settings(_make_ctx({"widget_id": "chart"})) + assert widget.emit.call_args[0][0] == "tvchart:show-settings" + + +def test_toggle_dark_mode_emits_value(widget: MagicMock) -> None: + mcp_handlers._handle_tvchart_toggle_dark_mode(_make_ctx({"widget_id": "chart", "value": True})) + name, payload = widget.emit.call_args[0] + assert name == "tvchart:toggle-dark-mode" + assert payload == {"value": True} + + +def test_screenshot_emits_event(widget: MagicMock) -> None: + mcp_handlers._handle_tvchart_screenshot(_make_ctx({"widget_id": "chart"})) + assert widget.emit.call_args[0][0] == "tvchart:screenshot" + + +def test_fullscreen_emits_event(widget: MagicMock) -> None: + mcp_handlers._handle_tvchart_fullscreen(_make_ctx({"widget_id": "chart"})) + assert widget.emit.call_args[0][0] == "tvchart:fullscreen" + + +# --------------------------------------------------------------------------- +# Layout / state +# --------------------------------------------------------------------------- + + +def test_save_layout_passes_optional_name(widget: MagicMock) -> None: + mcp_handlers._handle_tvchart_save_layout( + _make_ctx({"widget_id": "chart", "name": "Daily Setup"}) + ) + name, payload = widget.emit.call_args[0] + assert name == "tvchart:save-layout" + assert payload == {"name": "Daily Setup"} + + +def test_save_layout_omits_name_when_none(widget: MagicMock) -> None: + mcp_handlers._handle_tvchart_save_layout(_make_ctx({"widget_id": "chart"})) + payload = widget.emit.call_args[0][1] + assert payload == {} + + +def test_open_layout_emits_event(widget: MagicMock) -> None: + mcp_handlers._handle_tvchart_open_layout(_make_ctx({"widget_id": "chart"})) + assert widget.emit.call_args[0][0] == "tvchart:open-layout" + + +def test_save_state_emits_event(widget: MagicMock) -> None: + out = mcp_handlers._handle_tvchart_save_state(_make_ctx({"widget_id": "chart"})) + assert out["event_sent"] is True + assert widget.emit.call_args[0][0] == "tvchart:save-state" + + +def test_request_state_round_trip_returns_decoded_state(widget: MagicMock) -> None: + captured: dict[str, Any] = {} + + def fake_on(event: str, handler: Any) -> None: + captured["event"] = event + captured["handler"] = handler + + response = { + "context": None, + "chartId": "main", + "theme": "dark", + "series": {"main": {"type": "Candles"}}, + "visibleRange": {"from": 1, "to": 2}, + "rawData": [], + "drawings": [], + "indicators": [], + } + + def fake_emit(event: str, payload: dict[str, Any]) -> None: + response["context"] = payload.get("context") + captured["handler"](response, "", "") + + widget.on = fake_on + widget.emit = fake_emit + + out = mcp_handlers._handle_tvchart_request_state( + _make_ctx({"widget_id": "chart", "timeout": 1.0}) + ) + assert captured["event"] == "tvchart:state-response" + state = out["state"] + assert "context" not in state # token stripped + assert state["theme"] == "dark" + assert state["series"]["main"]["type"] == "Candles" + + +def test_request_state_returns_error_on_timeout(widget: MagicMock) -> None: + widget.on = MagicMock() + widget.emit = MagicMock() + out = mcp_handlers._handle_tvchart_request_state( + _make_ctx({"widget_id": "chart", "timeout": 0.05}) + ) + assert "error" in out 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_menu_tray.py b/pywry/tests/test_menu_tray.py index 63ce1f4..23f1f8e 100644 --- a/pywry/tests/test_menu_tray.py +++ b/pywry/tests/test_menu_tray.py @@ -693,6 +693,9 @@ def test_register_handlers_dispatches(self, mock_runtime: MagicMock) -> None: registry = get_registry() dispatched = registry.dispatch("win-1", "menu:click", {"item_id": "save", "source": "app"}) assert dispatched + # Sync handlers are executed on a thread pool, so wait for the + # invocation to finish before asserting (avoids flakes on slow CI). + registry._drain(timeout=5.0) h.assert_called_once() @patch("pywry.menu_proxy.runtime") @@ -714,7 +717,10 @@ def test_register_handlers_idempotent(self, mock_runtime: MagicMock) -> None: from pywry.callbacks import get_registry - get_registry().dispatch("win-1", "menu:click", {"item_id": "a", "source": "app"}) + registry = get_registry() + registry.dispatch("win-1", "menu:click", {"item_id": "a", "source": "app"}) + # Sync handlers run on a thread pool — wait before asserting. + registry._drain(timeout=5.0) # Handler should only be called once — one registration assert h.call_count == 1 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 8fcbdc7..86f6493 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" ) @@ -1213,8 +1213,22 @@ def _fn(self, src: str, name: str) -> str: return self._extract_braced(src, src.index(f"function {name}(")) def _handler(self, src: str, event: str) -> str: - """Extract the body of ``bridge.on('', ...)``.""" - return self._extract_braced(src, src.index(f"bridge.on('{event}'")) + """Extract the body of an event listener for ````. + + Accepts both ``window.pywry.on('', ...)`` and + ``bridge.on('', ...)`` — the tvchart event handlers are + registered against a local ``bridge`` reference that defaults to + ``window.pywry``. + """ + candidates = ( + f"window.pywry.on('{event}'", + f"bridge.on('{event}'", + ) + for candidate in candidates: + idx = src.find(candidate) + if idx != -1: + return self._extract_braced(src, idx) + raise ValueError(f"No handler registration found for event '{event}'") def _create_body(self, src: str) -> str: """Extract the PYWRY_TVCHART_CREATE function body.""" @@ -1313,18 +1327,19 @@ def test_volume_pane_height_is_clamped_proportionally(self, tvchart_defaults_js: # Actually sets the height on the pane assert "setHeight(desiredHeight)" in body - def test_volume_options_suppress_value_label(self, tvchart_defaults_js: str): - """The volume series builder must hide the value label and price line - so they don't overlap with the main series axis labels.""" + def test_volume_options(self, tvchart_defaults_js: str): + """Volume series uses the right-side price scale of its own pane, + keeps the latest-value label visible, and suppresses the price line.""" body = self._fn(tvchart_defaults_js, "_tvBuildVolumeOptions") - assert "lastValueVisible: false" in body, ( - "Volume must hide lastValueVisible to avoid axis label overlap" + assert "lastValueVisible: true" in body, ( + "Volume needs the latest-value label so the right axis renders ticks" ) assert "priceLineVisible: false" in body, ( - "Volume must hide priceLineVisible to avoid axis label overlap" + "Volume must hide priceLineVisible to avoid horizontal-line clutter" ) - # Volume gets its own price scale - assert "priceScaleId: 'volume'" in body + # Volume series binds to the standard 'right' price scale of its + # own pane (visible by default), not a hidden custom 'volume' scale. + assert "priceScaleId: 'right'" in body def test_volume_auto_enables_in_create(self, tvchart_defaults_js: str): """PYWRY_TVCHART_CREATE enables volume by default when enableVolume is @@ -1357,12 +1372,15 @@ def test_time_range_handler_is_zoom_only(self, tvchart_defaults_js: str): def test_time_range_selection_handles_all_and_ytd(self, tvchart_defaults_js: str): """_tvApplyTimeRangeSelection must have explicit branches for 'all' (fit all data) and 'ytd' (year-to-date), plus use _tvResolveRangeSpanDays - for named presets like '1y', '3m', etc.""" + for named presets like '1y', '3m', etc. For absolute date-range + requests it must delegate to _tvApplyAbsoluteDateRange.""" body = self._fn(tvchart_defaults_js, "_tvApplyTimeRangeSelection") assert "range === 'all'" in body assert "fitContent()" in body assert "range === 'ytd'" in body assert "_tvResolveRangeSpanDays(" in body + # Absolute date-range requests are handled by a separate helper. + assert "function _tvApplyAbsoluteDateRange" in tvchart_defaults_js def test_range_span_resolver_covers_standard_presets(self, tvchart_defaults_js: str): """_tvResolveRangeSpanDays must define time spans for all standard presets.""" @@ -1562,11 +1580,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 # ------------------------------------------------------------------ @@ -1801,6 +1819,88 @@ def test_remove_indicator(self): assert event == "tvchart:remove-series" assert payload["seriesId"] == "sma20" + # -------- built-in indicator engine (JS-side compute) --------------- + + def test_add_builtin_indicator_minimal(self): + m = _MockEmitter() + m.add_builtin_indicator("RSI") + event, payload = m._emitted[0] + assert event == "tvchart:add-indicator" + assert payload == {"name": "RSI"} + + def test_add_builtin_indicator_with_period_and_color(self): + m = _MockEmitter() + m.add_builtin_indicator("Moving Average", period=50, color="#2196F3", method="SMA") + event, payload = m._emitted[0] + assert event == "tvchart:add-indicator" + assert payload["name"] == "Moving Average" + assert payload["method"] == "SMA" + assert payload["period"] == 50 + assert payload["color"] == "#2196F3" + + def test_add_builtin_indicator_passes_bollinger_options(self): + m = _MockEmitter() + m.add_builtin_indicator( + "Bollinger Bands", + period=20, + multiplier=2.0, + ma_type="SMA", + offset=0, + source="close", + ) + _event, payload = m._emitted[0] + # Note: ma_type → maType in payload (per the wire contract) + assert payload["multiplier"] == 2.0 + assert payload["maType"] == "SMA" + assert payload["offset"] == 0 + assert payload["source"] == "close" + + def test_add_builtin_indicator_omits_unset_options(self): + m = _MockEmitter() + m.add_builtin_indicator("RSI", period=12) + _event, payload = m._emitted[0] + # Only the explicit fields land in the payload + assert set(payload.keys()) == {"name", "period"} + + def test_add_builtin_indicator_chart_id(self): + m = _MockEmitter() + m.add_builtin_indicator("Moving Average", period=10, method="SMA", chart_id="alt") + _event, payload = m._emitted[0] + assert payload["chartId"] == "alt" + + def test_add_builtin_indicator_with_method(self): + m = _MockEmitter() + m.add_builtin_indicator("Moving Average", period=14, method="EMA") + _event, payload = m._emitted[0] + assert payload["method"] == "EMA" + + def test_remove_builtin_indicator(self): + m = _MockEmitter() + m.remove_builtin_indicator("ind_sma_99") + event, payload = m._emitted[0] + assert event == "tvchart:remove-indicator" + assert payload == {"seriesId": "ind_sma_99"} + + def test_remove_builtin_indicator_with_chart_id(self): + m = _MockEmitter() + m.remove_builtin_indicator("ind_sma_99", chart_id="alt") + _event, payload = m._emitted[0] + assert payload["chartId"] == "alt" + + def test_list_indicators_default(self): + m = _MockEmitter() + m.list_indicators() + event, payload = m._emitted[0] + assert event == "tvchart:list-indicators" + assert payload == {} + + def test_list_indicators_with_context(self): + m = _MockEmitter() + m.list_indicators(chart_id="alt", context={"trigger": "init"}) + _event, payload = m._emitted[0] + assert payload["chartId"] == "alt" + assert payload["context"] == {"trigger": "init"} + def test_add_marker(self): m = _MockEmitter() markers = [ @@ -2349,6 +2449,354 @@ def test_resolution_defaults_to_1d(self): assert sig.parameters["resolution"].default == "1D" +# ============================================================================= +# Indicator catalog + compute + recompute coverage +# ============================================================================= + + +class TestTVChartIndicatorCatalog: + """Every indicator advertised by the catalog must have: + + * a compute function present in the bundled JS, + * an add-indicator branch that creates its series, and + * a recompute branch in ``_tvRecomputeIndicatorSeries`` so it refreshes + when underlying bars change (otherwise indicators silently freeze at + their initial snapshot when the datafeed replaces bars — exactly the + bug that made VWAP show 9.99 on a $270 stock). + """ + + @pytest.fixture + def js(self) -> str: + from pywry.assets import get_tvchart_defaults_js + + return get_tvchart_defaults_js() + + # ------------------------------------------------------------------ + # Catalog entries + # ------------------------------------------------------------------ + + EXPECTED_CATALOG_NAMES = ( + "Moving Average", + "Ichimoku Cloud", + "Bollinger Bands", + "Keltner Channels", + "ATR", + "Historical Volatility", + "Parabolic SAR", + "RSI", + "MACD", + "Stochastic", + "Williams %R", + "CCI", + "ADX", + "Aroon", + "VWAP", + "Volume SMA", + "Accumulation/Distribution", + "Volume Profile Fixed Range", + "Volume Profile Visible Range", + ) + + @pytest.mark.parametrize("name", EXPECTED_CATALOG_NAMES) + def test_catalog_contains_indicator(self, js: str, name: str) -> None: + cat_start = js.index("_INDICATOR_CATALOG = [") + cat_end = js.index("];", cat_start) + catalog_src = js[cat_start:cat_end] + assert f"name: '{name}'" in catalog_src, f"Indicator catalog missing entry for '{name}'" + + def test_volume_profile_entries_are_primitive(self, js: str) -> None: + cat_start = js.index("_INDICATOR_CATALOG = [") + cat_end = js.index("];", cat_start) + catalog_src = js[cat_start:cat_end] + for key in ("'volume-profile-fixed'", "'volume-profile-visible'"): + block = catalog_src[catalog_src.index(key) :] + first_close = block.index("}") + entry = block[:first_close] + assert "primitive: true" in entry, f"Expected VP entry {key} to have primitive: true" + + # ------------------------------------------------------------------ + # Compute functions + # ------------------------------------------------------------------ + + EXPECTED_COMPUTE_FNS = ( + "_computeSMA", + "_computeEMA", + "_computeWMA", + "_computeHMA", + "_computeVWMA", + "_computeRSI", + "_computeATR", + "_computeBollingerBands", + "_computeKeltnerChannels", + "_computeVWAP", + "_computeMACD", + "_computeStochastic", + "_computeAroon", + "_computeADX", + "_computeCCI", + "_computeWilliamsR", + "_computeAccumulationDistribution", + "_computeHistoricalVolatility", + "_computeIchimoku", + "_computeParabolicSAR", + ) + + @pytest.mark.parametrize("fn_name", EXPECTED_COMPUTE_FNS) + def test_compute_function_defined(self, js: str, fn_name: str) -> None: + assert f"function {fn_name}(" in js, f"Missing compute function {fn_name} in bundled JS" + + # ------------------------------------------------------------------ + # Add-indicator branches + # ------------------------------------------------------------------ + + ADD_BRANCHES = ( + ("name === 'VWAP'", "_computeVWAP"), + ("name === 'MACD'", "_computeMACD"), + ("name === 'Stochastic'", "_computeStochastic"), + ("name === 'Aroon'", "_computeAroon"), + ("name === 'ADX'", "_computeADX"), + ("name === 'CCI'", "_computeCCI"), + ("name === 'Williams %R'", "_computeWilliamsR"), + ("name === 'Accumulation/Distribution'", "_computeAccumulationDistribution"), + ("name === 'Historical Volatility'", "_computeHistoricalVolatility"), + ("name === 'Keltner Channels'", "_computeKeltnerChannels"), + ("name === 'Ichimoku Cloud'", "_computeIchimoku"), + ("name === 'Parabolic SAR'", "_computeParabolicSAR"), + ) + + @pytest.mark.parametrize("branch,fn", ADD_BRANCHES) + def test_add_branch_wires_compute(self, js: str, branch: str, fn: str) -> None: + assert branch in js, f"Missing add-indicator branch '{branch}' in 04-series.js" + # Narrow the search: compute call must appear after the branch and + # before the next `} else if (name ===` marker. + branch_idx = js.index(branch) + next_branch = js.find("} else if (name ===", branch_idx + 1) + if next_branch < 0: + next_branch = js.find("_tvAddIndicator fallthrough", branch_idx + 1) + segment = js[branch_idx : next_branch if next_branch > 0 else branch_idx + 2000] + assert fn in segment, ( + f"Branch for '{branch}' should call {fn}() but didn't within 2000 chars" + ) + + # ------------------------------------------------------------------ + # Recompute branches (THIS is the bug that caused VWAP=9.99) + # ------------------------------------------------------------------ + + @pytest.fixture + def recompute_body(self, js: str) -> str: + start = js.index("function _tvRecomputeIndicatorSeries(") + # Find matching close brace for the function + depth = 0 + i = js.index("{", start) + n = len(js) + while i < n: + ch = js[i] + if ch == "{": + depth += 1 + elif ch == "}": + depth -= 1 + if depth == 0: + return js[start : i + 1] + i += 1 + raise RuntimeError("Could not find end of _tvRecomputeIndicatorSeries") + + RECOMPUTE_BRANCHES = ( + ("info.name === 'VWAP'", "_computeVWAP"), + ("info.name === 'CCI'", "_computeCCI"), + ("info.name === 'Williams %R'", "_computeWilliamsR"), + ("info.name === 'Accumulation/Distribution'", "_computeAccumulationDistribution"), + ("info.name === 'Historical Volatility'", "_computeHistoricalVolatility"), + ("type === 'parabolic-sar'", "_computeParabolicSAR"), + ("type === 'macd'", "_computeMACD"), + ("type === 'stochastic'", "_computeStochastic"), + ("type === 'aroon'", "_computeAroon"), + ("type === 'adx'", "_computeADX"), + ("type === 'keltner-channels'", "_computeKeltnerChannels"), + ("type === 'ichimoku'", "_computeIchimoku"), + ) + + @pytest.mark.parametrize("branch,fn", RECOMPUTE_BRANCHES) + def test_recompute_branch_refreshes_series( + self, recompute_body: str, branch: str, fn: str + ) -> None: + assert branch in recompute_body, ( + f"_tvRecomputeIndicatorSeries missing branch for {branch!r}. " + "Without this branch, the indicator won't refresh when bars " + "change (e.g., via datafeed scrollback or interval switch) " + "and will stay frozen at its initial snapshot." + ) + idx = recompute_body.index(branch) + tail = recompute_body[idx : idx + 2500] + assert fn in tail, ( + f"Recompute branch {branch!r} found but never calls {fn}() " + "within the following 2500 chars — did the branch get broken?" + ) + + def test_recompute_branch_for_volume_profile(self, recompute_body: str) -> None: + """Visible-range volume profiles must recompute when the bar set + changes — otherwise scrolling into new data leaves their right-pinned + rows reflecting the old range.""" + assert "type === 'volume-profile-visible'" in recompute_body + assert "_tvRefreshVisibleVolumeProfiles" in recompute_body + + +# ============================================================================= +# Volume Profile compute contract +# ============================================================================= + + +class TestTVChartVolumeProfile: + """Tests for _tvComputeVolumeProfile — the pure function behind VPVR.""" + + @pytest.fixture + def js(self) -> str: + from pywry.assets import get_tvchart_defaults_js + + return get_tvchart_defaults_js() + + def test_vp_compute_function_signature(self, js: str) -> None: + assert "function _tvComputeVolumeProfile(bars, fromIdx, toIdx, opts)" in js + + def test_vp_result_returns_profile_and_metadata(self, js: str) -> None: + fn_start = js.index("function _tvComputeVolumeProfile(") + fn_end = js.index("\nfunction ", fn_start + 1) + body = js[fn_start:fn_end] + for key in ("profile", "minPrice", "maxPrice", "step", "totalVolume"): + assert key in body, f"VP compute result missing expected field '{key}'" + + def test_vp_splits_up_down_volume(self, js: str) -> None: + fn_start = js.index("function _tvComputeVolumeProfile(") + fn_end = js.index("\nfunction ", fn_start + 1) + body = js[fn_start:fn_end] + # Up/down split is what differentiates VPVR from a flat histogram. + assert "upVol" in body and "downVol" in body, ( + "VP compute must split each row into up vs down volume" + ) + + def test_vp_exposes_poc_value_area_helper(self, js: str) -> None: + """A separate helper derives POC and Value Area from the computed profile.""" + assert "function _tvComputePOCAndValueArea(" in js + fn_start = js.index("function _tvComputePOCAndValueArea(") + fn_end = js.index("\nfunction ", fn_start + 1) + body = js[fn_start:fn_end] + for key in ("pocIdx", "vaLowIdx", "vaHighIdx"): + assert key in body, f"POC/VA helper must expose '{key}' so renderer can draw lines" + + def test_vp_refresh_visible_exposed(self, js: str) -> None: + """Visible-range refresh must exist for the recompute path to call it.""" + assert "function _tvRefreshVisibleVolumeProfiles(chartId)" in js + + +# ============================================================================= +# Legend volume removal actually destroys the series + pane +# ============================================================================= + + +class TestTVChartLegendVolumeRemoval: + """Removing volume from the legend must actually remove it from the chart + (issue: previously, clicking Remove only set a legend dataset flag but + left the histogram series and its pane on the chart).""" + + @pytest.fixture + def js(self) -> str: + from pywry.assets import get_tvchart_defaults_js + + return get_tvchart_defaults_js() + + def _fn_or_nested(self, js: str, name: str) -> str: + """Extract a function body — works for nested ``function X()`` too.""" + idx = js.index(f"function {name}(") + depth = 0 + i = js.index("{", idx) + n = len(js) + while i < n: + ch = js[i] + if ch == "{": + depth += 1 + elif ch == "}": + depth -= 1 + if depth == 0: + return js[idx : i + 1] + i += 1 + raise RuntimeError(f"Could not find end of {name}") + + def test_disable_volume_removes_series(self, js: str) -> None: + body = self._fn_or_nested(js, "_legendDisableVolume") + assert "entry.chart.removeSeries(volSeries)" in body, ( + "Remove-volume must actually call chart.removeSeries" + ) + assert "delete entry.volumeMap.main" in body, "Remove-volume must clear the volumeMap entry" + + def test_disable_volume_removes_pane(self, js: str) -> None: + body = self._fn_or_nested(js, "_legendDisableVolume") + assert "chart.removePane(removedPane)" in body, ( + "Remove-volume must collapse the now-empty pane, not leave dead space" + ) + + def test_disable_volume_reindexes_panes(self, js: str) -> None: + body = self._fn_or_nested(js, "_legendDisableVolume") + # When pane N is removed, LWC reindexes panes > N down by 1. We must + # mirror that for our bookkeeping on _activeIndicators and _volumePaneBySeries. + assert ".paneIndex -= 1" in body + assert "_volumePaneBySeries" in body + + def test_enable_volume_rebuilds_series(self, js: str) -> None: + body = self._fn_or_nested(js, "_legendEnableVolume") + assert "_tvAddSeriesCompat(entry.chart, 'Histogram'" in body, ( + "Restore-volume must rebuild the histogram series via the same " + "path used for initial creation" + ) + assert "_tvExtractVolumeFromBars" in body, ( + "Restore-volume must re-extract volume from the stored raw bars" + ) + + +# ============================================================================= +# Theme CSS variables — every new VP / indicator color var is defined +# ============================================================================= + + +class TestTVChartThemeVariables: + """The tvchart.css stylesheet must define every CSS variable that the + frontend JS consumes, in both dark and light themes (otherwise colors + silently fall back to whatever the browser decides).""" + + @pytest.fixture + def css(self) -> str: + from pathlib import Path + + return ( + Path(__file__).parents[1] / "pywry" / "frontend" / "style" / "tvchart.css" + ).read_text(encoding="utf-8") + + VP_VARS = ( + "--pywry-tvchart-vp-up", + "--pywry-tvchart-vp-down", + "--pywry-tvchart-vp-va-up", + "--pywry-tvchart-vp-va-down", + "--pywry-tvchart-vp-poc", + ) + + INDICATOR_PALETTE_VARS = ( + "--pywry-tvchart-ind-primary", + "--pywry-tvchart-ind-secondary", + "--pywry-tvchart-ind-tertiary", + "--pywry-tvchart-ind-positive", + "--pywry-tvchart-ind-negative", + "--pywry-tvchart-ind-positive-dim", + "--pywry-tvchart-ind-negative-dim", + ) + + @pytest.mark.parametrize("var", VP_VARS + INDICATOR_PALETTE_VARS) + def test_var_defined_at_least_twice(self, css: str, var: str) -> None: + """Each var must appear in both the dark (root) and light theme blocks.""" + count = css.count(var + ":") + assert count >= 2, ( + f"CSS var {var} defined only {count} time(s); expected at least 2 " + "(one for dark theme, one for light)." + ) + + # ============================================================================= # MCP tool definition tests # ============================================================================= diff --git a/pywry/tests/test_tvchart_e2e.py b/pywry/tests/test_tvchart_e2e.py index 84480ef..0247379 100644 --- a/pywry/tests/test_tvchart_e2e.py +++ b/pywry/tests/test_tvchart_e2e.py @@ -107,6 +107,106 @@ def _cid() -> str: ) +# JS helpers for driving the REAL drawing pipeline (tool-selection + a +# synthetic ``click`` MouseEvent dispatched on the overlay canvas, not +# a direct ``ds.drawings.push``). The click handler computes the +# time/price from the click pixel via ``_tvFromPixel``, pushes the +# drawing itself, and calls ``_tvRenderDrawings`` — so the canvas +# actually ends up with drawing pixels and the tests exercise the same +# code the user would hit with a mouse. +_DRAW_PIXEL_JS = ( + "function _dispatchDrawClick(ds, fx, fy) {" + " var rect = ds.canvas.getBoundingClientRect();" + " var mx = rect.left + rect.width * fx;" + " var my = rect.top + rect.height * fy;" + " var ev = new MouseEvent('click', {" + " bubbles: true, cancelable: true, view: window," + " clientX: mx, clientY: my, button: 0," + " });" + " ds.canvas.dispatchEvent(ev);" + "}" + "function _canvasHasPixels(ds) {" + " try {" + " var w = ds.canvas.width, h = ds.canvas.height;" + " if (!w || !h) return false;" + " var data = ds.ctx.getImageData(0, 0, w, h).data;" + " for (var i = 3; i < data.length; i += 4) if (data[i] !== 0) return true;" + " return false;" + " } catch (e) { return null; }" + "}" +) + + +def _draw_hline_script() -> str: + return ( + _DRAW_PIXEL_JS + + "_tvSetDrawTool(cid, 'hline');" + + "var ds = window.__PYWRY_DRAWINGS__[cid];" + + "if (!ds) { pywry.result({error:'no drawing state'}); return; }" + + "var before = ds.drawings.length;" + + "_dispatchDrawClick(ds, 0.5, 0.5);" + + "var rendered = _canvasHasPixels(ds);" + + "_tvSetDrawTool(cid, 'cursor');" + + "pywry.result({" + + " count: ds.drawings.length," + + " added: ds.drawings.length - before," + + " type: ds.drawings.length ? ds.drawings[ds.drawings.length - 1].type : null," + + " rendered: rendered," + + "});" + ) + + +def _draw_two_point_script(tool: str) -> str: + return ( + _DRAW_PIXEL_JS + + "_tvSetDrawTool(cid, '" + + tool + + "');" + + "var ds = window.__PYWRY_DRAWINGS__[cid];" + + "if (!ds) { pywry.result({error:'no drawing state'}); return; }" + + "var before = ds.drawings.length;" + + "_dispatchDrawClick(ds, 0.3, 0.4);" + + "_dispatchDrawClick(ds, 0.7, 0.6);" + + "_tvSetDrawTool(cid, 'cursor');" + + "pywry.result({" + + " count: ds.drawings.length," + + " added: ds.drawings.length - before," + + " type: ds.drawings.length ? ds.drawings[ds.drawings.length - 1].type : null," + + "});" + ) + + +def _draw_single_point_script(tool: str) -> str: + # The ``text`` tool's canvas-click handler auto-opens + # ``_tvShowDrawingSettings`` (so the user can name the label + # immediately). That flips ``entry._interactionLocked`` to + # true. Leaving the overlay up leaks locked state into every + # subsequent test in the class-scoped fixture — test 39's + # ``assert lockedBefore is False`` then fails. Close the + # drawing-settings overlay and any floating toolbar, then + # clear the lock flag defensively so the idle-chart contract + # holds. + return ( + _DRAW_PIXEL_JS + + "_tvSetDrawTool(cid, '" + + tool + + "');" + + "var ds = window.__PYWRY_DRAWINGS__[cid];" + + "if (!ds) { pywry.result({error:'no drawing state'}); return; }" + + "var before = ds.drawings.length;" + + "_dispatchDrawClick(ds, 0.4, 0.5);" + + "if (typeof _tvHideDrawingSettings === 'function') _tvHideDrawingSettings();" + + "if (typeof _tvHideFloatingToolbar === 'function') _tvHideFloatingToolbar();" + + "_tvSetDrawTool(cid, 'cursor');" + + "if (entry) entry._interactionLocked = false;" + + "pywry.result({" + + " count: ds.drawings.length," + + " added: ds.drawings.length - before," + + " type: ds.drawings.length ? ds.drawings[ds.drawings.length - 1].type : null," + + "});" + ) + + # ============================================================================ # Fixture -- ONE chart for the entire class # ============================================================================ @@ -279,31 +379,37 @@ def test_07_chart_type_cycle_all(self, chart: dict[str, Any]) -> None: # ------------------------------------------------------------------ def test_08_add_sma_20(self, chart: dict[str, Any]) -> None: - """Add SMA overlay -- series count increases, metadata correct.""" + """Add an SMA(20) overlay via the unified Moving Average entry.""" r = _js( chart["label"], "(function() {" + _cid() + "var before = Object.keys(entry.seriesMap).length;" "_tvAddIndicator(" - " {name: 'SMA', key: 'sma', fullName: 'SMA'," - " category: 'Moving Averages', defaultPeriod: 20}," + " {name: 'Moving Average', key: 'moving-average-ex'," + " fullName: 'Moving Average', category: 'Moving Averages'," + " defaultPeriod: 20, _method: 'SMA'}," " cid" ");" "var after = Object.keys(entry.seriesMap).length;" "var indKey = Object.keys(_activeIndicators).filter(" - " function(k) { return _activeIndicators[k].name === 'SMA'; }" + " function(k) {" + " var ai = _activeIndicators[k];" + " return ai.type === 'moving-average-ex' && ai.method === 'SMA';" + " }" ")[0];" "var info = indKey ? _activeIndicators[indKey] : null;" "pywry.result({" " before: before, after: after," " name: info ? info.name : null," + " method: info ? info.method : null," " period: info ? info.period : null," " isSubplot: info ? !!info.isSubplot : null," " seriesId: indKey || null," "});" "})();", ) - assert r["after"] > r["before"], "SMA should add a new series" - assert r["name"] == "SMA" + assert r["after"] > r["before"], "Moving Average should add a new series" + assert r["name"] == "Moving Average" + assert r["method"] == "SMA" assert r["period"] == 20 assert r["isSubplot"] is False @@ -312,7 +418,10 @@ def test_09_sma_has_computed_data(self, chart: dict[str, Any]) -> None: r = _js( chart["label"], "(function() {" + _cid() + "var indKey = Object.keys(_activeIndicators).filter(" - " function(k) { return _activeIndicators[k].name === 'SMA'; }" + " function(k) {" + " var ai = _activeIndicators[k];" + " return ai.type === 'moving-average-ex' && ai.method === 'SMA';" + " }" ")[0];" "var series = indKey ? entry.seriesMap[indKey] : null;" "var data = [];" @@ -334,7 +443,10 @@ def test_10_change_sma_period_to_50(self, chart: dict[str, Any]) -> None: r = _js( chart["label"], "(function() {" + _cid() + "var indKey = Object.keys(_activeIndicators).filter(" - " function(k) { return _activeIndicators[k].name === 'SMA'; }" + " function(k) {" + " var ai = _activeIndicators[k];" + " return ai.type === 'moving-average-ex' && ai.method === 'SMA';" + " }" ")[0];" "_tvApplyIndicatorSettings(indKey, {period: 50});" "var info = _activeIndicators[indKey];" @@ -347,7 +459,10 @@ def test_11_change_sma_color(self, chart: dict[str, Any]) -> None: r = _js( chart["label"], "(function() {" + _cid() + "var indKey = Object.keys(_activeIndicators).filter(" - " function(k) { return _activeIndicators[k].name === 'SMA'; }" + " function(k) {" + " var ai = _activeIndicators[k];" + " return ai.type === 'moving-average-ex' && ai.method === 'SMA';" + " }" ")[0];" "_tvApplyIndicatorSettings(indKey, {color: '#ff6600'});" "var info = _activeIndicators[indKey];" @@ -357,12 +472,13 @@ def test_11_change_sma_color(self, chart: dict[str, Any]) -> None: assert r["color"] == "#ff6600" def test_12_add_ema_overlay(self, chart: dict[str, Any]) -> None: - """Add EMA(12) -- now two overlay indicators active.""" + """Add an EMA(12) -- now two overlay indicators active.""" r = _js( chart["label"], "(function() {" + _cid() + "_tvAddIndicator(" - " {name: 'EMA', key: 'ema', fullName: 'EMA'," - " category: 'Moving Averages', defaultPeriod: 12}," + " {name: 'Moving Average', key: 'moving-average-ex'," + " fullName: 'Moving Average', category: 'Moving Averages'," + " defaultPeriod: 12, _method: 'EMA'}," " cid" ");" "var indKeys = Object.keys(_activeIndicators);" @@ -470,7 +586,7 @@ def test_16_add_bollinger_bands(self, chart: dict[str, Any]) -> None: assert any(c >= 3 for c in group_counts) def test_17_indicator_count_correct(self, chart: dict[str, Any]) -> None: - """SMA + EMA + RSI + BB(3) = at least 6 indicator series.""" + """MA(SMA) + MA(EMA) + RSI + BB(3) = at least 6 indicator series.""" r = _js( chart["label"], "(function() {pywry.result({count: Object.keys(_activeIndicators).length});})();", @@ -486,7 +602,10 @@ def test_18_sma_overlay_rsi_subplot(self, chart: dict[str, Any]) -> None: chart["label"], "(function() {" "var smaKey = Object.keys(_activeIndicators).filter(" - " function(k) { return _activeIndicators[k].name === 'SMA'; }" + " function(k) {" + " var ai = _activeIndicators[k];" + " return ai.type === 'moving-average-ex' && ai.method === 'SMA';" + " }" ")[0];" "var rsiKey = Object.keys(_activeIndicators).filter(" " function(k) { return _activeIndicators[k].name === 'RSI'; }" @@ -534,86 +653,57 @@ def test_19_swap_rsi_pane(self, chart: dict[str, Any]) -> None: # ------------------------------------------------------------------ def test_20_draw_hline(self, chart: dict[str, Any]) -> None: + # Drive the real drawing pipeline: select the tool (which flips + # the overlay canvas into ``pointer-events: auto``), dispatch a + # synthetic click, and let the click handler compute the price, + # push the drawing, call ``_tvRenderDrawings``, and create the + # native ``priceLine``. No direct ``ds.drawings.push`` here. r = _js( chart["label"], - "(function() {" + _cid() + "_tvEnsureDrawingLayer(cid);" - "var ds = window.__PYWRY_DRAWINGS__[cid];" - "if (!ds) { pywry.result({error: 'no drawing state'}); return; }" - "var d = {type:'hline',color:'#ff0000',lineWidth:2," - " lineStyle:0,p1:{price:50000},p2:{price:50000}};" - "ds.drawings.push(d);" - "_tvPushUndo({label:'add hline'," - " undo:function(){ds.drawings.splice(ds.drawings.indexOf(d),1);}," - " redo:function(){ds.drawings.push(d);}});" - "pywry.result({count:ds.drawings.length," - " type:ds.drawings[ds.drawings.length-1].type});" - "})();", + "(function() {" + _cid() + _draw_hline_script() + "})();", ) assert r.get("error") is None, r.get("error") assert r["count"] >= 1 assert r["type"] == "hline" + assert r["rendered"] is True, "Canvas had no drawing pixels after click" def test_21_draw_trendline(self, chart: dict[str, Any]) -> None: + # Two-point tool: first click sets p1, second click commits. r = _js( chart["label"], - "(function() {" + _cid() + "var ds = window.__PYWRY_DRAWINGS__[cid];" - "var d = {type:'trendline',color:'#2962ff',lineWidth:2," - " lineStyle:0,p1:{x:100,y:200},p2:{x:400,y:150}};" - "ds.drawings.push(d);" - "_tvPushUndo({label:'add trendline'," - " undo:function(){ds.drawings.splice(ds.drawings.indexOf(d),1);}," - " redo:function(){ds.drawings.push(d);}});" - "pywry.result({count:ds.drawings.length," - " type:ds.drawings[ds.drawings.length-1].type});" - "})();", + "(function() {" + _cid() + _draw_two_point_script("trendline") + "})();", ) + assert r.get("error") is None, r.get("error") assert r["count"] >= 2 assert r["type"] == "trendline" def test_22_draw_rect(self, chart: dict[str, Any]) -> None: r = _js( chart["label"], - "(function() {" + _cid() + "var ds = window.__PYWRY_DRAWINGS__[cid];" - "var d = {type:'rect',color:'#089981',lineWidth:1," - " lineStyle:0,filled:true,p1:{x:150,y:100},p2:{x:350,y:300}};" - "ds.drawings.push(d);" - "_tvPushUndo({label:'add rect'," - " undo:function(){ds.drawings.splice(ds.drawings.indexOf(d),1);}," - " redo:function(){ds.drawings.push(d);}});" - "pywry.result({count:ds.drawings.length,type:'rect'});" - "})();", + "(function() {" + _cid() + _draw_two_point_script("rect") + "})();", ) + assert r.get("error") is None, r.get("error") assert r["count"] >= 3 + assert r["type"] == "rect" def test_23_draw_text_annotation(self, chart: dict[str, Any]) -> None: + # ``text`` is a single-click tool. r = _js( chart["label"], - "(function() {" + _cid() + "var ds = window.__PYWRY_DRAWINGS__[cid];" - "var d = {type:'text',color:'#d1d4dc',text:'Test Label'," - " fontSize:14,p1:{x:200,y:250}};" - "ds.drawings.push(d);" - "_tvPushUndo({label:'add text'," - " undo:function(){ds.drawings.splice(ds.drawings.indexOf(d),1);}," - " redo:function(){ds.drawings.push(d);}});" - "pywry.result({count:ds.drawings.length,lastType:'text'});" - "})();", + "(function() {" + _cid() + _draw_single_point_script("text") + "})();", ) + assert r.get("error") is None, r.get("error") assert r["count"] >= 4 + assert r["type"] == "text" def test_24_draw_fibonacci(self, chart: dict[str, Any]) -> None: r = _js( chart["label"], - "(function() {" + _cid() + "var ds = window.__PYWRY_DRAWINGS__[cid];" - "var d = {type:'fibonacci',color:'#787b86',lineWidth:1," - " lineStyle:0,p1:{x:100,y:100},p2:{x:400,y:350}};" - "ds.drawings.push(d);" - "_tvPushUndo({label:'add fib'," - " undo:function(){ds.drawings.splice(ds.drawings.indexOf(d),1);}," - " redo:function(){ds.drawings.push(d);}});" - "pywry.result({count:ds.drawings.length,lastType:'fibonacci'});" - "})();", + "(function() {" + _cid() + _draw_two_point_script("fibonacci") + "})();", ) + assert r.get("error") is None, r.get("error") assert r["count"] >= 5 + assert r["type"] == "fibonacci" def test_25_drawing_count_correct(self, chart: dict[str, Any]) -> None: """hline + trendline + rect + text + fib = 5.""" @@ -1035,7 +1125,10 @@ def test_46_remove_sma(self, chart: dict[str, Any]) -> None: "(function() {" "var before = Object.keys(_activeIndicators).length;" "var smaKey = Object.keys(_activeIndicators).filter(" - " function(k) { return _activeIndicators[k].name === 'SMA'; }" + " function(k) {" + " var ai = _activeIndicators[k];" + " return ai.type === 'moving-average-ex' && ai.method === 'SMA';" + " }" ")[0];" "if (smaKey) _tvRemoveIndicator(smaKey);" "var after = Object.keys(_activeIndicators).length;"