Skip to content

Commit 9822a03

Browse files
committed
refactor stream_with_context for async views
1 parent 49b7e7b commit 9822a03

File tree

3 files changed

+63
-32
lines changed

3 files changed

+63
-32
lines changed

CHANGES.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ Version 3.1.2
33

44
Unreleased
55

6+
- ``stream_with_context`` does not fail inside async views. :issue:`5774`
67
- When using ``follow_redirects`` in the test client, the final state
78
of ``session`` is correct. :issue:`5786`
89
- Relax type hint for passing bytes IO to ``send_file``. :issue:`5776`

src/flask/helpers.py

Lines changed: 39 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from werkzeug.utils import redirect as _wz_redirect
1414
from werkzeug.wrappers import Response as BaseResponse
1515

16+
from .globals import _cv_app
1617
from .globals import _cv_request
1718
from .globals import current_app
1819
from .globals import request
@@ -62,35 +63,40 @@ def stream_with_context(
6263
def stream_with_context(
6364
generator_or_function: t.Iterator[t.AnyStr] | t.Callable[..., t.Iterator[t.AnyStr]],
6465
) -> t.Iterator[t.AnyStr] | t.Callable[[t.Iterator[t.AnyStr]], t.Iterator[t.AnyStr]]:
65-
"""Request contexts disappear when the response is started on the server.
66-
This is done for efficiency reasons and to make it less likely to encounter
67-
memory leaks with badly written WSGI middlewares. The downside is that if
68-
you are using streamed responses, the generator cannot access request bound
69-
information any more.
66+
"""Wrap a response generator function so that it runs inside the current
67+
request context. This keeps :data:`request`, :data:`session`, and :data:`g`
68+
available, even though at the point the generator runs the request context
69+
will typically have ended.
7070
71-
This function however can help you keep the context around for longer::
71+
Use it as a decorator on a generator function:
72+
73+
.. code-block:: python
7274
7375
from flask import stream_with_context, request, Response
7476
75-
@app.route('/stream')
77+
@app.get("/stream")
7678
def streamed_response():
7779
@stream_with_context
7880
def generate():
79-
yield 'Hello '
80-
yield request.args['name']
81-
yield '!'
81+
yield "Hello "
82+
yield request.args["name"]
83+
yield "!"
84+
8285
return Response(generate())
8386
84-
Alternatively it can also be used around a specific generator::
87+
Or use it as a wrapper around a created generator:
88+
89+
.. code-block:: python
8590
8691
from flask import stream_with_context, request, Response
8792
88-
@app.route('/stream')
93+
@app.get("/stream")
8994
def streamed_response():
9095
def generate():
91-
yield 'Hello '
92-
yield request.args['name']
93-
yield '!'
96+
yield "Hello "
97+
yield request.args["name"]
98+
yield "!"
99+
94100
return Response(stream_with_context(generate()))
95101
96102
.. versionadded:: 0.9
@@ -105,35 +111,36 @@ def decorator(*args: t.Any, **kwargs: t.Any) -> t.Any:
105111

106112
return update_wrapper(decorator, generator_or_function) # type: ignore[arg-type]
107113

108-
def generator() -> t.Iterator[t.AnyStr | None]:
109-
ctx = _cv_request.get(None)
110-
if ctx is None:
114+
def generator() -> t.Iterator[t.AnyStr]:
115+
if (req_ctx := _cv_request.get(None)) is None:
111116
raise RuntimeError(
112117
"'stream_with_context' can only be used when a request"
113118
" context is active, such as in a view function."
114119
)
115-
with ctx:
116-
# Dummy sentinel. Has to be inside the context block or we're
117-
# not actually keeping the context around.
118-
yield None
119-
120-
# The try/finally is here so that if someone passes a WSGI level
121-
# iterator in we're still running the cleanup logic. Generators
122-
# don't need that because they are closed on their destruction
123-
# automatically.
120+
121+
app_ctx = _cv_app.get()
122+
# Setup code below will run the generator to this point, so that the
123+
# current contexts are recorded. The contexts must be pushed after,
124+
# otherwise their ContextVar will record the wrong event loop during
125+
# async view functions.
126+
yield None # type: ignore[misc]
127+
128+
# Push the app context first, so that the request context does not
129+
# automatically create and push a different app context.
130+
with app_ctx, req_ctx:
124131
try:
125132
yield from gen
126133
finally:
134+
# Clean up in case the user wrapped a WSGI iterator.
127135
if hasattr(gen, "close"):
128136
gen.close()
129137

130-
# The trick is to start the generator. Then the code execution runs until
131-
# the first dummy None is yielded at which point the context was already
132-
# pushed. This item is discarded. Then when the iteration continues the
133-
# real generator is executed.
138+
# Execute the generator to the sentinel value. This ensures the context is
139+
# preserved in the generator's state. Further iteration will push the
140+
# context and yield from the original iterator.
134141
wrapped_g = generator()
135142
next(wrapped_g)
136-
return wrapped_g # type: ignore[return-value]
143+
return wrapped_g
137144

138145

139146
def make_response(*args: t.Any) -> Response:

tests/test_helpers.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -306,6 +306,29 @@ def gen():
306306
rv = client.get("/")
307307
assert rv.data == b"flask"
308308

309+
def test_async_view(self, app, client):
310+
@app.route("/")
311+
async def index():
312+
flask.session["test"] = "flask"
313+
314+
@flask.stream_with_context
315+
def gen():
316+
yield flask.session["test"]
317+
318+
return flask.Response(gen())
319+
320+
# response is closed without reading stream
321+
client.get().close()
322+
# response stream is read
323+
assert client.get().text == "flask"
324+
325+
# same as above, but with client context preservation
326+
with client:
327+
client.get().close()
328+
329+
with client:
330+
assert client.get().text == "flask"
331+
309332

310333
class TestHelpers:
311334
@pytest.mark.parametrize(

0 commit comments

Comments
 (0)