Script 'mail_helper' called by obssrc
Hello community,
here is the log from the commit of package python-sse-starlette for
openSUSE:Factory checked in at 2026-03-24 18:48:56
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Comparing /work/SRC/openSUSE:Factory/python-sse-starlette (Old)
and /work/SRC/openSUSE:Factory/.python-sse-starlette.new.8177 (New)
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Package is "python-sse-starlette"
Tue Mar 24 18:48:56 2026 rev:4 rq:1342123 version:3.3.3
Changes:
--------
---
/work/SRC/openSUSE:Factory/python-sse-starlette/python-sse-starlette.changes
2026-03-10 18:01:17.439290675 +0100
+++
/work/SRC/openSUSE:Factory/.python-sse-starlette.new.8177/python-sse-starlette.changes
2026-03-24 18:49:50.856879014 +0100
@@ -1,0 +2,7 @@
+Mon Mar 23 22:53:53 UTC 2026 - Dirk Müller <[email protected]>
+
+- update to 3.3.3:
+ * updated documentation
+ * bump dependencies
+
+-------------------------------------------------------------------
Old:
----
sse_starlette-3.3.2.tar.gz
New:
----
sse_starlette-3.3.3.tar.gz
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Other differences:
------------------
++++++ python-sse-starlette.spec ++++++
--- /var/tmp/diff_new_pack.I3oka0/_old 2026-03-24 18:49:51.408901791 +0100
+++ /var/tmp/diff_new_pack.I3oka0/_new 2026-03-24 18:49:51.412901956 +0100
@@ -18,7 +18,7 @@
%{?sle15_python_module_pythons}
Name: python-sse-starlette
-Version: 3.3.2
+Version: 3.3.3
Release: 0
Summary: SSE plugin for Starlette
License: BSD-3-Clause
++++++ sse_starlette-3.3.2.tar.gz -> sse_starlette-3.3.3.tar.gz ++++++
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/sse-starlette-3.3.2/.github/workflows/build.yml
new/sse-starlette-3.3.3/.github/workflows/build.yml
--- old/sse-starlette-3.3.2/.github/workflows/build.yml 2026-02-28
12:27:42.000000000 +0100
+++ new/sse-starlette-3.3.3/.github/workflows/build.yml 2026-03-17
21:04:53.000000000 +0100
@@ -20,7 +20,7 @@
uses: actions/checkout@v6
- name: Install uv
- uses: astral-sh/setup-uv@v6
+ uses: astral-sh/setup-uv@v7
- name: Set up Python
uses: actions/setup-python@v6
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/sse-starlette-3.3.2/ARCHITECTURE.md
new/sse-starlette-3.3.3/ARCHITECTURE.md
--- old/sse-starlette-3.3.2/ARCHITECTURE.md 1970-01-01 01:00:00.000000000
+0100
+++ new/sse-starlette-3.3.3/ARCHITECTURE.md 2026-03-17 21:04:53.000000000
+0100
@@ -0,0 +1,273 @@
+# Shutdown & Cancellation Flow
+
+## Task Group Architecture
+
+When `EventSourceResponse.__call__` is invoked, it creates an `anyio` task
group with
+four concurrent tasks, each wrapped in `cancel_on_finish`. **Whichever task
returns first
+cancels all siblings** via `task_group.cancel_scope.cancel()`.
+
+```
+EventSourceResponse.__call__(scope, receive, send)
+ |
+ v
+anyio.create_task_group()
+ |
+ +-- cancel_on_finish(_stream_response) # pushes SSE data to client
+ +-- cancel_on_finish(_ping) # keepalive pings every ~15s
+ +-- cancel_on_finish(_listen_for_exit_signal_with_grace) # server shutdown
+ +-- cancel_on_finish(_listen_for_disconnect) # client disconnect
+ | (+ optional: data_sender_callable)
+ |
+ v
+All tasks cancelled --> background task (if any) --> return
+```
+
+---
+
+## The `cancel_on_finish` Pattern
+
+```python
+async def cancel_on_finish(coro):
+ await coro() # run until coro returns
+ task_group.cancel_scope.cancel() # then cancel ALL sibling tasks
+```
+
+This makes the task group a **race**: the first task to complete wins and
kills the rest.
+
+---
+
+## Flow 1: Normal Generator Exhaustion
+
+```
+Generator yields all items and finishes naturally.
+
+_stream_response _ping _exit_signal _disconnect
+ | | | |
+ async for data: | | |
+ send(chunk) sleep(15) wait(event) receive()
+ send(chunk) | | |
+ ... | | |
+ [generator ends] | | |
+ self.active = False | | |
+ send(more_body=False) | | |
+ return | | |
+ | | | |
+ cancel_on_finish ----> CANCEL CANCEL CANCEL
+ |
+ v
+ Task group exits cleanly
+```
+
+---
+
+## Flow 2: Client Disconnect
+
+```
+Client closes connection (browser navigates away, network drop).
+
+_stream_response _ping _exit_signal _disconnect
+ | | | |
+ async for data: sleep(15) wait(event) receive()
+ send(chunk) | | |
+ | | | http.disconnect
+ | | | self.active=False
+ | | | return
+ | | | |
+ | cancel_on_finish <-------- CANCEL <--+
+ | | |
+ CANCEL <----------------+----- CANCEL <-+
+ |
+ v
+ Task group exits (generator receives CancelledError)
+```
+
+---
+
+## Flow 3: Server Shutdown (No Cooperative Shutdown)
+
+Default behavior when `shutdown_event` is not provided.
+
+```
+ SIGTERM / SIGINT
+ |
+ v
+ Server.handle_exit() [monkey-patched]
+ |
+ AppStatus.should_exit = True
+ |
+ v
+ _shutdown_watcher (polls every 0.5s)
+ |
+ detects should_exit == True
+ |
+ broadcasts to all registered anyio.Events
+ |
+ v
+
+_stream_response _ping _exit_signal_with_grace _disconnect
+ | | | |
+ async for data: sleep(15) _listen_for_exit_signal() receive()
+ send(chunk) | | |
+ | | event.wait() returns |
+ | | | |
+ | | shutdown_event = None |
+ | | grace_period = 0 |
+ | | --> return immediately |
+ | | | |
+ CANCEL <------------ CANCEL <-- cancel_on_finish CANCEL <-+
+ |
+ v
+ Generator receives CancelledError (no chance for farewell)
+ Task group exits
+```
+
+---
+
+## Flow 4: Server Shutdown WITH Cooperative Shutdown (Issue #167)
+
+When `shutdown_event` and `shutdown_grace_period` are provided.
+
+```
+ SIGTERM / SIGINT
+ |
+ v
+ AppStatus.should_exit = True
+ |
+ _shutdown_watcher broadcasts
+ |
+ v
+
+_stream_response _ping _exit_signal_with_grace _disconnect
+ | | | |
+ async for data: sleep(15) _listen_for_exit_signal() receive()
+ send(chunk) | | |
+ | | event.wait() returns |
+ | | | |
+ | | shutdown_event.set() |
+ | | (user event signaled) |
+ | | | |
+ | | move_on_after(grace_period) |
+ | | | |
+ | | +-- while self.active: |
+ | | | sleep(0.1) |
+ | | | |
+ [generator sees event] | | (polling...) |
+ yield farewell event | | |
+ return | | |
+ self.active = False | | |
+ send(more_body=False) | | |
+ return | | |
+ | | | |
+ cancel_on_finish -----> CANCEL CANCEL CANCEL
+ |
+ v
+ Clean exit! Farewell event reached client.
+```
+
+### Sub-scenario: Generator ignores shutdown_event (grace expires)
+
+```
+_stream_response _exit_signal_with_grace
+ | |
+ async for data: _listen_for_exit_signal() returns
+ send(chunk) |
+ | shutdown_event.set()
+ | |
+ [generator ignores event, move_on_after(grace_period)
+ keeps yielding] |
+ | +-- while self.active:
+ | | sleep(0.1)
+ | | ...
+ | | (grace_period seconds pass)
+ | |
+ | +-- move_on_after EXPIRES
+ | |
+ | return
+ | |
+ CANCEL <----------------------- cancel_on_finish
+ |
+ v
+ Generator receives CancelledError (force-cancelled)
+```
+
+---
+
+## Flow 5: Send Timeout
+
+```
+_stream_response _ping _exit_signal _disconnect
+ | | | |
+ async for data: | | |
+ move_on_after(timeout):| | |
+ send(chunk) | | |
+ [send hangs!] | | |
+ ... | | |
+ [timeout expires] | | |
+ cancel_called = True | | |
+ aclose() iterator | | |
+ raise SendTimeoutError | | |
+ | | | |
+ EXCEPTION propagates through cancel_on_finish into task group
+ Task group cancels all siblings
+```
+
+---
+
+## Shutdown Detection: Two-Layer Architecture
+
+```
+Layer 1: Signal Capture (process-wide)
++------------------------------------------------------------------+
+| |
+| SIGTERM/SIGINT |
+| | |
+| v |
+| uvicorn.Server.handle_exit() [monkey-patched at import time] |
+| | |
+| v |
+| AppStatus.should_exit = True |
+| + calls original uvicorn handler |
+| |
+| Fallback (monkey-patch fails, e.g. uvicorn 0.29+): |
+| _get_uvicorn_server() introspects signal.getsignal(SIGTERM) |
+| to find uvicorn's Server instance and check .should_exit |
+| |
++------------------------------------------------------------------+
+
+Layer 2: Per-Thread Broadcast (thread-local)
++------------------------------------------------------------------+
+| |
+| Thread A (main event loop) Thread B (secondary loop) |
+| +----------------------------+ +------------------------+ |
+| | _thread_state.shutdown_state| | _thread_state (separate)| |
+| | .events = {ev1, ev2, ev3}| | .events = {ev4} | |
+| | .watcher_started = True | | .watcher_started=True| |
+| +----------------------------+ +------------------------+ |
+| | | |
+| _shutdown_watcher() _shutdown_watcher() |
+| polls AppStatus.should_exit polls AppStatus.should_exit|
+| every 0.5s every 0.5s |
+| | | |
+| on True: ev1.set() on True: ev4.set() |
+| ev2.set() |
+| ev3.set() |
+| |
++------------------------------------------------------------------+
+```
+
+Each SSE connection registers its own `anyio.Event` with the thread's shutdown
state.
+One watcher per thread broadcasts to all connections in that thread.
+
+---
+
+## Summary: Who Wins the Race?
+
+| Scenario | Task that returns first | Effect on generator |
+|---|---|---|
+| Generator exhausted | `_stream_response` | Clean exit, farewell sent |
+| Client disconnects | `_listen_for_disconnect` | CancelledError |
+| Server shutdown (no grace) | `_listen_for_exit_signal_with_grace` |
CancelledError |
+| Server shutdown (with grace, generator cooperates) | `_stream_response` |
Clean exit, farewell sent |
+| Server shutdown (with grace, generator ignores) |
`_listen_for_exit_signal_with_grace` (after timeout) | CancelledError |
+| Send timeout | `_stream_response` (via exception) | SendTimeoutError |
+| Client disconnect during grace | `_listen_for_disconnect` | Grace period cut
short |
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/sse-starlette-3.3.2/README.md
new/sse-starlette-3.3.3/README.md
--- old/sse-starlette-3.3.2/README.md 2026-02-28 12:27:42.000000000 +0100
+++ new/sse-starlette-3.3.3/README.md 2026-03-17 21:04:53.000000000 +0100
@@ -50,6 +50,9 @@
- **Thread Safety**: Context-local event management for multi-threaded
applications
- **Multi-Loop Support**: Works correctly with multiple asyncio event loops
+For a detailed look at the internal task coordination, shutdown detection, and
cancellation
+flows, see [ARCHITECTURE.md](ARCHITECTURE.md).
+
## Key Components
### EventSourceResponse
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/sse-starlette-3.3.2/VERSION
new/sse-starlette-3.3.3/VERSION
--- old/sse-starlette-3.3.2/VERSION 2026-02-28 12:27:42.000000000 +0100
+++ new/sse-starlette-3.3.3/VERSION 2026-03-17 21:04:53.000000000 +0100
@@ -1 +1 @@
-3.3.2
+3.3.3
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/sse-starlette-3.3.2/pyproject.toml
new/sse-starlette-3.3.3/pyproject.toml
--- old/sse-starlette-3.3.2/pyproject.toml 2026-02-28 12:27:42.000000000
+0100
+++ new/sse-starlette-3.3.3/pyproject.toml 2026-03-17 21:04:53.000000000
+0100
@@ -1,6 +1,6 @@
[project]
name = "sse-starlette"
-version = "3.3.2"
+version = "3.3.3"
description = "SSE plugin for Starlette"
readme = "README.md"
license = "BSD-3-Clause"
@@ -73,7 +73,7 @@
include = ["sse_starlette*"]
[tool.bumpversion]
-current_version = "3.3.2"
+current_version = "3.3.3"
commit = true
tag = false
message = "Bump version to {new_version}"
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/sse-starlette-3.3.2/sse_starlette/__init__.py
new/sse-starlette-3.3.3/sse_starlette/__init__.py
--- old/sse-starlette-3.3.2/sse_starlette/__init__.py 2026-02-28
12:27:42.000000000 +0100
+++ new/sse-starlette-3.3.3/sse_starlette/__init__.py 2026-03-17
21:04:53.000000000 +0100
@@ -2,4 +2,4 @@
from sse_starlette.sse import EventSourceResponse
__all__ = ["EventSourceResponse", "ServerSentEvent", "JSONServerSentEvent"]
-__version__ = "3.3.2"
+__version__ = "3.3.3"
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore'
old/sse-starlette-3.3.2/tests/integration/test_multiple_consumers.py
new/sse-starlette-3.3.3/tests/integration/test_multiple_consumers.py
--- old/sse-starlette-3.3.2/tests/integration/test_multiple_consumers.py
2026-02-28 12:27:42.000000000 +0100
+++ new/sse-starlette-3.3.3/tests/integration/test_multiple_consumers.py
2026-03-17 21:04:53.000000000 +0100
@@ -37,7 +37,7 @@
async def consume_events(url: str, expected_lines: int = 2):
"""Simulate Client: Stream the SSE endpoint and count received lines."""
i = 0
- async with httpx.AsyncClient() as client:
+ async with httpx.AsyncClient(trust_env=False) as client:
try:
async with client.stream("GET", url) as response:
async for line in response.aiter_lines():
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/sse-starlette-3.3.2/uv.lock
new/sse-starlette-3.3.3/uv.lock
--- old/sse-starlette-3.3.2/uv.lock 2026-02-28 12:27:42.000000000 +0100
+++ new/sse-starlette-3.3.3/uv.lock 2026-03-17 21:04:53.000000000 +0100
@@ -1105,11 +1105,11 @@
[[package]]
name = "pyasn1"
-version = "0.6.2"
+version = "0.6.3"
source = { registry = "https://pypi.org/simple" }
-sdist = { url =
"https://files.pythonhosted.org/packages/fe/b6/6e630dff89739fcd427e3f72b3d905ce0acb85a45d4ec3e2678718a3487f/pyasn1-0.6.2.tar.gz",
hash =
"sha256:9b59a2b25ba7e4f8197db7686c09fb33e658b98339fadb826e9512629017833b", size
= 146586, upload-time = "2026-01-16T18:04:18.534Z" }
+sdist = { url =
"https://files.pythonhosted.org/packages/5c/5f/6583902b6f79b399c9c40674ac384fd9cd77805f9e6205075f828ef11fb2/pyasn1-0.6.3.tar.gz",
hash =
"sha256:697a8ecd6d98891189184ca1fa05d1bb00e2f84b5977c481452050549c8a72cf", size
= 148685, upload-time = "2026-03-17T01:06:53.382Z" }
wheels = [
- { url =
"https://files.pythonhosted.org/packages/44/b5/a96872e5184f354da9c84ae119971a0a4c221fe9b27a4d94bd43f2596727/pyasn1-0.6.2-py3-none-any.whl",
hash =
"sha256:1eb26d860996a18e9b6ed05e7aae0e9fc21619fcee6af91cca9bad4fbea224bf", size
= 83371, upload-time = "2026-01-16T18:04:17.174Z" },
+ { url =
"https://files.pythonhosted.org/packages/5d/a0/7d793dce3fa811fe047d6ae2431c672364b462850c6235ae306c0efd025f/pyasn1-0.6.3-py3-none-any.whl",
hash =
"sha256:a80184d120f0864a52a073acc6fc642847d0be408e7c7252f31390c0f4eadcde", size
= 83997, upload-time = "2026-03-17T01:06:52.036Z" },
]
[[package]]
@@ -1277,15 +1277,15 @@
[[package]]
name = "pyopenssl"
-version = "25.3.0"
+version = "26.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cryptography" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
-sdist = { url =
"https://files.pythonhosted.org/packages/80/be/97b83a464498a79103036bc74d1038df4a7ef0e402cfaf4d5e113fb14759/pyopenssl-25.3.0.tar.gz",
hash =
"sha256:c981cb0a3fd84e8602d7afc209522773b94c1c2446a3c710a75b06fe1beae329", size
= 184073, upload-time = "2025-09-17T00:32:21.037Z" }
+sdist = { url =
"https://files.pythonhosted.org/packages/8e/11/a62e1d33b373da2b2c2cd9eb508147871c80f12b1cacde3c5d314922afdd/pyopenssl-26.0.0.tar.gz",
hash =
"sha256:f293934e52936f2e3413b89c6ce36df66a0b34ae1ea3a053b8c5020ff2f513fc", size
= 185534, upload-time = "2026-03-15T14:28:26.353Z" }
wheels = [
- { url =
"https://files.pythonhosted.org/packages/d1/81/ef2b1dfd1862567d573a4fdbc9f969067621764fbb74338496840a1d2977/pyopenssl-25.3.0-py3-none-any.whl",
hash =
"sha256:1fda6fc034d5e3d179d39e59c1895c9faeaf40a79de5fc4cbbfbe0d36f4a77b6", size
= 57268, upload-time = "2025-09-17T00:32:19.474Z" },
+ { url =
"https://files.pythonhosted.org/packages/fb/7d/d4f7d908fa8415571771b30669251d57c3cf313b36a856e6d7548ae01619/pyopenssl-26.0.0-py3-none-any.whl",
hash =
"sha256:df94d28498848b98cc1c0ffb8ef1e71e40210d3b0a8064c9d29571ed2904bf81", size
= 57969, upload-time = "2026-03-15T14:28:24.864Z" },
]
[[package]]