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.
@@ -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,12 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.py[cod]
4
+ .pytest_cache/
5
+ .ruff_cache/
6
+ .coverage*
7
+ htmlcov/
8
+ dist/
9
+ build/
10
+ *.egg-info/
11
+ .env
12
+ .env.*
@@ -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.