pytest-stream-lifecycle 0.1.0a1__tar.gz
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-0.1.0a1/.github/workflows/ci.yml +51 -0
- pytest_stream_lifecycle-0.1.0a1/.github/workflows/release.yml +152 -0
- pytest_stream_lifecycle-0.1.0a1/.gitignore +12 -0
- pytest_stream_lifecycle-0.1.0a1/AGENTS.md +1 -0
- pytest_stream_lifecycle-0.1.0a1/CHANGELOG.md +17 -0
- pytest_stream_lifecycle-0.1.0a1/CONTRIBUTING.md +23 -0
- pytest_stream_lifecycle-0.1.0a1/LICENSE +21 -0
- pytest_stream_lifecycle-0.1.0a1/PKG-INFO +163 -0
- pytest_stream_lifecycle-0.1.0a1/README.md +131 -0
- pytest_stream_lifecycle-0.1.0a1/docs/api.md +94 -0
- pytest_stream_lifecycle-0.1.0a1/docs/design.md +57 -0
- pytest_stream_lifecycle-0.1.0a1/docs/releasing.md +117 -0
- pytest_stream_lifecycle-0.1.0a1/examples/test_contracts.py +165 -0
- pytest_stream_lifecycle-0.1.0a1/pyproject.toml +46 -0
- pytest_stream_lifecycle-0.1.0a1/src/pytest_stream_lifecycle/__init__.py +6 -0
- pytest_stream_lifecycle-0.1.0a1/src/pytest_stream_lifecycle/_errors.py +20 -0
- pytest_stream_lifecycle-0.1.0a1/src/pytest_stream_lifecycle/plugin.py +18 -0
- pytest_stream_lifecycle-0.1.0a1/src/pytest_stream_lifecycle/probe.py +153 -0
- pytest_stream_lifecycle-0.1.0a1/src/pytest_stream_lifecycle/server.py +314 -0
- pytest_stream_lifecycle-0.1.0a1/src/pytest_stream_lifecycle/streams.py +152 -0
- pytest_stream_lifecycle-0.1.0a1/tests/test_probe.py +163 -0
- pytest_stream_lifecycle-0.1.0a1/tests/test_server.py +674 -0
- pytest_stream_lifecycle-0.1.0a1/tests/test_streams.py +491 -0
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
on:
|
|
3
|
+
push:
|
|
4
|
+
branches: [main]
|
|
5
|
+
pull_request:
|
|
6
|
+
workflow_call:
|
|
7
|
+
permissions:
|
|
8
|
+
contents: read
|
|
9
|
+
concurrency:
|
|
10
|
+
group: ci-${{ github.workflow }}-${{ github.ref }}-${{ github.event_name == 'workflow_dispatch' && github.run_id || 'standard' }}
|
|
11
|
+
cancel-in-progress: true
|
|
12
|
+
jobs:
|
|
13
|
+
tests:
|
|
14
|
+
runs-on: ${{ matrix.os }}
|
|
15
|
+
timeout-minutes: 10
|
|
16
|
+
strategy:
|
|
17
|
+
fail-fast: false
|
|
18
|
+
matrix:
|
|
19
|
+
os: [ubuntu-latest, windows-latest, macos-latest]
|
|
20
|
+
python: ['3.11', '3.14']
|
|
21
|
+
dependencies: [latest]
|
|
22
|
+
include:
|
|
23
|
+
- os: ubuntu-latest
|
|
24
|
+
python: '3.11'
|
|
25
|
+
dependencies: minimum
|
|
26
|
+
steps:
|
|
27
|
+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
28
|
+
with:
|
|
29
|
+
persist-credentials: false
|
|
30
|
+
- uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
|
|
31
|
+
with:
|
|
32
|
+
python-version: ${{ matrix.python }}
|
|
33
|
+
- run: python -m pip install -e ".[dev]"
|
|
34
|
+
- if: matrix.dependencies == 'minimum'
|
|
35
|
+
run: python -m pip install pytest==8.0.0 uvicorn==0.30.0 httpx==0.27.0 httpx-sse==0.4.0
|
|
36
|
+
- run: python -m pytest -q
|
|
37
|
+
quality:
|
|
38
|
+
runs-on: ubuntu-latest
|
|
39
|
+
timeout-minutes: 10
|
|
40
|
+
steps:
|
|
41
|
+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
42
|
+
with:
|
|
43
|
+
persist-credentials: false
|
|
44
|
+
- uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
|
|
45
|
+
with:
|
|
46
|
+
python-version: '3.12'
|
|
47
|
+
- run: python -m pip install -e '.[dev]'
|
|
48
|
+
- run: python -m ruff check .
|
|
49
|
+
- run: python -m ruff format --check .
|
|
50
|
+
- run: python -m build
|
|
51
|
+
- run: python -m twine check dist/*
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
name: Release preparation
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
workflow_dispatch:
|
|
5
|
+
inputs:
|
|
6
|
+
target:
|
|
7
|
+
description: "Check only, publish to TestPyPI, or publish to PyPI"
|
|
8
|
+
type: choice
|
|
9
|
+
options:
|
|
10
|
+
- check
|
|
11
|
+
- testpypi
|
|
12
|
+
- pypi
|
|
13
|
+
default: check
|
|
14
|
+
required: true
|
|
15
|
+
|
|
16
|
+
permissions:
|
|
17
|
+
contents: read
|
|
18
|
+
|
|
19
|
+
# Distinct from the reusable CI workflow's concurrency group. Do not cancel
|
|
20
|
+
# a release while a build or publication is in progress.
|
|
21
|
+
concurrency:
|
|
22
|
+
group: release-${{ github.ref }}
|
|
23
|
+
cancel-in-progress: false
|
|
24
|
+
|
|
25
|
+
jobs:
|
|
26
|
+
validate:
|
|
27
|
+
runs-on: ubuntu-latest
|
|
28
|
+
timeout-minutes: 10
|
|
29
|
+
steps:
|
|
30
|
+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
31
|
+
with:
|
|
32
|
+
fetch-depth: 0
|
|
33
|
+
persist-credentials: false
|
|
34
|
+
- uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
|
|
35
|
+
with:
|
|
36
|
+
python-version: '3.12'
|
|
37
|
+
- name: Validate target and publication ref
|
|
38
|
+
env:
|
|
39
|
+
RELEASE_TARGET: ${{ inputs.target }}
|
|
40
|
+
RELEASE_REPOSITORY: ${{ github.repository }}
|
|
41
|
+
RELEASE_REF: ${{ github.ref }}
|
|
42
|
+
RELEASE_SHA: ${{ github.sha }}
|
|
43
|
+
shell: bash
|
|
44
|
+
run: |
|
|
45
|
+
set -euo pipefail
|
|
46
|
+
case "$RELEASE_TARGET" in
|
|
47
|
+
check) exit 0 ;;
|
|
48
|
+
testpypi|pypi) ;;
|
|
49
|
+
*) echo 'Invalid release target' >&2; exit 1 ;;
|
|
50
|
+
esac
|
|
51
|
+
if [[ "$RELEASE_REPOSITORY" != 'shenxi200-eng/pytest-stream-lifecycle' ]]; then
|
|
52
|
+
echo 'Publication is restricted to the canonical repository' >&2
|
|
53
|
+
exit 1
|
|
54
|
+
fi
|
|
55
|
+
version="$(python -c 'import pathlib, tomllib; print(tomllib.loads(pathlib.Path("pyproject.toml").read_text(encoding="utf-8"))["project"]["version"])')"
|
|
56
|
+
if [[ "$RELEASE_REF" != "refs/tags/v${version}" ]]; then
|
|
57
|
+
echo 'Publication requires the exact v{project.version} tag' >&2
|
|
58
|
+
exit 1
|
|
59
|
+
fi
|
|
60
|
+
git fetch --no-tags origin '+refs/heads/main:refs/remotes/origin/main'
|
|
61
|
+
if ! git merge-base --is-ancestor "$RELEASE_SHA" refs/remotes/origin/main; then
|
|
62
|
+
echo 'The tagged commit must be an ancestor of origin/main' >&2
|
|
63
|
+
exit 1
|
|
64
|
+
fi
|
|
65
|
+
|
|
66
|
+
tests:
|
|
67
|
+
needs: validate
|
|
68
|
+
permissions:
|
|
69
|
+
contents: read
|
|
70
|
+
uses: ./.github/workflows/ci.yml
|
|
71
|
+
|
|
72
|
+
build:
|
|
73
|
+
needs: [validate, tests]
|
|
74
|
+
runs-on: ubuntu-latest
|
|
75
|
+
timeout-minutes: 15
|
|
76
|
+
steps:
|
|
77
|
+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
78
|
+
with:
|
|
79
|
+
persist-credentials: false
|
|
80
|
+
- uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
|
|
81
|
+
with:
|
|
82
|
+
python-version: '3.12'
|
|
83
|
+
- name: Build isolated distributions and check metadata
|
|
84
|
+
shell: bash
|
|
85
|
+
run: |
|
|
86
|
+
set -euo pipefail
|
|
87
|
+
python -m pip install build twine
|
|
88
|
+
dist_dir="$(mktemp -d "$RUNNER_TEMP/release-dist.XXXXXX")"
|
|
89
|
+
echo "DIST_DIR=$dist_dir" >> "$GITHUB_ENV"
|
|
90
|
+
python -m build --outdir "$dist_dir"
|
|
91
|
+
python -m twine check --strict "$dist_dir"/*
|
|
92
|
+
shopt -s nullglob
|
|
93
|
+
wheels=("$dist_dir"/*.whl)
|
|
94
|
+
sdists=("$dist_dir"/*.tar.gz)
|
|
95
|
+
(( ${#wheels[@]} == 1 && ${#sdists[@]} == 1 )) || { echo 'Expected one wheel and one sdist' >&2; exit 1; }
|
|
96
|
+
sha256sum "${wheels[0]}" "${sdists[0]}"
|
|
97
|
+
- name: Test the built wheel in a fresh virtual environment
|
|
98
|
+
env:
|
|
99
|
+
PYTHONPATH: ''
|
|
100
|
+
shell: bash
|
|
101
|
+
run: |
|
|
102
|
+
set -euo pipefail
|
|
103
|
+
shopt -s nullglob
|
|
104
|
+
wheels=("$DIST_DIR"/*.whl)
|
|
105
|
+
(( ${#wheels[@]} == 1 ))
|
|
106
|
+
python -m venv "$RUNNER_TEMP/release-wheel-venv"
|
|
107
|
+
python="$RUNNER_TEMP/release-wheel-venv/bin/python"
|
|
108
|
+
"$python" -m pip install "${wheels[0]}[dev]"
|
|
109
|
+
"$python" -c 'import pathlib, sysconfig, pytest_stream_lifecycle as p; module = pathlib.Path(p.__file__).resolve(); site = pathlib.Path(sysconfig.get_paths()["purelib"]).resolve(); print(f"Wheel module: {module}"); assert module.is_relative_to(site), "Package was not imported from the wheel environment"'
|
|
110
|
+
"$python" -m pytest -q
|
|
111
|
+
- name: Upload verified distributions
|
|
112
|
+
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
|
113
|
+
with:
|
|
114
|
+
name: release-distributions
|
|
115
|
+
path: ${{ env.DIST_DIR }}/*
|
|
116
|
+
if-no-files-found: error
|
|
117
|
+
|
|
118
|
+
publish-testpypi:
|
|
119
|
+
if: inputs.target == 'testpypi'
|
|
120
|
+
needs: build
|
|
121
|
+
runs-on: ubuntu-latest
|
|
122
|
+
timeout-minutes: 10
|
|
123
|
+
environment: testpypi
|
|
124
|
+
permissions:
|
|
125
|
+
id-token: write
|
|
126
|
+
steps:
|
|
127
|
+
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
|
128
|
+
with:
|
|
129
|
+
name: release-distributions
|
|
130
|
+
path: dist
|
|
131
|
+
- uses: pypa/gh-action-pypi-publish@7245906fb61fa33a13554f9d5fb856fe61758086 # v1.14.3
|
|
132
|
+
with:
|
|
133
|
+
repository-url: https://test.pypi.org/legacy/
|
|
134
|
+
attestations: true
|
|
135
|
+
|
|
136
|
+
publish-pypi:
|
|
137
|
+
if: inputs.target == 'pypi'
|
|
138
|
+
needs: build
|
|
139
|
+
runs-on: ubuntu-latest
|
|
140
|
+
timeout-minutes: 10
|
|
141
|
+
environment: pypi
|
|
142
|
+
permissions:
|
|
143
|
+
id-token: write
|
|
144
|
+
steps:
|
|
145
|
+
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
|
146
|
+
with:
|
|
147
|
+
name: release-distributions
|
|
148
|
+
path: dist
|
|
149
|
+
- uses: pypa/gh-action-pypi-publish@7245906fb61fa33a13554f9d5fb856fe61758086 # v1.14.3
|
|
150
|
+
with:
|
|
151
|
+
repository-url: https://upload.pypi.org/legacy/
|
|
152
|
+
attestations: true
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
Project: pytest-stream-lifecycle. Independent pytest plugin; Python 3.11+; asyncio only; real loopback HTTP/1.1 via Uvicorn. Use explicit lifecycle instrumentation, never infer cleanup from a closed client or cancel unrelated tasks. Preserve failure evidence. No external publishing except by primary session. No credentials, private paths, or unverified performance/novelty claims in repository.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0a1 — 2026-11-12
|
|
4
|
+
|
|
5
|
+
First experimental alpha; the public API may change.
|
|
6
|
+
|
|
7
|
+
- Real loopback HTTP/1.1 ASGI server fixture on the test's asyncio event loop.
|
|
8
|
+
- Bounded SSE and line readers, including POST requests and explicit disconnects.
|
|
9
|
+
- Lifecycle observations requiring explicit acknowledgement after cleanup.
|
|
10
|
+
- Buffering, detached-upstream and repeated-session examples.
|
|
11
|
+
- Failure diagnostics preserve primary errors and nested cleanup exceptions.
|
|
12
|
+
- Cross-platform regression tests for Linux, macOS and Windows.
|
|
13
|
+
|
|
14
|
+
Limits: asyncio and explicitly instrumented resources only. Client closure is
|
|
15
|
+
not proof of upstream cleanup, and a local marker does not prove remote work or
|
|
16
|
+
billing stopped. Event-loop-blocking or cancellation-resistant apps need process
|
|
17
|
+
isolation for a hard stop.
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# Contributing
|
|
2
|
+
|
|
3
|
+
Start with a reproducible failure and a bounded change. Include a regression
|
|
4
|
+
case that fails without the fix and passes with it. Do not replace real-network
|
|
5
|
+
coverage with mocks for network lifecycle behavior.
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
python -m pip install -e '.[dev]'
|
|
9
|
+
python -m pytest -q
|
|
10
|
+
python -m ruff check .
|
|
11
|
+
python -m ruff format --check .
|
|
12
|
+
python -m build
|
|
13
|
+
python -m twine check dist/*
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
AI-assisted contributions are welcome. Disclose material AI assistance, verify
|
|
17
|
+
all code and references, and never present generated test results as observed
|
|
18
|
+
runs. Keep credentials, private logs, and customer traffic out of issues and
|
|
19
|
+
fixtures. Synthetic applications should be small and run without remote APIs.
|
|
20
|
+
|
|
21
|
+
The public API is experimental. Discuss major scope expansion before building
|
|
22
|
+
it; HTTP/2, WebSockets, model evaluation, and general process supervision are
|
|
23
|
+
outside the initial scope.
|
|
@@ -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.
|
|
@@ -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,131 @@
|
|
|
1
|
+
# pytest-stream-lifecycle
|
|
2
|
+
|
|
3
|
+
Test streaming delivery and disconnect cleanup over real loopback HTTP.
|
|
4
|
+
|
|
5
|
+
**Experimental alpha. Python 3.11+, asyncio.**
|
|
6
|
+
No GPU, model API credentials, or external service is needed.
|
|
7
|
+
|
|
8
|
+
## What it catches
|
|
9
|
+
|
|
10
|
+
A final response-body assertion can pass even when every token was buffered.
|
|
11
|
+
Closing a client can also leave a producer or upstream task running. This plugin
|
|
12
|
+
provides:
|
|
13
|
+
|
|
14
|
+
- `stream_server`: an ASGI app served by Uvicorn on a retained local socket.
|
|
15
|
+
- `server.sse()` / `server.lines()`: bounded incremental reads over real HTTP/1.1.
|
|
16
|
+
- `stream_probe`: explicit resource observations and cleanup acknowledgements.
|
|
17
|
+
|
|
18
|
+
It is not a load tester, LLM evaluator, SSE parser, or general leak detector.
|
|
19
|
+
The SSE parser is [httpx-sse](https://github.com/florimondmanca/httpx-sse).
|
|
20
|
+
|
|
21
|
+
## Install
|
|
22
|
+
|
|
23
|
+
Install the experimental alpha in your test environment:
|
|
24
|
+
|
|
25
|
+
```sh
|
|
26
|
+
python -m pip install pytest-stream-lifecycle==0.1.0a1
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
To install the current source instead:
|
|
30
|
+
|
|
31
|
+
```sh
|
|
32
|
+
python -m pip install 'git+https://github.com/shenxi200-eng/pytest-stream-lifecycle.git'
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Maintainers can follow the [release checklist](https://github.com/shenxi200-eng/pytest-stream-lifecycle/blob/main/docs/releasing.md).
|
|
36
|
+
|
|
37
|
+
For the example below, also install `fastapi` and `sse-starlette`. These are
|
|
38
|
+
example/development dependencies, not plugin runtime requirements. Installation
|
|
39
|
+
registers the pytest fixtures automatically; pytest-asyncio is not required.
|
|
40
|
+
|
|
41
|
+
## Test delivery, then disconnect
|
|
42
|
+
|
|
43
|
+
Save as `test_stream.py` and run `python -m pytest -q test_stream.py`:
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
import asyncio
|
|
47
|
+
|
|
48
|
+
from fastapi import FastAPI
|
|
49
|
+
from sse_starlette.sse import EventSourceResponse
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_disconnect(stream_server, stream_probe):
|
|
53
|
+
async def scenario():
|
|
54
|
+
app = FastAPI()
|
|
55
|
+
release = asyncio.Event()
|
|
56
|
+
|
|
57
|
+
async def tokens():
|
|
58
|
+
with stream_probe.track("producer") as observed:
|
|
59
|
+
try:
|
|
60
|
+
yield {"event": "token", "data": "first"}
|
|
61
|
+
await release.wait()
|
|
62
|
+
yield {"event": "token", "data": "second"}
|
|
63
|
+
finally:
|
|
64
|
+
# This synthetic producer owns no external resource.
|
|
65
|
+
# In real code, await cleanup BEFORE marking it closed.
|
|
66
|
+
observed.mark_closed()
|
|
67
|
+
|
|
68
|
+
@app.get("/events")
|
|
69
|
+
async def events():
|
|
70
|
+
# Listens for disconnects even while tokens() is waiting.
|
|
71
|
+
return EventSourceResponse(tokens())
|
|
72
|
+
|
|
73
|
+
async with stream_server(app) as server:
|
|
74
|
+
async with server.sse("/events") as stream:
|
|
75
|
+
assert (await stream.next()).data == "first"
|
|
76
|
+
stream_probe.assert_active("producer")
|
|
77
|
+
# Assert while the server is STILL RUNNING: its shutdown must
|
|
78
|
+
# not hide an application that ignores client disconnects.
|
|
79
|
+
await stream_probe.wait_closed("producer", timeout=2)
|
|
80
|
+
assert not release.is_set()
|
|
81
|
+
|
|
82
|
+
asyncio.run(scenario())
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
The first event must arrive before the release gate opens. Exiting the stream
|
|
86
|
+
context disconnects the client; it does **not** assert server cleanup. The
|
|
87
|
+
separate probe assertion checks that an observed producer exited and explicitly
|
|
88
|
+
acknowledged cleanup without a non-cancellation error.
|
|
89
|
+
|
|
90
|
+
For a real upstream, instrument its lifetime separately. Call `mark_closed()`
|
|
91
|
+
only after the cleanup you intend to test has completed successfully. Leaving
|
|
92
|
+
`track()` without that acknowledgement fails `wait_closed()`, even if the task
|
|
93
|
+
has already exited. The marker is application-supplied evidence, not an
|
|
94
|
+
independent guarantee that a remote provider stopped work or billing.
|
|
95
|
+
|
|
96
|
+
## Executable comparisons
|
|
97
|
+
|
|
98
|
+
```sh
|
|
99
|
+
git clone https://github.com/shenxi200-eng/pytest-stream-lifecycle.git
|
|
100
|
+
cd pytest-stream-lifecycle
|
|
101
|
+
python -m venv .venv
|
|
102
|
+
# Activate .venv using your shell's activation command.
|
|
103
|
+
python -m pip install -e '.[dev]'
|
|
104
|
+
python -m pytest examples/test_contracts.py -v
|
|
105
|
+
python -m pytest -q
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
The examples compare a buffered implementation that passes a body-only test
|
|
109
|
+
with a streaming contract that detects it. They also cover incremental delivery,
|
|
110
|
+
20 sequential sessions, and a detached upstream that remains alive after the
|
|
111
|
+
producer closes. Negative examples use `pytest.raises`: a passing test means
|
|
112
|
+
the defect was **detected**, not that the intentionally broken app is correct.
|
|
113
|
+
|
|
114
|
+
## Boundaries
|
|
115
|
+
|
|
116
|
+
- Same-loop asyncio and loopback HTTP/1.1 only: not Trio, HTTP/2, TLS,
|
|
117
|
+
WebSockets, or browser/proxy emulation.
|
|
118
|
+
- Resource observations are explicit. Unknown resources do not count as clean;
|
|
119
|
+
uninstrumented tasks are outside the probe's coverage.
|
|
120
|
+
- Cleanup checks should run before server teardown. Server shutdown can itself
|
|
121
|
+
cancel request tasks and otherwise hide a broken disconnect handler.
|
|
122
|
+
- An in-process server cannot forcibly kill code that blocks the event loop or
|
|
123
|
+
continually suppresses cancellation. Such apps need process isolation.
|
|
124
|
+
- A local cleanup marker does not prove remote cancellation, GPU work cessation,
|
|
125
|
+
or a provider's billing state.
|
|
126
|
+
|
|
127
|
+
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
|
|
128
|
+
[contribution guide](https://github.com/shenxi200-eng/pytest-stream-lifecycle/blob/main/CONTRIBUTING.md). Development uses AI-assisted implementation
|
|
129
|
+
and review; generated code and claims must be checked rather than treated as
|
|
130
|
+
verification evidence. AI-assisted contributions have the same testing and review
|
|
131
|
+
requirements as other contributions.
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# API guide
|
|
2
|
+
|
|
3
|
+
The pytest plugin exposes two function-scoped fixtures. Use them within one
|
|
4
|
+
asyncio event loop per test, either through `asyncio.run()` or your own async
|
|
5
|
+
pytest integration.
|
|
6
|
+
|
|
7
|
+
## Server
|
|
8
|
+
|
|
9
|
+
```python
|
|
10
|
+
async with stream_server(app, startup_timeout=5.0, shutdown_timeout=5.0, lifespan="on") as server:
|
|
11
|
+
print(server.url) # http://127.0.0.1:<ephemeral-port>
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
`stream_server` is the `live_server` factory, also exported for non-pytest use.
|
|
15
|
+
It runs on the test's event loop, retains its port-zero socket, and does not
|
|
16
|
+
install signal handlers or reconfigure global logging. `lifespan` accepts
|
|
17
|
+
`"on"`, `"auto"`, or `"off"`; choose `"off"` for an app without lifespan support.
|
|
18
|
+
Startup and shutdown failures raise `ServerError`. Deadlines require cooperative
|
|
19
|
+
async code; they cannot preempt code that blocks the event loop.
|
|
20
|
+
|
|
21
|
+
## Stream readers
|
|
22
|
+
|
|
23
|
+
```python
|
|
24
|
+
async with server.sse("/events", method="POST", json={"prompt": "hello"}) as stream:
|
|
25
|
+
event = await stream.next(timeout=2.0)
|
|
26
|
+
assert event.data
|
|
27
|
+
|
|
28
|
+
async with server.lines("/jsonl", headers={"X-Test": "yes"}) as stream:
|
|
29
|
+
line = await stream.next()
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
- `sse()` yields a reader of `httpx_sse.ServerSentEvent` objects; `lines()` yields
|
|
33
|
+
strings, without decoding JSON or filtering empty lines.
|
|
34
|
+
- `timeout=2.0` is the default for connection/response headers and each read.
|
|
35
|
+
`next(timeout=...)` overrides one read. It is not a whole-session deadline.
|
|
36
|
+
Timeouts must be positive and finite.
|
|
37
|
+
- Paths must start with a single `/`; absolute URLs and `//host` paths are
|
|
38
|
+
rejected. Request options such as `json`, `params`, and `headers` are passed
|
|
39
|
+
to HTTPX. Environment proxy settings are ignored (`trust_env=False`). Redirects
|
|
40
|
+
are not followed by default; callers opting in to redirects are responsible
|
|
41
|
+
for the destination.
|
|
42
|
+
- HTTP errors and invalid SSE content types fail the context entry. Read timeout,
|
|
43
|
+
read error, and EOF before a requested event/line raise `LifecycleError`.
|
|
44
|
+
EOF is an error for `next()`, not an end-of-stream assertion API.
|
|
45
|
+
- A timed-out reader is closed, not reusable. Keep one reader context open for
|
|
46
|
+
multiple `next()` calls. Concurrent reads on the same reader are rejected.
|
|
47
|
+
- `await stream.aclose()` is idempotent. Context exit also closes the reader.
|
|
48
|
+
Client closure is deliberately separate from server-side cleanup assertions.
|
|
49
|
+
|
|
50
|
+
## Lifecycle probe
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
with stream_probe.track("upstream") as observed:
|
|
54
|
+
try:
|
|
55
|
+
await use_resource()
|
|
56
|
+
finally:
|
|
57
|
+
await close_resource()
|
|
58
|
+
observed.mark_closed()
|
|
59
|
+
|
|
60
|
+
await stream_probe.wait_closed("upstream", timeout=2.0)
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
`track(name)` is a synchronous context manager for instrumentation **inside async
|
|
64
|
+
code**. Entering it records a distinct resource instance; multiple instances may
|
|
65
|
+
share a label. Put the acknowledgement after successful cleanup, not unconditionally
|
|
66
|
+
in an outer `finally`. A second cancellation can interrupt awaited cleanup.
|
|
67
|
+
The probe neither shields cleanup nor cancels application tasks.
|
|
68
|
+
|
|
69
|
+
| Operation | Meaning |
|
|
70
|
+
| --- | --- |
|
|
71
|
+
| `track(name)` | Record one resource lifetime; name must be a nonempty string. |
|
|
72
|
+
| `observed.mark_closed()` | Acknowledge cleanup while the observation is still active. |
|
|
73
|
+
| `assert_active(name)` | Require at least one currently active instance. |
|
|
74
|
+
| `await wait_started(name, timeout=2.0)` | Wait for at least one observed instance; it may already have exited. |
|
|
75
|
+
| `await wait_closed(*names, timeout=2.0)` | Require each label to have been observed, and all its observed instances to have exited with cleanup acknowledged and no non-cancellation error. |
|
|
76
|
+
| `started_count(name)` | Number of entered instances for this label, including closed ones. |
|
|
77
|
+
| `active_labels` | A `frozenset` of labels with currently active instances. |
|
|
78
|
+
|
|
79
|
+
Unknown labels wait until observed or timeout; an empty `wait_closed()` call
|
|
80
|
+
fails. An exited instance without acknowledgement or with a recorded error fails
|
|
81
|
+
immediately. `CancelledError` and `GeneratorExit` propagate but are not recorded
|
|
82
|
+
as cleanup failures by themselves; an acknowledgement is still required.
|
|
83
|
+
Failures use `LifecycleError`, an `AssertionError` subclass.
|
|
84
|
+
|
|
85
|
+
Observations are retained for the probe's lifetime, so an earlier failure is not
|
|
86
|
+
forgotten when another instance with the same name closes successfully. A probe
|
|
87
|
+
binds to its first running event loop and rejects reuse from another loop. It
|
|
88
|
+
can be constructed outside that loop. `LifecycleProbe` is exported for use
|
|
89
|
+
without pytest.
|
|
90
|
+
|
|
91
|
+
A successful check describes instances observed **so far**, not resources that
|
|
92
|
+
might start later. Synchronize application start points explicitly. Check both
|
|
93
|
+
producer and upstream labels while the server is still running, before test
|
|
94
|
+
teardown has a chance to cancel them.
|