Skip to content

Commit 5f5ac2e

Browse files
author
deeleeramone
committed
merge branch develop
1 parent 31b99e0 commit 5f5ac2e

11 files changed

Lines changed: 4725 additions & 42 deletions

File tree

.github/workflows/test-pywry.yml

Lines changed: 18 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -57,21 +57,18 @@ jobs:
5757
run:
5858
working-directory: ${{ github.workspace }}
5959
outputs:
60-
matrix: ${{ steps.matrix.outputs.matrix }}
60+
full-matrix: ${{ steps.matrix.outputs.full-matrix }}
61+
pr-matrix: ${{ steps.matrix.outputs.pr-matrix }}
6162
pytest-targets: ${{ steps.targets.outputs.targets }}
63+
is-scoped: ${{ steps.targets.outputs.is-scoped }}
6264
steps:
6365
- id: matrix
6466
shell: bash
65-
env:
66-
IS_PR: ${{ github.event_name == 'pull_request' }}
67-
HAS_FULL_LABEL: ${{ contains(github.event.pull_request.labels.*.name, 'full-test') }}
6867
run: |
69-
if [[ "$IS_PR" == "true" && "$HAS_FULL_LABEL" != "true" ]]; then
70-
MATRIX='{"include":[{"os":"ubuntu-24.04","python-version":"3.14"},{"os":"windows-2025","python-version":"3.14"},{"os":"macos-15-intel","python-version":"3.14"},{"os":"ubuntu-24.04-arm","python-version":"3.14"},{"os":"windows-11-arm","python-version":"3.14"},{"os":"macos-latest","python-version":"3.14"}]}'
71-
else
72-
MATRIX='{"include":[{"os":"ubuntu-24.04","python-version":"3.11"},{"os":"ubuntu-24.04","python-version":"3.14"},{"os":"windows-2025","python-version":"3.11"},{"os":"windows-2025","python-version":"3.14"},{"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"},{"os":"ubuntu-24.04-arm","python-version":"3.14"},{"os":"windows-11-arm","python-version":"3.12"},{"os":"windows-11-arm","python-version":"3.14"},{"os":"macos-latest","python-version":"3.11"},{"os":"macos-latest","python-version":"3.14"}]}'
73-
fi
74-
echo "matrix=$MATRIX" >> "$GITHUB_OUTPUT"
68+
FULL_MATRIX='{"include":[{"os":"ubuntu-24.04","python-version":"3.11"},{"os":"ubuntu-24.04","python-version":"3.14"},{"os":"windows-2025","python-version":"3.11"},{"os":"windows-2025","python-version":"3.14"},{"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"},{"os":"ubuntu-24.04-arm","python-version":"3.14"},{"os":"windows-11-arm","python-version":"3.12"},{"os":"windows-11-arm","python-version":"3.14"},{"os":"macos-latest","python-version":"3.11"},{"os":"macos-latest","python-version":"3.14"}]}'
69+
PR_MATRIX='{"include":[{"os":"ubuntu-24.04","python-version":"3.14"}]}'
70+
echo "full-matrix=$FULL_MATRIX" >> "$GITHUB_OUTPUT"
71+
echo "pr-matrix=$PR_MATRIX" >> "$GITHUB_OUTPUT"
7572
7673
- name: Checkout for diff
7774
if: github.event_name == 'pull_request' && !contains(github.event.pull_request.labels.*.name, 'full-test')
@@ -90,6 +87,7 @@ jobs:
9087
if [[ "$IS_PR" != "true" || "$HAS_FULL_LABEL" == "true" ]]; then
9188
echo "Full suite (dispatch or full-test label)"
9289
echo "targets=tests/" >> "$GITHUB_OUTPUT"
90+
echo "is-scoped=false" >> "$GITHUB_OUTPUT"
9391
exit 0
9492
fi
9593
@@ -107,13 +105,16 @@ jobs:
107105
if [[ -n "$SOURCE_NON_TEST" || -n "$WORKFLOW_SELF" || -n "$INFRA_TEST" ]]; then
108106
echo "Full suite (source/workflow/test-infra changes)"
109107
echo "targets=tests/" >> "$GITHUB_OUTPUT"
108+
echo "is-scoped=false" >> "$GITHUB_OUTPUT"
110109
elif [[ -n "$TEST_FILES" ]]; then
111110
T=$(echo "$TEST_FILES" | sed 's|^pywry/||' | tr '\n' ' ' | sed 's/ *$//')
112111
echo "Scoped to changed test files: $T"
113112
echo "targets=$T" >> "$GITHUB_OUTPUT"
113+
echo "is-scoped=true" >> "$GITHUB_OUTPUT"
114114
else
115115
echo "No pywry source/test changes; skipping pytest selection"
116116
echo "targets=__skip__" >> "$GITHUB_OUTPUT"
117+
echo "is-scoped=false" >> "$GITHUB_OUTPUT"
117118
fi
118119
119120
test:
@@ -130,7 +131,7 @@ jobs:
130131
# ``sqlcipher3`` is a source build against the SQLCipher C
131132
# library installed in the ``Build SQLCipher`` step below, so
132133
# any Python ABI works (no binary-wheel coverage gaps).
133-
matrix: ${{ fromJSON(needs.gen-matrix.outputs.matrix) }}
134+
matrix: ${{ fromJSON(github.event_name == 'pull_request' && !contains(github.event.pull_request.labels.*.name, 'full-test') ? needs.gen-matrix.outputs.pr-matrix : needs.gen-matrix.outputs.full-matrix) }}
134135

135136
steps:
136137
- uses: actions/checkout@v5
@@ -274,7 +275,7 @@ jobs:
274275
- name: Install dependencies
275276
run: |
276277
python -m pip install --upgrade pip
277-
pip install -e ".[dev]"
278+
pip install -e ".[dev]" pytest-xdist
278279
279280
- name: Verify Docker (Linux)
280281
if: runner.os == 'Linux'
@@ -327,7 +328,7 @@ jobs:
327328
NO_AT_BRIDGE: "1"
328329
PYTEST_TARGETS: ${{ needs.gen-matrix.outputs.pytest-targets }}
329330
run: |
330-
dbus-run-session -- xvfb-run --auto-servernum --server-args="-screen 0 1920x1080x24" python -m pytest -c pytest.ini $PYTEST_TARGETS -v --tb=short
331+
dbus-run-session -- xvfb-run --auto-servernum --server-args="-screen 0 1920x1080x24" python -m pytest -c pytest.ini $PYTEST_TARGETS -n auto -v --tb=short
331332
rc=$?
332333
# Exit 5 = no tests collected after platform filtering on a scoped run; treat as success.
333334
if [[ $rc -eq 5 && "$PYTEST_TARGETS" != "tests/" ]]; then rc=0; fi
@@ -342,7 +343,7 @@ jobs:
342343
PYTEST_TARGETS: ${{ needs.gen-matrix.outputs.pytest-targets }}
343344
shell: pwsh
344345
run: |
345-
python -m pytest -c pytest.ini $env:PYTEST_TARGETS.Split(' ') -v --tb=short --ignore=tests/test_state_redis_integration.py --ignore=tests/test_auth_rbac_integration.py --ignore=tests/test_deploy_mode_integration.py --ignore=tests/test_e2e_deploy_mode.py --ignore=tests/test_e2e_rbac_widgets.py -m "not redis and not container"
346+
python -m pytest -c pytest.ini $env:PYTEST_TARGETS.Split(' ') -n auto -v --tb=short --ignore=tests/test_state_redis_integration.py --ignore=tests/test_auth_rbac_integration.py --ignore=tests/test_deploy_mode_integration.py --ignore=tests/test_e2e_deploy_mode.py --ignore=tests/test_e2e_rbac_widgets.py -m "not redis and not container"
346347
if ($LASTEXITCODE -eq 5 -and $env:PYTEST_TARGETS -ne 'tests/') { exit 0 }
347348
exit $LASTEXITCODE
348349
@@ -355,7 +356,7 @@ jobs:
355356
PYTEST_TARGETS: ${{ needs.gen-matrix.outputs.pytest-targets }}
356357
shell: pwsh
357358
run: |
358-
python -m pytest -c pytest.ini $env:PYTEST_TARGETS.Split(' ') -v --tb=short --ignore=tests/test_state_redis_integration.py --ignore=tests/test_auth_rbac_integration.py --ignore=tests/test_deploy_mode_integration.py --ignore=tests/test_e2e_deploy_mode.py --ignore=tests/test_e2e_rbac_widgets.py --ignore=tests/test_inline_ssl.py -m "not redis and not container"
359+
python -m pytest -c pytest.ini $env:PYTEST_TARGETS.Split(' ') -n auto -v --tb=short --ignore=tests/test_state_redis_integration.py --ignore=tests/test_auth_rbac_integration.py --ignore=tests/test_deploy_mode_integration.py --ignore=tests/test_e2e_deploy_mode.py --ignore=tests/test_e2e_rbac_widgets.py --ignore=tests/test_inline_ssl.py -m "not redis and not container"
359360
if ($LASTEXITCODE -eq 5 -and $env:PYTEST_TARGETS -ne 'tests/') { exit 0 }
360361
exit $LASTEXITCODE
361362
@@ -365,7 +366,7 @@ jobs:
365366
PYWRY_HEADLESS: "1"
366367
PYTEST_TARGETS: ${{ needs.gen-matrix.outputs.pytest-targets }}
367368
run: |
368-
python -m pytest -c pytest.ini $PYTEST_TARGETS -v --tb=short
369+
python -m pytest -c pytest.ini $PYTEST_TARGETS -n auto -v --tb=short
369370
rc=$?
370371
if [[ $rc -eq 5 && "$PYTEST_TARGETS" != "tests/" ]]; then rc=0; fi
371372
exit $rc
@@ -377,7 +378,7 @@ jobs:
377378
PYWRY_DEPLOY__STATE_BACKEND: "memory"
378379
PYTEST_TARGETS: ${{ needs.gen-matrix.outputs.pytest-targets }}
379380
run: |
380-
python -m pytest -c pytest.ini $PYTEST_TARGETS -v --tb=short --ignore=tests/test_state_redis_integration.py --ignore=tests/test_auth_rbac_integration.py --ignore=tests/test_deploy_mode_integration.py --ignore=tests/test_e2e_deploy_mode.py --ignore=tests/test_e2e_rbac_widgets.py -m "not redis and not container"
381+
python -m pytest -c pytest.ini $PYTEST_TARGETS -n auto -v --tb=short --ignore=tests/test_state_redis_integration.py --ignore=tests/test_auth_rbac_integration.py --ignore=tests/test_deploy_mode_integration.py --ignore=tests/test_e2e_deploy_mode.py --ignore=tests/test_e2e_rbac_widgets.py -m "not redis and not container"
381382
rc=$?
382383
if [[ $rc -eq 5 && "$PYTEST_TARGETS" != "tests/" ]]; then rc=0; fi
383384
exit $rc

pywry/pywry/chat/manager.py

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1160,23 +1160,26 @@ async def _handle_async_stream(
11601160
"""Stream from an async generator."""
11611161
state = _StreamState(message_id)
11621162
typing_hidden = False
1163-
async for item in agen:
1163+
try:
1164+
async for item in agen:
1165+
if not typing_hidden:
1166+
typing_hidden = True
1167+
self._emit(
1168+
"chat:typing-indicator",
1169+
{"typing": False, "threadId": thread_id},
1170+
)
1171+
if cancel.is_set():
1172+
self._handle_cancel(state, thread_id)
1173+
return
1174+
self._process_handler_item(item, state, thread_id, ctx)
11641175
if not typing_hidden:
1165-
typing_hidden = True
11661176
self._emit(
11671177
"chat:typing-indicator",
11681178
{"typing": False, "threadId": thread_id},
11691179
)
1170-
if cancel.is_set():
1171-
self._handle_cancel(state, thread_id)
1172-
return
1173-
self._process_handler_item(item, state, thread_id, ctx)
1174-
if not typing_hidden:
1175-
self._emit(
1176-
"chat:typing-indicator",
1177-
{"typing": False, "threadId": thread_id},
1178-
)
1179-
self._finalize_stream(state, thread_id)
1180+
self._finalize_stream(state, thread_id)
1181+
finally:
1182+
await agen.aclose()
11801183

11811184
def _inject_context(
11821185
self,

pywry/pywry/chat/providers/anthropic.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -137,10 +137,13 @@ async def prompt(
137137
temperature=session.get("temperature", 0.7),
138138
max_tokens=session.get("max_tokens", 4096),
139139
) as stream:
140-
async for text in stream.text_stream:
141-
if cancel_event and cancel_event.is_set():
142-
raise GenerationCancelledError()
143-
yield AgentMessageUpdate(text=text)
140+
try:
141+
async for text in stream.text_stream:
142+
if cancel_event and cancel_event.is_set():
143+
raise GenerationCancelledError()
144+
yield AgentMessageUpdate(text=text)
145+
finally:
146+
await stream.text_stream.aclose()
144147

145148
async def cancel(self, session_id: str) -> None:
146149
"""Cancel is handled cooperatively via ``cancel_event``.

pywry/pywry/chat/providers/callback.py

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -143,10 +143,14 @@ def _wrap(item: Any) -> Any:
143143
return AgentMessageUpdate(text=item) if isinstance(item, str) else item
144144

145145
if hasattr(result, "__aiter__"):
146-
async for item in result:
147-
if cancel_event and cancel_event.is_set():
148-
raise GenerationCancelledError()
149-
yield _wrap(item)
146+
try:
147+
async for item in result:
148+
if cancel_event and cancel_event.is_set():
149+
raise GenerationCancelledError()
150+
yield _wrap(item)
151+
finally:
152+
if hasattr(result, "aclose"):
153+
await result.aclose()
150154
elif hasattr(result, "__iter__"):
151155
for item in result:
152156
if cancel_event and cancel_event.is_set():

pywry/pywry/chat/providers/magentic.py

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -150,10 +150,14 @@ async def prompt(
150150
output_types=[AsyncStreamedStr],
151151
)
152152
chat = await chat.asubmit()
153-
async for chunk in chat.last_message.content:
154-
if cancel_event and cancel_event.is_set():
155-
raise GenerationCancelledError()
156-
yield AgentMessageUpdate(text=chunk)
153+
try:
154+
async for chunk in chat.last_message.content:
155+
if cancel_event and cancel_event.is_set():
156+
raise GenerationCancelledError()
157+
yield AgentMessageUpdate(text=chunk)
158+
finally:
159+
if hasattr(chat.last_message.content, "aclose"):
160+
await chat.last_message.content.aclose()
157161

158162
async def cancel(self, session_id: str) -> None:
159163
"""Cancel is handled cooperatively via ``cancel_event``.

pywry/pywry/state/redis.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -310,8 +310,10 @@ async def subscribe(self, channel: str) -> AsyncIterator[EventMessage]:
310310
pubsub = r.pubsub()
311311
await pubsub.subscribe(self._channel_name(channel))
312312

313+
listen_gen = None
313314
try:
314-
async for message in pubsub.listen():
315+
listen_gen = pubsub.listen()
316+
async for message in listen_gen:
315317
if message["type"] == "message":
316318
try:
317319
data = json.loads(message["data"])
@@ -327,6 +329,8 @@ async def subscribe(self, channel: str) -> AsyncIterator[EventMessage]:
327329
except json.JSONDecodeError:
328330
continue
329331
finally:
332+
if listen_gen:
333+
await listen_gen.aclose()
330334
await pubsub.unsubscribe(self._channel_name(channel))
331335
await pubsub.close()
332336

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
"""Tests for the Anthropic chat provider.
2+
3+
Source: ``pywry/chat/providers/anthropic.py``.
4+
5+
The Anthropic client is mocked via ``unittest.mock`` so tests run without
6+
network or an installed ``anthropic`` package.
7+
"""
8+
9+
from __future__ import annotations
10+
11+
import asyncio
12+
import builtins
13+
import sys
14+
15+
from unittest.mock import MagicMock
16+
17+
import pytest
18+
19+
from pywry.chat.models import GenerationCancelledError, TextPart
20+
from pywry.chat.session import ClientCapabilities
21+
from pywry.chat.updates import AgentMessageUpdate
22+
23+
24+
# =============================================================================
25+
# Anthropic client surrogate
26+
# =============================================================================
27+
28+
29+
class _FakeAnthStream:
30+
"""Async-context-manager + async-iterable text stream surrogate."""
31+
32+
def __init__(self, chunks: list[str]):
33+
self._chunks = chunks
34+
self._gen = None
35+
36+
async def __aenter__(self):
37+
return self
38+
39+
async def __aexit__(self, exc_type, exc, tb):
40+
if self._gen:
41+
await self._gen.aclose()
42+
return False
43+
44+
@property
45+
def text_stream(self):
46+
async def _gen():
47+
for c in self._chunks:
48+
yield c
49+
50+
if self._gen is None:
51+
self._gen = _gen()
52+
return self._gen
53+
54+
55+
def _make_anthropic_client(chunks: list[str]) -> MagicMock:
56+
"""Build a fake AsyncAnthropic whose ``.messages.stream`` returns a context-manager."""
57+
client = MagicMock()
58+
stream_obj = _FakeAnthStream(chunks)
59+
60+
def _stream(**_kwargs):
61+
return stream_obj
62+
63+
client.messages.stream = MagicMock(side_effect=_stream)
64+
return client
65+
66+
67+
@pytest.fixture
68+
def anthropic_module(monkeypatch):
69+
"""Install a fake ``anthropic`` module whose AsyncAnthropic returns an
70+
empty MagicMock — useful for tests that exercise paths that don't drive
71+
the client."""
72+
fake_module = MagicMock()
73+
fake_module.AsyncAnthropic = lambda **_kwargs: MagicMock()
74+
monkeypatch.setitem(sys.modules, "anthropic", fake_module)
75+
return fake_module
76+
77+
78+
# =============================================================================
79+
# Tests
80+
# =============================================================================
81+
82+
83+
class TestAnthropicProvider:
84+
def test_import_error_when_anthropic_missing(self, monkeypatch):
85+
real_import = builtins.__import__
86+
87+
def fake_import(name, *args, **kwargs):
88+
if name == "anthropic":
89+
raise ImportError("no anthropic")
90+
return real_import(name, *args, **kwargs)
91+
92+
monkeypatch.setattr(builtins, "__import__", fake_import)
93+
from pywry.chat.providers.anthropic import AnthropicProvider
94+
95+
with pytest.raises(ImportError, match="pywry\\[anthropic\\]"):
96+
AnthropicProvider(api_key="x")
97+
98+
def test_constructor_creates_client(self, monkeypatch):
99+
fake_client = MagicMock()
100+
fake_module = MagicMock()
101+
fake_module.AsyncAnthropic = lambda **_kwargs: fake_client
102+
monkeypatch.setitem(sys.modules, "anthropic", fake_module)
103+
from pywry.chat.providers.anthropic import AnthropicProvider
104+
105+
provider = AnthropicProvider(api_key="sk-test")
106+
assert provider._client is fake_client
107+
assert provider._sessions == {}
108+
109+
async def test_initialize_returns_image_capabilities(self, anthropic_module):
110+
from pywry.chat.providers.anthropic import AnthropicProvider
111+
112+
provider = AnthropicProvider()
113+
caps = await provider.initialize(ClientCapabilities())
114+
assert caps.prompt_capabilities is not None
115+
assert caps.prompt_capabilities.image is True
116+
117+
async def test_new_session_returns_id_with_prefix(self, anthropic_module):
118+
from pywry.chat.providers.anthropic import AnthropicProvider
119+
120+
provider = AnthropicProvider()
121+
sid = await provider.new_session("/tmp")
122+
assert sid.startswith("ant_")
123+
assert provider._sessions[sid]["model"] == "claude-sonnet-4-20250514"
124+
125+
async def test_prompt_yields_agent_message_updates(self, monkeypatch):
126+
client = _make_anthropic_client(["Hello", " world"])
127+
fake_module = MagicMock()
128+
fake_module.AsyncAnthropic = lambda **_kwargs: client
129+
monkeypatch.setitem(sys.modules, "anthropic", fake_module)
130+
from pywry.chat.providers.anthropic import AnthropicProvider
131+
132+
provider = AnthropicProvider()
133+
sid = await provider.new_session("/tmp")
134+
updates = []
135+
async for u in provider.prompt(sid, [TextPart(text="hi")]):
136+
updates.append(u)
137+
assert [u.text for u in updates] == ["Hello", " world"]
138+
assert all(isinstance(u, AgentMessageUpdate) for u in updates)
139+
140+
async def test_prompt_respects_cancel_event(self, monkeypatch):
141+
client = _make_anthropic_client(["a", "b", "c"])
142+
fake_module = MagicMock()
143+
fake_module.AsyncAnthropic = lambda **_kwargs: client
144+
monkeypatch.setitem(sys.modules, "anthropic", fake_module)
145+
from pywry.chat.providers.anthropic import AnthropicProvider
146+
147+
provider = AnthropicProvider()
148+
sid = await provider.new_session("/tmp")
149+
cancel = asyncio.Event()
150+
cancel.set()
151+
152+
with pytest.raises(GenerationCancelledError):
153+
async for _ in provider.prompt(sid, [TextPart(text="hi")], cancel_event=cancel):
154+
pass
155+
156+
async def test_cancel_is_noop(self, anthropic_module):
157+
from pywry.chat.providers.anthropic import AnthropicProvider
158+
159+
provider = AnthropicProvider()
160+
assert await provider.cancel("any-session") is None

0 commit comments

Comments
 (0)