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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 24 additions & 36 deletions pywry/pywry/chat/providers/deepagent.py
Original file line number Diff line number Diff line change
Expand Up @@ -161,65 +161,53 @@ def _step_in_call(self, ch: str) -> None:
self._in_string = False
self._escape = False

def _step_in_special(self, ch: str, out: list[str]) -> None:
"""Advance the ``<|...|>`` state machine; recurse on tail after ``|>``."""
def _step_in_special(self, ch: str, _out: list[str]) -> None:
"""Advance the ``<|...|>`` state machine; close when ``|>`` arrives.

Because ``feed()`` drives one character at a time and ``_in_special``
is entered with an empty buffer, the close marker is always at the
tail of the buffer — there is no trailing text to recurse on.
"""
self._buffer += ch
close_idx = self._buffer.find(self._SPECIAL_CLOSE)
if close_idx < 0:
if self._SPECIAL_CLOSE not in self._buffer:
return
rest = self._buffer[close_idx + len(self._SPECIAL_CLOSE) :]
self._buffer = ""
self._in_special = False
if rest:
out.append(self.feed(rest))

def _try_open_call(self, out: list[str]) -> bool:
def _try_open_call(self, _out: list[str]) -> bool:
"""If a complete ``functions.<name>...{`` opener sits in buffer, enter call mode.

Returns True if the buffer was consumed (caller skips other checks);
False if the marker isn't fully present yet — caller must NOT keep
scanning the buffer for ``<|`` (the ``functions.`` prefix already
committed us to wait).
False if the marker isn't fully present yet. ``_flush_safe_prefix``
guarantees ``functions.`` always sits at the buffer head when it's
present, and char-by-char feeding means ``{`` is always the tail —
no leading prefix to emit and no trailing text to recurse on.
"""
call_idx = self._buffer.find(self._CALL_START)
if call_idx < 0:
if self._CALL_START not in self._buffer:
return False
brace_idx = self._buffer.find("{", call_idx + len(self._CALL_START))
brace_idx = self._buffer.find("{", len(self._CALL_START))
if brace_idx < 0:
# Marker present but no ``{`` yet — keep buffering, do not
# fall through to the ``<|`` check (it would never match
# ``functions.`` and we'd over-emit).
return True
if call_idx > 0:
out.append(self._buffer[:call_idx])
rest = self._buffer[brace_idx + 1 :]
self._buffer = ""
self._in_call = True
self._depth = 1
self._in_string = False
self._escape = False
if rest:
out.append(self.feed(rest))
return True

def _try_open_special(self, out: list[str]) -> bool:
"""If a ``<|...|>`` token (or its open) is in buffer, drop it; return True."""
special_idx = self._buffer.find(self._SPECIAL_OPEN)
if special_idx < 0:
def _try_open_special(self, _out: list[str]) -> bool:
"""If a ``<|`` opener sits in buffer, drop it and enter skip mode.

``_flush_safe_prefix`` guarantees only ``<|`` itself (no trailing
text) ever reaches us, and the closing ``|>`` is consumed later
by ``_step_in_special`` — so we only need to handle the "open
seen, no close yet" case.
"""
if self._SPECIAL_OPEN not in self._buffer:
return False
close_idx = self._buffer.find(self._SPECIAL_CLOSE, special_idx + len(self._SPECIAL_OPEN))
if close_idx >= 0:
if special_idx > 0:
out.append(self._buffer[:special_idx])
rest = self._buffer[close_idx + len(self._SPECIAL_CLOSE) :]
self._buffer = ""
if rest:
out.append(self.feed(rest))
return True
# Open seen but no close yet — drop everything from ``<|`` on,
# emit the prefix, enter token-skip mode.
if special_idx > 0:
out.append(self._buffer[:special_idx])
self._buffer = ""
self._in_special = True
return True
Expand Down
3 changes: 0 additions & 3 deletions pywry/pywry/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -447,9 +447,6 @@ def show_config_sources() -> int:
if forced_status is True:
status = "✓ Active"
path_display = ""
elif forced_status is False:
status = "✗ Not found"
path_display = path_str
# Check if file exists
elif name == "Environment variables":
import os
Expand Down
2 changes: 1 addition & 1 deletion pywry/pywry/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@

if sys.version_info >= (3, 11):
import tomllib
else:
else: # pragma: no cover - python 3.10 fallback; cannot be exercised on 3.11+
try:
import tomli as tomllib
except ImportError:
Expand Down
25 changes: 9 additions & 16 deletions pywry/pywry/inline.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,16 +80,11 @@ def _get_default_theme() -> ThemeLiteral:
return "system" if is_headless() else "dark"


try:
import uvicorn

from fastapi import FastAPI, Request, WebSocket, WebSocketDisconnect
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import HTMLResponse, Response
import uvicorn

HAS_FASTAPI = True
except ImportError:
HAS_FASTAPI = False
from fastapi import FastAPI, Request, WebSocket, WebSocketDisconnect
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import HTMLResponse, Response

try:
from ipywidgets import Output
Expand Down Expand Up @@ -1688,8 +1683,6 @@ def __init__(
token: str | None = None,
) -> None:
super().__init__()
if not HAS_FASTAPI:
raise ImportError("fastapi and uvicorn required: pip install fastapi uvicorn")

# For browser_only mode, we don't need IPython (just the server + browser)
self._browser_only = browser_only
Expand Down Expand Up @@ -3337,8 +3330,6 @@ def generate_dataframe_html(
}
if grid_options:
grid_config.update(grid_options)
if "rowData" not in grid_config:
grid_config["rowData"] = row_data

assets = _build_aggrid_assets(aggrid_theme, theme_mode)
# For system theme, default to dark AG Grid theme (JS will switch)
Expand Down Expand Up @@ -3899,17 +3890,20 @@ def generate_tvchart_html(
modal_html, modal_scripts = wrap_content_with_modals("", modals)
modal_block = f"{modal_html}{modal_scripts}"

bridge_js = _get_pywry_bridge_js(widget_id, token)

return f"""<!DOCTYPE html>
<html class="{theme}">
<head>
<meta charset="utf-8">
<title>{title}</title>
{tvchart_script}
{tvchart_defaults_script}
{pywry_style}
{toast_style}
{inline_style}
{scrollbar_script}
{bridge_js}
{tvchart_script}
{tvchart_defaults_script}
<style>
html, body {{
margin: 0;
Expand Down Expand Up @@ -3954,7 +3948,6 @@ def generate_tvchart_html(
{widget_content}
</div>
{modal_block}
{_get_pywry_bridge_js(widget_id, token)}
{chart_init_script}
</body>
</html>"""
Expand Down
15 changes: 6 additions & 9 deletions pywry/pywry/toolbar.py
Original file line number Diff line number Diff line change
Expand Up @@ -1113,15 +1113,12 @@ def build_html(self) -> str:
f'onclick="{toggle_script}">{_EYE_ICON_SVG}</button>'
)

if buttons_html:
input_wrapper = (
f'<span class="pywry-secret-wrapper">'
f"{input_html}"
f'<span class="pywry-secret-actions">{buttons_html}</span>'
f"</span>"
)
else:
input_wrapper = input_html
input_wrapper = (
f'<span class="pywry-secret-wrapper">'
f"{input_html}"
f'<span class="pywry-secret-actions">{buttons_html}</span>'
f"</span>"
)

if self.label:
return (
Expand Down
2 changes: 0 additions & 2 deletions pywry/pywry/tvchart/normalize.py
Original file line number Diff line number Diff line change
Expand Up @@ -255,8 +255,6 @@ def _detect_symbol_column( # noqa: C901
for col in columns:
if col not in _SYMBOL_ALIASES:
continue
if col in _ALL_OHLCV_ALIASES:
continue
if hasattr(data, "__getitem__") and hasattr(data, "__len__"):
try:
col_data = data[col]
Expand Down
3 changes: 0 additions & 3 deletions pywry/pywry/tvchart/toolbars.py
Original file line number Diff line number Diff line change
Expand Up @@ -422,9 +422,6 @@ def _time_range_presets(intervals: list[str] | None = None) -> tuple[list[Any],
if value in {"all", "ytd"} or (span_lookup[value] / finest_days) >= 3
]

if not preferred:
preferred = candidates[-3:]

selected = next(
(
candidate
Expand Down
77 changes: 77 additions & 0 deletions pywry/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,17 @@
from pathlib import Path
from typing import TYPE_CHECKING, Any

# Pre-import pydantic.root_model and beartype.claw._clawstate to work
# around a Pydantic + beartype + coverage interaction that breaks test
# collection when both packages are involved (e.g. anything importing
# mcp.types). Keep these imports above pytest.
import pydantic.root_model # noqa: F401

try:
import beartype.claw._clawstate # noqa: F401
except ImportError:
pass

import pytest

from tests.constants import (
Expand Down Expand Up @@ -868,3 +879,69 @@ def auth_session_manager(mock_oauth_provider, memory_token_store):
token_store=memory_token_store,
session_key="test_user",
)


# =============================================================================
# MCP Test Fixtures
# =============================================================================


@pytest.fixture
def mcp_fresh_state():
"""Reset all MCP global state before and after each test.

Clears the singleton app, widget registry, widget configs, pending
responses, pending events, and the server-side events bucket.
"""
from pywry.mcp import state as mcp_state
from pywry.mcp.server import _events

mcp_state._app = None
mcp_state._widgets.clear()
mcp_state._widget_configs.clear()
mcp_state._pending_responses.clear()
mcp_state._pending_events.clear()
_events.clear()
yield
mcp_state._app = None
mcp_state._widgets.clear()
mcp_state._widget_configs.clear()
mcp_state._pending_responses.clear()
mcp_state._pending_events.clear()
_events.clear()


@pytest.fixture
def mcp_widget(mcp_fresh_state):
"""Register a single mock widget under id ``w``.

Depends on ``mcp_fresh_state`` so the registry is clean.
"""
from unittest.mock import MagicMock

from pywry.mcp import state as mcp_state

widget = MagicMock()
widget.widget_id = "w"
mcp_state._widgets["w"] = widget
yield widget


def make_handler_ctx(
args: dict[str, Any],
headless: bool = False,
events: dict | None = None,
):
"""Build a HandlerContext for unit-testing MCP handlers.

The ``make_callback`` is a no-op so tests can focus on the handler
contract (widget.emit calls + return dict).
"""
from pywry.mcp.handlers import HandlerContext

return HandlerContext(
args=args,
events=events if events is not None else {},
make_callback=lambda _wid: lambda *_a, **_kw: None,
headless=headless,
)
7 changes: 0 additions & 7 deletions pywry/tests/test_alerts.py
Original file line number Diff line number Diff line change
Expand Up @@ -1015,13 +1015,6 @@ def test_pywry_alert_event_triggers_toast(self) -> None:
# =============================================================================


try:
from pywry.inline import HAS_FASTAPI
except ImportError:
HAS_FASTAPI = False


@pytest.mark.skipif(not HAS_FASTAPI, reason="FastAPI not installed")
class TestInlineAlertE2E:
"""E2E tests for alerts in inline/notebook rendering."""

Expand Down
Loading
Loading