pytest-stream-lifecycle 0.1.0a1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,6 @@
1
+ """Real-loopback ASGI test server."""
2
+
3
+ from .probe import LifecycleError, LifecycleProbe
4
+ from .server import ServerError, ServerHandle, live_server
5
+
6
+ __all__ = ["LifecycleError", "LifecycleProbe", "ServerError", "ServerHandle", "live_server"]
@@ -0,0 +1,20 @@
1
+ """Attach secondary exception evidence without replacing a primary failure."""
2
+
3
+
4
+ def add_exception_notes(primary: BaseException, label: str, error: BaseException) -> None:
5
+ seen: set[int] = set()
6
+ pending = [error]
7
+ while pending:
8
+ current = pending.pop()
9
+ if current is primary or id(current) in seen:
10
+ continue
11
+ seen.add(id(current))
12
+ primary.add_note(f"{label}: {current!r}")
13
+ for note in getattr(current, "__notes__", ()):
14
+ primary.add_note(note)
15
+ if isinstance(current, BaseExceptionGroup):
16
+ pending.extend(reversed(current.exceptions))
17
+ if current.__context__ is not None:
18
+ pending.append(current.__context__)
19
+ if current.__cause__ is not None:
20
+ pending.append(current.__cause__)
@@ -0,0 +1,18 @@
1
+ """Pytest fixture registration."""
2
+
3
+ import pytest
4
+
5
+ from .probe import LifecycleProbe
6
+ from .server import live_server
7
+
8
+
9
+ @pytest.fixture
10
+ def stream_server():
11
+ """Return a factory for async context managers serving ASGI applications."""
12
+ return live_server
13
+
14
+
15
+ @pytest.fixture
16
+ def stream_probe():
17
+ """Return a new explicit application-lifecycle probe."""
18
+ return LifecycleProbe()
@@ -0,0 +1,153 @@
1
+ """Explicit observations of application-owned resource lifetimes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from collections import defaultdict
7
+ from types import TracebackType
8
+ from typing import Self
9
+
10
+
11
+ class LifecycleError(AssertionError):
12
+ """An observed lifecycle or streaming expectation was not met."""
13
+
14
+
15
+ def _positive_timeout(timeout: float) -> float:
16
+ import math
17
+
18
+ try:
19
+ value = float(timeout)
20
+ except (TypeError, ValueError) as exc:
21
+ raise ValueError("timeout must be a positive finite number") from exc
22
+ if not math.isfinite(value) or value <= 0:
23
+ raise ValueError("timeout must be a positive finite number")
24
+ return value
25
+
26
+
27
+ class _Observation:
28
+ def __init__(self, probe: LifecycleProbe, name: str):
29
+ self._probe = probe
30
+ self.name = name
31
+ self.active = False
32
+ self.acknowledged = False
33
+ self.failure: BaseException | None = None
34
+ self._entered = False
35
+
36
+ def __enter__(self) -> Self:
37
+ self._probe._check_loop()
38
+ if self._entered:
39
+ raise RuntimeError("an observation cannot be entered more than once")
40
+ self._entered = self.active = True
41
+ self._probe._entries[self.name].append(self)
42
+ self._probe._changed()
43
+ return self
44
+
45
+ def mark_closed(self) -> None:
46
+ """Acknowledge that this instance's awaited cleanup has completed."""
47
+ self._probe._check_loop()
48
+ if not self.active:
49
+ raise RuntimeError("mark_closed() requires an active observation")
50
+ self.acknowledged = True
51
+ self._probe._changed()
52
+
53
+ def __exit__(
54
+ self,
55
+ exc_type: type[BaseException] | None,
56
+ exc: BaseException | None,
57
+ tb: TracebackType | None,
58
+ ) -> None:
59
+ self._probe._check_loop()
60
+ self.active = False
61
+ if exc is not None and not isinstance(exc, (asyncio.CancelledError, GeneratorExit)):
62
+ self.failure = exc
63
+ self._probe._changed()
64
+
65
+
66
+ class LifecycleProbe:
67
+ """Record explicitly instrumented resource instances on one asyncio loop.
68
+
69
+ A cleanup acknowledgement is supplied by the application, not proof of
70
+ remote-provider cancellation. The probe never cancels application tasks.
71
+ """
72
+
73
+ def __init__(self) -> None:
74
+ self._entries: dict[str, list[_Observation]] = defaultdict(list)
75
+ self._loop: asyncio.AbstractEventLoop | None = None
76
+ self._event: asyncio.Event | None = None
77
+
78
+ def _check_loop(self) -> None:
79
+ loop = asyncio.get_running_loop()
80
+ if self._loop is None:
81
+ self._loop = loop
82
+ self._event = asyncio.Event()
83
+ elif self._loop is not loop:
84
+ raise RuntimeError("LifecycleProbe cannot be reused across asyncio event loops")
85
+
86
+ def _changed(self) -> None:
87
+ assert self._event is not None
88
+ self._event.set()
89
+ self._event = asyncio.Event()
90
+
91
+ def track(self, name: str) -> _Observation:
92
+ if not isinstance(name, str) or not name:
93
+ raise ValueError("resource name must be a nonempty string")
94
+ return _Observation(self, name)
95
+
96
+ @property
97
+ def active_labels(self) -> frozenset[str]:
98
+ self._check_loop()
99
+ return frozenset(
100
+ name for name, entries in self._entries.items() if any(e.active for e in entries)
101
+ )
102
+
103
+ def started_count(self, name: str) -> int:
104
+ self._check_loop()
105
+ return len(self._entries.get(name, ()))
106
+
107
+ def assert_active(self, name: str) -> None:
108
+ self._check_loop()
109
+ if name not in self.active_labels:
110
+ state = "not observed" if not self.started_count(name) else "not active"
111
+ raise LifecycleError(f"resource {name!r} is {state}")
112
+
113
+ async def wait_started(self, name: str, *, timeout: float = 2.0) -> None:
114
+ self._check_loop()
115
+ deadline = asyncio.get_running_loop().time() + _positive_timeout(timeout)
116
+ while not self.started_count(name):
117
+ await self._wait_change(deadline, f"resource {name!r} was not observed")
118
+
119
+ async def _wait_change(self, deadline: float, diagnostic: str) -> None:
120
+ assert self._event is not None
121
+ remaining = deadline - asyncio.get_running_loop().time()
122
+ if remaining <= 0:
123
+ raise LifecycleError(f"timed out: {diagnostic}")
124
+ try:
125
+ await asyncio.wait_for(self._event.wait(), timeout=remaining)
126
+ except TimeoutError as exc:
127
+ raise LifecycleError(f"timed out: {diagnostic}") from exc
128
+
129
+ async def wait_closed(self, *names: str, timeout: float = 2.0) -> None:
130
+ self._check_loop()
131
+ if not names:
132
+ raise LifecycleError("wait_closed requires at least one resource name")
133
+ deadline = asyncio.get_running_loop().time() + _positive_timeout(timeout)
134
+ while True:
135
+ unknown = [name for name in names if not self._entries.get(name)]
136
+ active = [name for name in names if any(e.active for e in self._entries.get(name, ()))]
137
+ failed = [e for name in names for e in self._entries.get(name, ()) if e.failure]
138
+ unacknowledged = [
139
+ name
140
+ for name in names
141
+ if any(not e.active and not e.acknowledged for e in self._entries.get(name, ()))
142
+ ]
143
+ if failed:
144
+ raise LifecycleError(
145
+ f"resource {failed[0].name!r} exited with error: {failed[0].failure!r}"
146
+ ) from failed[0].failure
147
+ if unacknowledged:
148
+ raise LifecycleError(
149
+ f"resource cleanup unacknowledged: {', '.join(unacknowledged)}"
150
+ )
151
+ if not unknown and not active:
152
+ return
153
+ await self._wait_change(deadline, f"unknown: {unknown}; active: {active}")
@@ -0,0 +1,314 @@
1
+ """In-process, asyncio-only Uvicorn server on a reserved loopback socket.
2
+
3
+ Cancellation-resistant application code cannot be forcibly killed in process.
4
+ Timeouts bound our waits; any tasks that ignore cancellation may continue running.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import math
11
+ import socket
12
+ from collections.abc import AsyncIterator, Callable
13
+ from contextlib import asynccontextmanager, contextmanager
14
+ from dataclasses import dataclass
15
+ from typing import Any, Literal
16
+
17
+ import uvicorn
18
+
19
+ from ._errors import add_exception_notes
20
+
21
+ ASGIApp = Callable[..., Any]
22
+ Lifespan = Literal["on", "off", "auto"]
23
+
24
+
25
+ class ServerError(RuntimeError):
26
+ """The server failed to start or stop within its allotted time."""
27
+
28
+
29
+ @dataclass(frozen=True, slots=True)
30
+ class ServerHandle:
31
+ """Address of a running HTTP server."""
32
+
33
+ url: str
34
+
35
+ def sse(self, path: str, *, method: str = "GET", timeout: float = 2.0, **request_kwargs: Any):
36
+ """Open a bounded SSE reader over the server's real HTTP socket."""
37
+ from .streams import open_stream
38
+
39
+ return open_stream(
40
+ self.url, path, kind="sse", method=method, timeout=timeout, **request_kwargs
41
+ )
42
+
43
+ def lines(self, path: str, *, method: str = "GET", timeout: float = 2.0, **request_kwargs: Any):
44
+ """Open a bounded line reader over the server's real HTTP socket."""
45
+ from .streams import open_stream
46
+
47
+ return open_stream(
48
+ self.url, path, kind="lines", method=method, timeout=timeout, **request_kwargs
49
+ )
50
+
51
+
52
+ class _Config(uvicorn.Config):
53
+ def configure_logging(self) -> None:
54
+ # Uvicorn's default method changes global logging even when
55
+ # log_config=None (and removes access handlers when access_log=False).
56
+ pass
57
+
58
+
59
+ class _Server(uvicorn.Server):
60
+ @contextmanager
61
+ def capture_signals(self):
62
+ # Embedded server must never replace the test process's signal handlers.
63
+ yield
64
+
65
+
66
+ def _timeout(name: str, value: float) -> float:
67
+ try:
68
+ number = float(value)
69
+ except (TypeError, ValueError) as exc:
70
+ raise ValueError(f"{name} must be a positive finite number") from exc
71
+ if not math.isfinite(number) or number <= 0:
72
+ raise ValueError(f"{name} must be a positive finite number")
73
+ return number
74
+
75
+
76
+ async def _wait_until_started(server: _Server, task: asyncio.Task[None], timeout: float) -> None:
77
+ loop = asyncio.get_running_loop()
78
+ deadline = loop.time() + timeout
79
+ while not server.started:
80
+ if task.done():
81
+ try:
82
+ task.result()
83
+ except BaseException as exc:
84
+ raise ServerError("server failed during startup") from exc
85
+ raise ServerError("server exited before startup completed")
86
+ remaining = deadline - loop.time()
87
+ if remaining <= 0:
88
+ raise ServerError(f"server startup timed out after {timeout:g}s")
89
+ await asyncio.wait({task}, timeout=min(0.01, remaining))
90
+
91
+
92
+ async def _stop(
93
+ server: _Server,
94
+ task: asyncio.Task[None],
95
+ sock: socket.socket,
96
+ app_tasks: set[asyncio.Task[Any]],
97
+ timeout: float,
98
+ task_errors: list[BaseException],
99
+ ) -> None:
100
+ loop = asyncio.get_running_loop()
101
+ deadline = loop.time() + timeout
102
+ server.should_exit = True
103
+ timed_out = False
104
+ # Reserve part of the deadline for cooperative cancellation and its cleanup.
105
+ grace = timeout * 0.8
106
+ grace_deadline = loop.time() + grace
107
+ try:
108
+ done, pending = await asyncio.wait({task}, timeout=grace)
109
+ if pending:
110
+ timed_out = True
111
+ server.force_exit = True
112
+ task.cancel()
113
+ owned = {t for t in app_tasks if not t.done()} | set(pending)
114
+ if owned and not timed_out:
115
+ _, owned = await asyncio.wait(owned, timeout=max(0, grace_deadline - loop.time()))
116
+ timed_out = bool(owned)
117
+ if owned:
118
+ for owned_task in owned:
119
+ owned_task.cancel()
120
+ completed, _ = await asyncio.wait(owned, timeout=max(0, deadline - loop.time()))
121
+ for completed_task in completed:
122
+ if not completed_task.cancelled():
123
+ error = completed_task.exception()
124
+ if error is not None and error not in task_errors:
125
+ task_errors.append(error)
126
+ server_error = task.exception() if done and not task.cancelled() else None
127
+ if server_error is not None:
128
+ error = ServerError("server failed while running or shutting down")
129
+ for secondary in task_errors:
130
+ add_exception_notes(error, "Owned task cleanup failure", secondary)
131
+ raise error from server_error
132
+ if timed_out:
133
+ error = ServerError(f"server shutdown timed out after {timeout:g}s")
134
+ for secondary in task_errors:
135
+ add_exception_notes(error, "Owned task cleanup failure", secondary)
136
+ raise error
137
+ if task_errors:
138
+ error = ServerError(f"owned task cleanup failed: {task_errors[0]!r}")
139
+ for secondary in task_errors[1:]:
140
+ add_exception_notes(error, "Additional owned task failure", secondary)
141
+ raise error from task_errors[0]
142
+ finally:
143
+ # Uvicorn's asyncio.Server normally owns the supplied socket. If its
144
+ # serve task was cancelled mid-shutdown, close its listener explicitly
145
+ # before closing the original socket, and never touch other servers.
146
+ for listener in getattr(server, "servers", ()):
147
+ listener.close()
148
+ for connection in tuple(server.server_state.connections):
149
+ transport = getattr(connection, "transport", None)
150
+ if transport is not None:
151
+ transport.abort()
152
+ sock.close()
153
+
154
+
155
+ @asynccontextmanager
156
+ async def live_server(
157
+ app: ASGIApp,
158
+ *,
159
+ startup_timeout: float = 5.0,
160
+ shutdown_timeout: float = 5.0,
161
+ lifespan: Lifespan = "on",
162
+ ) -> AsyncIterator[ServerHandle]:
163
+ """Serve ``app`` on a reserved 127.0.0.1 port within this event loop.
164
+
165
+ Startup and shutdown waits are bounded. Cancellation-resistant application
166
+ code can outlive those waits; process isolation is required for a hard kill.
167
+ """
168
+ startup_timeout = _timeout("startup_timeout", startup_timeout)
169
+ shutdown_timeout = _timeout("shutdown_timeout", shutdown_timeout)
170
+ if lifespan not in ("on", "off", "auto"):
171
+ raise ValueError("lifespan must be 'on', 'off', or 'auto'")
172
+ app_tasks: set[asyncio.Task[Any]] = set()
173
+ task_errors: list[BaseException] = []
174
+ lifespan_started = False
175
+ startup_failure: BaseException | str | None = None
176
+ lifespan_failure: BaseException | str | None = None
177
+
178
+ async def tracked_app(scope, receive, send):
179
+ nonlocal lifespan_started, lifespan_failure, startup_failure
180
+ current = asyncio.current_task()
181
+ if current is not None:
182
+ app_tasks.add(current)
183
+
184
+ def task_done(done):
185
+ app_tasks.discard(done)
186
+ if not done.cancelled():
187
+ error = done.exception()
188
+ if error is not None and error not in task_errors:
189
+ task_errors.append(error)
190
+
191
+ current.add_done_callback(task_done)
192
+ if scope["type"] != "lifespan":
193
+ try:
194
+ await app(scope, receive, send)
195
+ except BaseException as exc:
196
+ if (
197
+ current is not None
198
+ and current.cancelling()
199
+ and not isinstance(exc, asyncio.CancelledError)
200
+ ):
201
+ task_errors.append(exc)
202
+ raise
203
+ return
204
+
205
+ async def tracked_send(message):
206
+ nonlocal lifespan_started, lifespan_failure, startup_failure
207
+ if message["type"] == "lifespan.startup.complete":
208
+ lifespan_started = True
209
+ elif message["type"] == "lifespan.startup.failed":
210
+ startup_failure = message.get("message") or "application reported startup failure"
211
+ elif message["type"] == "lifespan.shutdown.failed":
212
+ lifespan_failure = message.get("message") or "application reported shutdown failure"
213
+ await send(message)
214
+
215
+ try:
216
+ await app(scope, receive, tracked_send)
217
+ except BaseException as exc:
218
+ if not isinstance(exc, asyncio.CancelledError):
219
+ if lifespan_started:
220
+ lifespan_failure = exc
221
+ elif startup_failure is None:
222
+ startup_failure = exc
223
+ raise
224
+
225
+ sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
226
+ task: asyncio.Task[None] | None = None
227
+ server: _Server | None = None
228
+ failure: BaseException | None = None
229
+ try:
230
+ sock.bind(("127.0.0.1", 0))
231
+ sock.listen(socket.SOMAXCONN)
232
+ sock.setblocking(False)
233
+ port = sock.getsockname()[1]
234
+ config = _Config(
235
+ tracked_app,
236
+ lifespan=lifespan,
237
+ log_config=None,
238
+ access_log=False,
239
+ http="h11",
240
+ ws="none",
241
+ # Uvicorn also polls in main_loop and sleeps before waiting for
242
+ # active connections. Reserve fixed time for those phases and
243
+ # scheduler jitter, in addition to our cancellation reserve.
244
+ timeout_graceful_shutdown=min(
245
+ shutdown_timeout * 0.4, max(0.0, shutdown_timeout * 0.8 - 0.3)
246
+ ),
247
+ )
248
+ server = _Server(config)
249
+
250
+ async def serve() -> None:
251
+ try:
252
+ await server.serve(sockets=[sock])
253
+ except SystemExit as exc:
254
+ # Recent Uvicorn versions call sys.exit on failed startup.
255
+ # A BaseException escaping a task stops the host event loop.
256
+ raise ServerError("Uvicorn exited during startup") from exc
257
+
258
+ task = asyncio.create_task(serve(), name="stream-lifecycle-server")
259
+ try:
260
+ await _wait_until_started(server, task, startup_timeout)
261
+ except ServerError:
262
+ if startup_failure is not None:
263
+ detail = f"application lifespan startup failed: {startup_failure}"
264
+ if isinstance(startup_failure, BaseException):
265
+ raise ServerError(detail) from startup_failure
266
+ raise ServerError(detail) from None
267
+ raise
268
+ yield ServerHandle(url=f"http://127.0.0.1:{port}")
269
+ except BaseException as exc:
270
+ failure = exc
271
+ raise
272
+ finally:
273
+ if task is not None and server is not None:
274
+
275
+ async def cleanup() -> None:
276
+ cleanup_error: BaseException | None = None
277
+ try:
278
+ await _stop(server, task, sock, app_tasks, shutdown_timeout, task_errors)
279
+ except Exception as exc: # noqa: BLE001 - preserve cleanup failure
280
+ cleanup_error = exc
281
+ if lifespan_failure is not None:
282
+ detail = f"application lifespan failed after startup: {lifespan_failure}"
283
+ if cleanup_error is not None:
284
+ if isinstance(lifespan_failure, BaseException):
285
+ add_exception_notes(cleanup_error, detail, lifespan_failure)
286
+ else:
287
+ cleanup_error.add_note(detail)
288
+ elif isinstance(lifespan_failure, BaseException):
289
+ raise ServerError(detail) from lifespan_failure
290
+ else:
291
+ raise ServerError(detail)
292
+ if cleanup_error is not None:
293
+ raise cleanup_error
294
+
295
+ cleanup_task = asyncio.create_task(cleanup(), name="stream-lifecycle-cleanup")
296
+ interrupted: asyncio.CancelledError | None = None
297
+ while not cleanup_task.done():
298
+ try:
299
+ await asyncio.shield(cleanup_task)
300
+ except asyncio.CancelledError as exc:
301
+ if interrupted is None:
302
+ interrupted = exc
303
+ except Exception: # noqa: BLE001 - retrieve the cleanup task error below
304
+ break
305
+ cleanup_error = None if cleanup_task.cancelled() else cleanup_task.exception()
306
+ primary = failure if failure is not None else interrupted
307
+ if cleanup_error is not None:
308
+ if primary is None:
309
+ raise cleanup_error
310
+ add_exception_notes(primary, "Server cleanup failure", cleanup_error)
311
+ if failure is None and interrupted is not None:
312
+ raise interrupted
313
+ else:
314
+ sock.close()
@@ -0,0 +1,152 @@
1
+ """Bounded real-network line and server-sent-event readers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from collections.abc import AsyncIterator
7
+ from contextlib import asynccontextmanager
8
+ from functools import partial
9
+ from typing import Any
10
+
11
+ import httpx
12
+ from httpx_sse import EventSource
13
+
14
+ from ._errors import add_exception_notes
15
+ from .probe import LifecycleError, _positive_timeout
16
+
17
+
18
+ async def _close_steps(*steps) -> None:
19
+ error: BaseException | None = None
20
+ for close in steps:
21
+ try:
22
+ await close()
23
+ except BaseException as exc: # noqa: BLE001 - attempt every owned cleanup step
24
+ if error is None:
25
+ error = exc
26
+ else:
27
+ add_exception_notes(error, "Additional stream cleanup failure", exc)
28
+ if error is not None:
29
+ raise error
30
+
31
+
32
+ class Stream:
33
+ """An open response; closing it does not assert application cleanup."""
34
+
35
+ def __init__(self, client, response_context, response, iterator, timeout: float, label: str):
36
+ self._client = client
37
+ self._response_context = response_context
38
+ self._response = response
39
+ self._iterator = iterator
40
+ self._timeout = timeout
41
+ self._label = label
42
+ self._closed = False
43
+ self._reading = False
44
+
45
+ async def next(self, *, timeout: float | None = None) -> Any:
46
+ if self._closed:
47
+ raise LifecycleError(f"{self._label} stream is closed")
48
+ if self._reading:
49
+ raise LifecycleError(f"{self._label} stream already has a pending read")
50
+ duration = self._timeout if timeout is None else _positive_timeout(timeout)
51
+ self._reading = True
52
+ try:
53
+ try:
54
+ return await asyncio.wait_for(anext(self._iterator), timeout=duration)
55
+ except TimeoutError as exc:
56
+ error = LifecycleError(f"{self._label} stream timed out after {duration:g}s")
57
+ await self._close_with_primary(error)
58
+ raise error from exc
59
+ except StopAsyncIteration as exc:
60
+ error = LifecycleError(f"{self._label} stream ended unexpectedly (EOF)")
61
+ await self._close_with_primary(error)
62
+ raise error from exc
63
+ except asyncio.CancelledError as exc:
64
+ await self._close_with_primary(exc)
65
+ raise
66
+ except Exception as exc:
67
+ error = LifecycleError(f"{self._label} stream read failed: {exc}")
68
+ await self._close_with_primary(error)
69
+ raise error from exc
70
+ finally:
71
+ self._reading = False
72
+
73
+ async def _close_with_primary(self, primary: BaseException) -> None:
74
+ try:
75
+ await self.aclose()
76
+ except BaseException as exc: # noqa: BLE001 - preserve the primary failure
77
+ add_exception_notes(primary, "Stream cleanup failure", exc)
78
+
79
+ async def aclose(self) -> None:
80
+ if self._closed:
81
+ return
82
+ self._closed = True
83
+ await _close_steps(
84
+ self._iterator.aclose,
85
+ partial(self._response_context.__aexit__, None, None, None),
86
+ self._client.aclose,
87
+ )
88
+
89
+
90
+ @asynccontextmanager
91
+ async def open_stream(
92
+ base_url: str,
93
+ path: str,
94
+ *,
95
+ kind: str,
96
+ method: str = "GET",
97
+ timeout: float = 2.0,
98
+ **request_kwargs: Any,
99
+ ) -> AsyncIterator[Stream]:
100
+ duration = _positive_timeout(timeout)
101
+ if not isinstance(path, str) or not path.startswith("/") or path.startswith("//"):
102
+ raise ValueError("path must be a relative path starting with a single '/'")
103
+ if kind == "sse":
104
+ headers = httpx.Headers(request_kwargs.pop("headers", {}))
105
+ headers["Accept"] = "text/event-stream"
106
+ headers["Cache-Control"] = "no-store"
107
+ request_kwargs["headers"] = headers
108
+ client = httpx.AsyncClient(trust_env=False, timeout=None)
109
+ response_context = client.stream(method, base_url + path, **request_kwargs)
110
+ response = None
111
+ stream = None
112
+ failure: BaseException | None = None
113
+ try:
114
+ try:
115
+ response = await asyncio.wait_for(response_context.__aenter__(), timeout=duration)
116
+ response.raise_for_status()
117
+ if kind == "sse":
118
+ media_type = (
119
+ response.headers.get("content-type", "").partition(";")[0].strip().lower()
120
+ )
121
+ if media_type != "text/event-stream":
122
+ raise LifecycleError(f"SSE stream has invalid content-type: {media_type!r}")
123
+ iterator = EventSource(response).aiter_sse()
124
+ else:
125
+ iterator = response.aiter_lines()
126
+ stream = Stream(
127
+ client, response_context, response, iterator, duration, f"{kind.upper()} {path}"
128
+ )
129
+ except TimeoutError as exc:
130
+ raise LifecycleError(
131
+ f"{kind.upper()} {path} connection timed out after {duration:g}s"
132
+ ) from exc
133
+ except httpx.HTTPError as exc:
134
+ raise LifecycleError(f"{kind.upper()} {path} request failed: {exc}") from exc
135
+ yield stream
136
+ except BaseException as exc:
137
+ failure = exc
138
+ raise
139
+ finally:
140
+ try:
141
+ if stream is not None:
142
+ await stream.aclose()
143
+ else:
144
+ steps = []
145
+ if response is not None:
146
+ steps.append(partial(response_context.__aexit__, None, None, None))
147
+ steps.append(client.aclose)
148
+ await _close_steps(*steps)
149
+ except BaseException as exc:
150
+ if failure is None:
151
+ raise
152
+ add_exception_notes(failure, "Stream cleanup failure", exc)
@@ -0,0 +1,163 @@
1
+ Metadata-Version: 2.5
2
+ Name: pytest-stream-lifecycle
3
+ Version: 0.1.0a1
4
+ Summary: Real-network streaming and explicit cleanup assertions for ASGI tests
5
+ Project-URL: Documentation, https://github.com/shenxi200-eng/pytest-stream-lifecycle/blob/main/docs/api.md
6
+ Project-URL: Source, https://github.com/shenxi200-eng/pytest-stream-lifecycle
7
+ Project-URL: Issues, https://github.com/shenxi200-eng/pytest-stream-lifecycle/issues
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Keywords: asgi,pytest,sse,streaming,testing
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Framework :: Pytest
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Programming Language :: Python :: 3.14
18
+ Requires-Python: >=3.11
19
+ Requires-Dist: httpx-sse<0.5,>=0.4
20
+ Requires-Dist: httpx<1,>=0.27
21
+ Requires-Dist: pytest<10,>=8
22
+ Requires-Dist: uvicorn<1,>=0.30
23
+ Provides-Extra: dev
24
+ Requires-Dist: build; extra == 'dev'
25
+ Requires-Dist: fastapi; extra == 'dev'
26
+ Requires-Dist: pytest-cov; extra == 'dev'
27
+ Requires-Dist: ruff; extra == 'dev'
28
+ Requires-Dist: sse-starlette; extra == 'dev'
29
+ Requires-Dist: starlette; extra == 'dev'
30
+ Requires-Dist: twine; extra == 'dev'
31
+ Description-Content-Type: text/markdown
32
+
33
+ # pytest-stream-lifecycle
34
+
35
+ Test streaming delivery and disconnect cleanup over real loopback HTTP.
36
+
37
+ **Experimental alpha. Python 3.11+, asyncio.**
38
+ No GPU, model API credentials, or external service is needed.
39
+
40
+ ## What it catches
41
+
42
+ A final response-body assertion can pass even when every token was buffered.
43
+ Closing a client can also leave a producer or upstream task running. This plugin
44
+ provides:
45
+
46
+ - `stream_server`: an ASGI app served by Uvicorn on a retained local socket.
47
+ - `server.sse()` / `server.lines()`: bounded incremental reads over real HTTP/1.1.
48
+ - `stream_probe`: explicit resource observations and cleanup acknowledgements.
49
+
50
+ It is not a load tester, LLM evaluator, SSE parser, or general leak detector.
51
+ The SSE parser is [httpx-sse](https://github.com/florimondmanca/httpx-sse).
52
+
53
+ ## Install
54
+
55
+ Install the experimental alpha in your test environment:
56
+
57
+ ```sh
58
+ python -m pip install pytest-stream-lifecycle==0.1.0a1
59
+ ```
60
+
61
+ To install the current source instead:
62
+
63
+ ```sh
64
+ python -m pip install 'git+https://github.com/shenxi200-eng/pytest-stream-lifecycle.git'
65
+ ```
66
+
67
+ Maintainers can follow the [release checklist](https://github.com/shenxi200-eng/pytest-stream-lifecycle/blob/main/docs/releasing.md).
68
+
69
+ For the example below, also install `fastapi` and `sse-starlette`. These are
70
+ example/development dependencies, not plugin runtime requirements. Installation
71
+ registers the pytest fixtures automatically; pytest-asyncio is not required.
72
+
73
+ ## Test delivery, then disconnect
74
+
75
+ Save as `test_stream.py` and run `python -m pytest -q test_stream.py`:
76
+
77
+ ```python
78
+ import asyncio
79
+
80
+ from fastapi import FastAPI
81
+ from sse_starlette.sse import EventSourceResponse
82
+
83
+
84
+ def test_disconnect(stream_server, stream_probe):
85
+ async def scenario():
86
+ app = FastAPI()
87
+ release = asyncio.Event()
88
+
89
+ async def tokens():
90
+ with stream_probe.track("producer") as observed:
91
+ try:
92
+ yield {"event": "token", "data": "first"}
93
+ await release.wait()
94
+ yield {"event": "token", "data": "second"}
95
+ finally:
96
+ # This synthetic producer owns no external resource.
97
+ # In real code, await cleanup BEFORE marking it closed.
98
+ observed.mark_closed()
99
+
100
+ @app.get("/events")
101
+ async def events():
102
+ # Listens for disconnects even while tokens() is waiting.
103
+ return EventSourceResponse(tokens())
104
+
105
+ async with stream_server(app) as server:
106
+ async with server.sse("/events") as stream:
107
+ assert (await stream.next()).data == "first"
108
+ stream_probe.assert_active("producer")
109
+ # Assert while the server is STILL RUNNING: its shutdown must
110
+ # not hide an application that ignores client disconnects.
111
+ await stream_probe.wait_closed("producer", timeout=2)
112
+ assert not release.is_set()
113
+
114
+ asyncio.run(scenario())
115
+ ```
116
+
117
+ The first event must arrive before the release gate opens. Exiting the stream
118
+ context disconnects the client; it does **not** assert server cleanup. The
119
+ separate probe assertion checks that an observed producer exited and explicitly
120
+ acknowledged cleanup without a non-cancellation error.
121
+
122
+ For a real upstream, instrument its lifetime separately. Call `mark_closed()`
123
+ only after the cleanup you intend to test has completed successfully. Leaving
124
+ `track()` without that acknowledgement fails `wait_closed()`, even if the task
125
+ has already exited. The marker is application-supplied evidence, not an
126
+ independent guarantee that a remote provider stopped work or billing.
127
+
128
+ ## Executable comparisons
129
+
130
+ ```sh
131
+ git clone https://github.com/shenxi200-eng/pytest-stream-lifecycle.git
132
+ cd pytest-stream-lifecycle
133
+ python -m venv .venv
134
+ # Activate .venv using your shell's activation command.
135
+ python -m pip install -e '.[dev]'
136
+ python -m pytest examples/test_contracts.py -v
137
+ python -m pytest -q
138
+ ```
139
+
140
+ The examples compare a buffered implementation that passes a body-only test
141
+ with a streaming contract that detects it. They also cover incremental delivery,
142
+ 20 sequential sessions, and a detached upstream that remains alive after the
143
+ producer closes. Negative examples use `pytest.raises`: a passing test means
144
+ the defect was **detected**, not that the intentionally broken app is correct.
145
+
146
+ ## Boundaries
147
+
148
+ - Same-loop asyncio and loopback HTTP/1.1 only: not Trio, HTTP/2, TLS,
149
+ WebSockets, or browser/proxy emulation.
150
+ - Resource observations are explicit. Unknown resources do not count as clean;
151
+ uninstrumented tasks are outside the probe's coverage.
152
+ - Cleanup checks should run before server teardown. Server shutdown can itself
153
+ cancel request tasks and otherwise hide a broken disconnect handler.
154
+ - An in-process server cannot forcibly kill code that blocks the event loop or
155
+ continually suppresses cancellation. Such apps need process isolation.
156
+ - A local cleanup marker does not prove remote cancellation, GPU work cessation,
157
+ or a provider's billing state.
158
+
159
+ See the [API guide](https://github.com/shenxi200-eng/pytest-stream-lifecycle/blob/main/docs/api.md), [design and related work](https://github.com/shenxi200-eng/pytest-stream-lifecycle/blob/main/docs/design.md), and
160
+ [contribution guide](https://github.com/shenxi200-eng/pytest-stream-lifecycle/blob/main/CONTRIBUTING.md). Development uses AI-assisted implementation
161
+ and review; generated code and claims must be checked rather than treated as
162
+ verification evidence. AI-assisted contributions have the same testing and review
163
+ requirements as other contributions.
@@ -0,0 +1,11 @@
1
+ pytest_stream_lifecycle/__init__.py,sha256=kgO-gFpj7iNalN9iKlX82BDHTB1nsE0QHam-vDiT-6k,242
2
+ pytest_stream_lifecycle/_errors.py,sha256=Ktx53YwMxpbmoWLs4IuiZ7syqJ4A08IKuq4OnC_cGFA,806
3
+ pytest_stream_lifecycle/plugin.py,sha256=lKgbzXIDCsBGfKZv72rAq4L9taWDzUdILsAONxGYUwM,387
4
+ pytest_stream_lifecycle/probe.py,sha256=2Z0AuzniZyoe18puWwkdDTtIFjeRv3clL4hohQwxKno,5806
5
+ pytest_stream_lifecycle/server.py,sha256=ZuLx2Wj-bwlCBQ44XaJBN5bmdVuS1GauJkIoBVazSUM,12740
6
+ pytest_stream_lifecycle/streams.py,sha256=wKafYFo3CxO3GXTuddpv248U1LKXyeGokJQWkyRAzok,5731
7
+ pytest_stream_lifecycle-0.1.0a1.dist-info/METADATA,sha256=VvY5JFuv2ls7_weTQpvuhsvuNF7rAkpG7IJPrsfxOH8,6902
8
+ pytest_stream_lifecycle-0.1.0a1.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
9
+ pytest_stream_lifecycle-0.1.0a1.dist-info/entry_points.txt,sha256=v7toDewlwVbhOHcT6aYnMZQp8_608ofiVbeUZM3mZek,61
10
+ pytest_stream_lifecycle-0.1.0a1.dist-info/licenses/LICENSE,sha256=tyLk9zlzElZ8PMQcbAoIHywEqffyuHtcSFDgh1cPagM,1070
11
+ pytest_stream_lifecycle-0.1.0a1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [pytest11]
2
+ stream_lifecycle = pytest_stream_lifecycle.plugin
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 shenxi200-eng
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.