Skip to content

Commit b9e1dff

Browse files
authored
[Feature] Improve Test Coverage (#85)
* merge branch develop * improve test coverage * lint fixes * more fixes * more fixes --------- Co-authored-by: deeleeramone <>
1 parent c6f8e59 commit b9e1dff

111 files changed

Lines changed: 44307 additions & 8612 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

pywry/pywry/chat/providers/deepagent.py

Lines changed: 24 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -161,65 +161,53 @@ def _step_in_call(self, ch: str) -> None:
161161
self._in_string = False
162162
self._escape = False
163163

164-
def _step_in_special(self, ch: str, out: list[str]) -> None:
165-
"""Advance the ``<|...|>`` state machine; recurse on tail after ``|>``."""
164+
def _step_in_special(self, ch: str, _out: list[str]) -> None:
165+
"""Advance the ``<|...|>`` state machine; close when ``|>`` arrives.
166+
167+
Because ``feed()`` drives one character at a time and ``_in_special``
168+
is entered with an empty buffer, the close marker is always at the
169+
tail of the buffer — there is no trailing text to recurse on.
170+
"""
166171
self._buffer += ch
167-
close_idx = self._buffer.find(self._SPECIAL_CLOSE)
168-
if close_idx < 0:
172+
if self._SPECIAL_CLOSE not in self._buffer:
169173
return
170-
rest = self._buffer[close_idx + len(self._SPECIAL_CLOSE) :]
171174
self._buffer = ""
172175
self._in_special = False
173-
if rest:
174-
out.append(self.feed(rest))
175176

176-
def _try_open_call(self, out: list[str]) -> bool:
177+
def _try_open_call(self, _out: list[str]) -> bool:
177178
"""If a complete ``functions.<name>...{`` opener sits in buffer, enter call mode.
178179
179180
Returns True if the buffer was consumed (caller skips other checks);
180-
False if the marker isn't fully present yet — caller must NOT keep
181-
scanning the buffer for ``<|`` (the ``functions.`` prefix already
182-
committed us to wait).
181+
False if the marker isn't fully present yet. ``_flush_safe_prefix``
182+
guarantees ``functions.`` always sits at the buffer head when it's
183+
present, and char-by-char feeding means ``{`` is always the tail —
184+
no leading prefix to emit and no trailing text to recurse on.
183185
"""
184-
call_idx = self._buffer.find(self._CALL_START)
185-
if call_idx < 0:
186+
if self._CALL_START not in self._buffer:
186187
return False
187-
brace_idx = self._buffer.find("{", call_idx + len(self._CALL_START))
188+
brace_idx = self._buffer.find("{", len(self._CALL_START))
188189
if brace_idx < 0:
189190
# Marker present but no ``{`` yet — keep buffering, do not
190191
# fall through to the ``<|`` check (it would never match
191192
# ``functions.`` and we'd over-emit).
192193
return True
193-
if call_idx > 0:
194-
out.append(self._buffer[:call_idx])
195-
rest = self._buffer[brace_idx + 1 :]
196194
self._buffer = ""
197195
self._in_call = True
198196
self._depth = 1
199197
self._in_string = False
200198
self._escape = False
201-
if rest:
202-
out.append(self.feed(rest))
203199
return True
204200

205-
def _try_open_special(self, out: list[str]) -> bool:
206-
"""If a ``<|...|>`` token (or its open) is in buffer, drop it; return True."""
207-
special_idx = self._buffer.find(self._SPECIAL_OPEN)
208-
if special_idx < 0:
201+
def _try_open_special(self, _out: list[str]) -> bool:
202+
"""If a ``<|`` opener sits in buffer, drop it and enter skip mode.
203+
204+
``_flush_safe_prefix`` guarantees only ``<|`` itself (no trailing
205+
text) ever reaches us, and the closing ``|>`` is consumed later
206+
by ``_step_in_special`` — so we only need to handle the "open
207+
seen, no close yet" case.
208+
"""
209+
if self._SPECIAL_OPEN not in self._buffer:
209210
return False
210-
close_idx = self._buffer.find(self._SPECIAL_CLOSE, special_idx + len(self._SPECIAL_OPEN))
211-
if close_idx >= 0:
212-
if special_idx > 0:
213-
out.append(self._buffer[:special_idx])
214-
rest = self._buffer[close_idx + len(self._SPECIAL_CLOSE) :]
215-
self._buffer = ""
216-
if rest:
217-
out.append(self.feed(rest))
218-
return True
219-
# Open seen but no close yet — drop everything from ``<|`` on,
220-
# emit the prefix, enter token-skip mode.
221-
if special_idx > 0:
222-
out.append(self._buffer[:special_idx])
223211
self._buffer = ""
224212
self._in_special = True
225213
return True

pywry/pywry/cli.py

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -447,9 +447,6 @@ def show_config_sources() -> int:
447447
if forced_status is True:
448448
status = "✓ Active"
449449
path_display = ""
450-
elif forced_status is False:
451-
status = "✗ Not found"
452-
path_display = path_str
453450
# Check if file exists
454451
elif name == "Environment variables":
455452
import os

pywry/pywry/config.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727

2828
if sys.version_info >= (3, 11):
2929
import tomllib
30-
else:
30+
else: # pragma: no cover - python 3.10 fallback; cannot be exercised on 3.11+
3131
try:
3232
import tomli as tomllib
3333
except ImportError:

pywry/pywry/inline.py

Lines changed: 9 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -80,16 +80,11 @@ def _get_default_theme() -> ThemeLiteral:
8080
return "system" if is_headless() else "dark"
8181

8282

83-
try:
84-
import uvicorn
85-
86-
from fastapi import FastAPI, Request, WebSocket, WebSocketDisconnect
87-
from fastapi.middleware.cors import CORSMiddleware
88-
from fastapi.responses import HTMLResponse, Response
83+
import uvicorn
8984

90-
HAS_FASTAPI = True
91-
except ImportError:
92-
HAS_FASTAPI = False
85+
from fastapi import FastAPI, Request, WebSocket, WebSocketDisconnect
86+
from fastapi.middleware.cors import CORSMiddleware
87+
from fastapi.responses import HTMLResponse, Response
9388

9489
try:
9590
from ipywidgets import Output
@@ -1688,8 +1683,6 @@ def __init__(
16881683
token: str | None = None,
16891684
) -> None:
16901685
super().__init__()
1691-
if not HAS_FASTAPI:
1692-
raise ImportError("fastapi and uvicorn required: pip install fastapi uvicorn")
16931686

16941687
# For browser_only mode, we don't need IPython (just the server + browser)
16951688
self._browser_only = browser_only
@@ -3337,8 +3330,6 @@ def generate_dataframe_html(
33373330
}
33383331
if grid_options:
33393332
grid_config.update(grid_options)
3340-
if "rowData" not in grid_config:
3341-
grid_config["rowData"] = row_data
33423333

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

3893+
bridge_js = _get_pywry_bridge_js(widget_id, token)
3894+
39023895
return f"""<!DOCTYPE html>
39033896
<html class="{theme}">
39043897
<head>
39053898
<meta charset="utf-8">
39063899
<title>{title}</title>
3907-
{tvchart_script}
3908-
{tvchart_defaults_script}
39093900
{pywry_style}
39103901
{toast_style}
39113902
{inline_style}
39123903
{scrollbar_script}
3904+
{bridge_js}
3905+
{tvchart_script}
3906+
{tvchart_defaults_script}
39133907
<style>
39143908
html, body {{
39153909
margin: 0;
@@ -3954,7 +3948,6 @@ def generate_tvchart_html(
39543948
{widget_content}
39553949
</div>
39563950
{modal_block}
3957-
{_get_pywry_bridge_js(widget_id, token)}
39583951
{chart_init_script}
39593952
</body>
39603953
</html>"""

pywry/pywry/toolbar.py

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1113,15 +1113,12 @@ def build_html(self) -> str:
11131113
f'onclick="{toggle_script}">{_EYE_ICON_SVG}</button>'
11141114
)
11151115

1116-
if buttons_html:
1117-
input_wrapper = (
1118-
f'<span class="pywry-secret-wrapper">'
1119-
f"{input_html}"
1120-
f'<span class="pywry-secret-actions">{buttons_html}</span>'
1121-
f"</span>"
1122-
)
1123-
else:
1124-
input_wrapper = input_html
1116+
input_wrapper = (
1117+
f'<span class="pywry-secret-wrapper">'
1118+
f"{input_html}"
1119+
f'<span class="pywry-secret-actions">{buttons_html}</span>'
1120+
f"</span>"
1121+
)
11251122

11261123
if self.label:
11271124
return (

pywry/pywry/tvchart/normalize.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -225,7 +225,7 @@ def _detect_ohlcv_column_types(data: Any) -> dict[str, str]:
225225
return {str(col): str(dtype) for col, dtype in data.dtypes.items()}
226226

227227

228-
def _detect_symbol_column( # noqa: C901
228+
def _detect_symbol_column(
229229
columns: list[str],
230230
data: Any,
231231
symbol_col: str | None = None,
@@ -255,8 +255,6 @@ def _detect_symbol_column( # noqa: C901
255255
for col in columns:
256256
if col not in _SYMBOL_ALIASES:
257257
continue
258-
if col in _ALL_OHLCV_ALIASES:
259-
continue
260258
if hasattr(data, "__getitem__") and hasattr(data, "__len__"):
261259
try:
262260
col_data = data[col]

pywry/pywry/tvchart/toolbars.py

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -422,9 +422,6 @@ def _time_range_presets(intervals: list[str] | None = None) -> tuple[list[Any],
422422
if value in {"all", "ytd"} or (span_lookup[value] / finest_days) >= 3
423423
]
424424

425-
if not preferred:
426-
preferred = candidates[-3:]
427-
428425
selected = next(
429426
(
430427
candidate

pywry/ruff.toml

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,16 +77,41 @@ dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$"
7777
# rule is inappropriate under ``tests/`` specifically.
7878
"tests/**/*.py" = [
7979
"S101", # `assert` is the primary test mechanism
80+
"S102", # `exec` used intentionally in subprocess / module-reload tests
81+
"S603", # subprocess calls are intentional in fallback tests
8082
"D", # pydocstyle rules do not apply to test fixtures / cases
83+
"F401", # unused imports common in test scaffolding (`from x import y` for side effects)
84+
"F841", # unused variables from `with patch(...) as mock_x:` — side-effect-only mocks
8185
"PLR2004", # magic numbers are expected in assertions
86+
"PLR0915", # long test functions are acceptable
8287
"S104", # binding to 0.0.0.0 in integration tests (testcontainers, etc.)
8388
"S105", # hard-coded "password" / secret fixtures
8489
"S106", # hard-coded passwords in test kwargs (e.g. OAuth fixtures)
8590
"S108", # hard-coded /tmp paths in fixtures
8691
"S310", # unverified urllib calls against fixture URLs
8792
"ARG", # unused fixture / mock arguments are idiomatic in pytest
8893
"ASYNC240", # deliberately blocking calls in async tests (e.g. assertions)
94+
"ASYNC251", # time.sleep in async test functions is intentional
8995
"PERF401", # readability beats micro-optimisation in tests
96+
"E741", # ambiguous variable names (`l` for label in lambdas)
97+
"N802", # test names may contain camelCase from source code identifiers
98+
"N805", # first arg not `self` in test helper classes
99+
"N806", # variable names in tests may be uppercase for clarity
100+
"B017", # blind exception catches acceptable in validation tests
101+
"B018", # useless expressions acceptable in test assertions
102+
"C901", # complex test helper functions are acceptable
103+
"RUF006", # un-stored asyncio.create_task in test scaffolding
104+
"RUF012", # mutable class defaults in test helper dataclasses
105+
"RUF043", # unescaped regex metacharacters in match= patterns
106+
"TRY301", # raise in try block is intentional in test helpers
107+
"PTH100", # os.path usage acceptable in tests
108+
"PTH118", # os.path.join acceptable in tests
109+
"PTH120", # os.path.dirname acceptable in tests
110+
"PLW2901", # loop variable overwrite acceptable in test data processing
111+
"A002", # shadowing builtins acceptable in test fixture args
112+
"F811", # redefinition acceptable for test class name reuse
113+
"SIM117", # nested with statements acceptable in complex test setup
114+
"E402", # late imports acceptable after pytest.importorskip / guards
90115
]
91116

92117
"__init__.py" = [

pywry/tests/conftest.py

Lines changed: 79 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,16 @@
1111
from pathlib import Path
1212
from typing import TYPE_CHECKING, Any
1313

14+
# Pre-import pydantic.root_model and beartype.claw._clawstate to work
15+
# around a Pydantic + beartype + coverage interaction that breaks test
16+
# collection when both packages are involved (e.g. anything importing
17+
# mcp.types). Keep these imports above pytest.
18+
import pydantic.root_model
19+
20+
21+
with contextlib.suppress(ImportError):
22+
import beartype.claw._clawstate
23+
1424
import pytest
1525

1626
from tests.constants import (
@@ -579,16 +589,14 @@ def callback_registry():
579589

580590
def _configure_testcontainers() -> None:
581591
"""Configure testcontainers settings for the current platform."""
582-
try:
592+
with contextlib.suppress(ImportError):
583593
from testcontainers.core.config import testcontainers_config
584594

585595
testcontainers_config.ryuk_disabled = True
586596

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

593601

594602
def _start_redis_container_with_fallback():
@@ -645,7 +653,7 @@ def redis_container() -> Generator[str, None, None]:
645653
return
646654

647655
try:
648-
import testcontainers.redis # noqa: F401
656+
import testcontainers.redis
649657
except ImportError:
650658
pytest.skip("testcontainers not installed (pip install testcontainers[redis])")
651659
return
@@ -683,7 +691,7 @@ def redis_container_with_acl() -> Generator[dict, None, None]:
683691
- users: Dict of user info (username, password, role)
684692
"""
685693
try:
686-
import testcontainers.redis # noqa: F401
694+
import testcontainers.redis
687695
except ImportError:
688696
pytest.skip("testcontainers not installed")
689697
return
@@ -868,3 +876,69 @@ def auth_session_manager(mock_oauth_provider, memory_token_store):
868876
token_store=memory_token_store,
869877
session_key="test_user",
870878
)
879+
880+
881+
# =============================================================================
882+
# MCP Test Fixtures
883+
# =============================================================================
884+
885+
886+
@pytest.fixture
887+
def mcp_fresh_state():
888+
"""Reset all MCP global state before and after each test.
889+
890+
Clears the singleton app, widget registry, widget configs, pending
891+
responses, pending events, and the server-side events bucket.
892+
"""
893+
from pywry.mcp import state as mcp_state
894+
from pywry.mcp.server import _events
895+
896+
mcp_state._app = None
897+
mcp_state._widgets.clear()
898+
mcp_state._widget_configs.clear()
899+
mcp_state._pending_responses.clear()
900+
mcp_state._pending_events.clear()
901+
_events.clear()
902+
yield
903+
mcp_state._app = None
904+
mcp_state._widgets.clear()
905+
mcp_state._widget_configs.clear()
906+
mcp_state._pending_responses.clear()
907+
mcp_state._pending_events.clear()
908+
_events.clear()
909+
910+
911+
@pytest.fixture
912+
def mcp_widget(mcp_fresh_state):
913+
"""Register a single mock widget under id ``w``.
914+
915+
Depends on ``mcp_fresh_state`` so the registry is clean.
916+
"""
917+
from unittest.mock import MagicMock
918+
919+
from pywry.mcp import state as mcp_state
920+
921+
widget = MagicMock()
922+
widget.widget_id = "w"
923+
mcp_state._widgets["w"] = widget
924+
yield widget
925+
926+
927+
def make_handler_ctx(
928+
args: dict[str, Any],
929+
headless: bool = False,
930+
events: dict | None = None,
931+
):
932+
"""Build a HandlerContext for unit-testing MCP handlers.
933+
934+
The ``make_callback`` is a no-op so tests can focus on the handler
935+
contract (widget.emit calls + return dict).
936+
"""
937+
from pywry.mcp.handlers import HandlerContext
938+
939+
return HandlerContext(
940+
args=args,
941+
events=events if events is not None else {},
942+
make_callback=lambda _wid: lambda *_a, **_kw: None,
943+
headless=headless,
944+
)

0 commit comments

Comments
 (0)