Skip to content
Merged
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
4 changes: 1 addition & 3 deletions pywry/pywry/tvchart/normalize.py
Original file line number Diff line number Diff line change
Expand Up @@ -225,7 +225,7 @@ def _detect_ohlcv_column_types(data: Any) -> dict[str, str]:
return {str(col): str(dtype) for col, dtype in data.dtypes.items()}


def _detect_symbol_column( # noqa: C901
def _detect_symbol_column(
columns: list[str],
data: Any,
symbol_col: str | None = None,
Expand Down 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
25 changes: 25 additions & 0 deletions pywry/ruff.toml
Original file line number Diff line number Diff line change
Expand Up @@ -77,16 +77,41 @@ dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$"
# rule is inappropriate under ``tests/`` specifically.
"tests/**/*.py" = [
"S101", # `assert` is the primary test mechanism
"S102", # `exec` used intentionally in subprocess / module-reload tests
"S603", # subprocess calls are intentional in fallback tests
"D", # pydocstyle rules do not apply to test fixtures / cases
"F401", # unused imports common in test scaffolding (`from x import y` for side effects)
"F841", # unused variables from `with patch(...) as mock_x:` — side-effect-only mocks
"PLR2004", # magic numbers are expected in assertions
"PLR0915", # long test functions are acceptable
"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)
"ASYNC251", # time.sleep in async test functions is intentional
"PERF401", # readability beats micro-optimisation in tests
"E741", # ambiguous variable names (`l` for label in lambdas)
"N802", # test names may contain camelCase from source code identifiers
"N805", # first arg not `self` in test helper classes
"N806", # variable names in tests may be uppercase for clarity
"B017", # blind exception catches acceptable in validation tests
"B018", # useless expressions acceptable in test assertions
"C901", # complex test helper functions are acceptable
"RUF006", # un-stored asyncio.create_task in test scaffolding
"RUF012", # mutable class defaults in test helper dataclasses
"RUF043", # unescaped regex metacharacters in match= patterns
"TRY301", # raise in try block is intentional in test helpers
"PTH100", # os.path usage acceptable in tests
"PTH118", # os.path.join acceptable in tests
"PTH120", # os.path.dirname acceptable in tests
"PLW2901", # loop variable overwrite acceptable in test data processing
"A002", # shadowing builtins acceptable in test fixture args
"F811", # redefinition acceptable for test class name reuse
"SIM117", # nested with statements acceptable in complex test setup
"E402", # late imports acceptable after pytest.importorskip / guards
]

"__init__.py" = [
Expand Down
84 changes: 79 additions & 5 deletions pywry/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,16 @@
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


with contextlib.suppress(ImportError):
import beartype.claw._clawstate

import pytest

from tests.constants import (
Expand Down Expand Up @@ -579,16 +589,14 @@ def callback_registry():

def _configure_testcontainers() -> None:
"""Configure testcontainers settings for the current platform."""
try:
with contextlib.suppress(ImportError):
from testcontainers.core.config import testcontainers_config

testcontainers_config.ryuk_disabled = True

# Ensure images are always pulled (don't rely on local cache check)
# This fixes issues on some CI environments
os.environ.setdefault("TC_IMAGE_PULL_POLICY", "always")
except ImportError:
pass # testcontainers not installed


def _start_redis_container_with_fallback():
Expand Down Expand Up @@ -645,7 +653,7 @@ def redis_container() -> Generator[str, None, None]:
return

try:
import testcontainers.redis # noqa: F401
import testcontainers.redis
except ImportError:
pytest.skip("testcontainers not installed (pip install testcontainers[redis])")
return
Expand Down Expand Up @@ -683,7 +691,7 @@ def redis_container_with_acl() -> Generator[dict, None, None]:
- users: Dict of user info (username, password, role)
"""
try:
import testcontainers.redis # noqa: F401
import testcontainers.redis
except ImportError:
pytest.skip("testcontainers not installed")
return
Expand Down Expand Up @@ -868,3 +876,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,
)
Loading
Loading