Skip to content

Commit 1b5d492

Browse files
authored
Merge branch 'develop' into dependabot/uv/pywry/ipykernel-7.3.0
2 parents e53f7e1 + 9dd83d9 commit 1b5d492

11 files changed

Lines changed: 98 additions & 47 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

pywry/tests/test_chat_providers_anthropic.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,11 +31,14 @@ class _FakeAnthStream:
3131

3232
def __init__(self, chunks: list[str]):
3333
self._chunks = chunks
34+
self._gen = None
3435

3536
async def __aenter__(self):
3637
return self
3738

3839
async def __aexit__(self, exc_type, exc, tb):
40+
if self._gen:
41+
await self._gen.aclose()
3942
return False
4043

4144
@property
@@ -44,7 +47,9 @@ async def _gen():
4447
for c in self._chunks:
4548
yield c
4649

47-
return _gen()
50+
if self._gen is None:
51+
self._gen = _gen()
52+
return self._gen
4853

4954

5055
def _make_anthropic_client(chunks: list[str]) -> MagicMock:

pywry/tests/test_chat_providers_magentic.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,13 +38,20 @@ def __init__(self, model_name, **_kwargs):
3838
class _AsyncStrIter:
3939
def __init__(self, items):
4040
self._items = items
41+
self._gen = None
4142

4243
def __aiter__(self):
4344
async def _gen():
4445
for x in self._items:
4546
yield x
4647

47-
return _gen()
48+
if self._gen is None:
49+
self._gen = _gen()
50+
return self._gen
51+
52+
async def aclose(self):
53+
if self._gen:
54+
await self._gen.aclose()
4855

4956
class _FakeChat:
5057
def __init__(self, **_kwargs):

pywry/tests/test_chat_providers_openai.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ class _FakeOaiResponse:
4343

4444
def __init__(self, chunks: list[Any]):
4545
self._chunks = chunks
46+
self._gen = None
4647
self.response = MagicMock()
4748
self.response.aclose = AsyncMock()
4849

@@ -51,7 +52,9 @@ async def _gen():
5152
for c in self._chunks:
5253
yield c
5354

54-
return _gen()
55+
if self._gen is None:
56+
self._gen = _gen()
57+
return self._gen
5558

5659

5760
def _make_openai_client(chunks: list[Any]) -> MagicMock:

pywry/tests/test_cli.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -818,6 +818,15 @@ def test_plugin_path_command_dispatches(self):
818818
assert result == 0
819819
mock_handle.assert_called_once()
820820

821+
def test_no_command_prints_help(self):
822+
with (
823+
patch("argparse.ArgumentParser.print_help") as mock_help,
824+
patch.object(sys, "argv", ["pywry"]),
825+
):
826+
result = main()
827+
assert result == 0
828+
mock_help.assert_called_once()
829+
821830

822831
class TestMainModuleEntry:
823832
"""Cover the `if __name__ == "__main__"` guard."""

0 commit comments

Comments
 (0)