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.
- pytest_stream_lifecycle/__init__.py +6 -0
- pytest_stream_lifecycle/_errors.py +20 -0
- pytest_stream_lifecycle/plugin.py +18 -0
- pytest_stream_lifecycle/probe.py +153 -0
- pytest_stream_lifecycle/server.py +314 -0
- pytest_stream_lifecycle/streams.py +152 -0
- pytest_stream_lifecycle-0.1.0a1.dist-info/METADATA +163 -0
- pytest_stream_lifecycle-0.1.0a1.dist-info/RECORD +11 -0
- pytest_stream_lifecycle-0.1.0a1.dist-info/WHEEL +4 -0
- pytest_stream_lifecycle-0.1.0a1.dist-info/entry_points.txt +2 -0
- pytest_stream_lifecycle-0.1.0a1.dist-info/licenses/LICENSE +21 -0
|
@@ -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,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.
|