forked from orbit-oss/flask
refactor stream_with_context for async views
This commit is contained in:
parent
49b7e7bc8f
commit
9822a03515
3 changed files with 62 additions and 31 deletions
|
|
@ -3,6 +3,7 @@ Version 3.1.2
|
||||||
|
|
||||||
Unreleased
|
Unreleased
|
||||||
|
|
||||||
|
- ``stream_with_context`` does not fail inside async views. :issue:`5774`
|
||||||
- When using ``follow_redirects`` in the test client, the final state
|
- When using ``follow_redirects`` in the test client, the final state
|
||||||
of ``session`` is correct. :issue:`5786`
|
of ``session`` is correct. :issue:`5786`
|
||||||
- Relax type hint for passing bytes IO to ``send_file``. :issue:`5776`
|
- Relax type hint for passing bytes IO to ``send_file``. :issue:`5776`
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ from werkzeug.exceptions import abort as _wz_abort
|
||||||
from werkzeug.utils import redirect as _wz_redirect
|
from werkzeug.utils import redirect as _wz_redirect
|
||||||
from werkzeug.wrappers import Response as BaseResponse
|
from werkzeug.wrappers import Response as BaseResponse
|
||||||
|
|
||||||
|
from .globals import _cv_app
|
||||||
from .globals import _cv_request
|
from .globals import _cv_request
|
||||||
from .globals import current_app
|
from .globals import current_app
|
||||||
from .globals import request
|
from .globals import request
|
||||||
|
|
@ -62,35 +63,40 @@ def stream_with_context(
|
||||||
def stream_with_context(
|
def stream_with_context(
|
||||||
generator_or_function: t.Iterator[t.AnyStr] | t.Callable[..., t.Iterator[t.AnyStr]],
|
generator_or_function: t.Iterator[t.AnyStr] | t.Callable[..., t.Iterator[t.AnyStr]],
|
||||||
) -> t.Iterator[t.AnyStr] | t.Callable[[t.Iterator[t.AnyStr]], t.Iterator[t.AnyStr]]:
|
) -> t.Iterator[t.AnyStr] | t.Callable[[t.Iterator[t.AnyStr]], t.Iterator[t.AnyStr]]:
|
||||||
"""Request contexts disappear when the response is started on the server.
|
"""Wrap a response generator function so that it runs inside the current
|
||||||
This is done for efficiency reasons and to make it less likely to encounter
|
request context. This keeps :data:`request`, :data:`session`, and :data:`g`
|
||||||
memory leaks with badly written WSGI middlewares. The downside is that if
|
available, even though at the point the generator runs the request context
|
||||||
you are using streamed responses, the generator cannot access request bound
|
will typically have ended.
|
||||||
information any more.
|
|
||||||
|
|
||||||
This function however can help you keep the context around for longer::
|
Use it as a decorator on a generator function:
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
from flask import stream_with_context, request, Response
|
from flask import stream_with_context, request, Response
|
||||||
|
|
||||||
@app.route('/stream')
|
@app.get("/stream")
|
||||||
def streamed_response():
|
def streamed_response():
|
||||||
@stream_with_context
|
@stream_with_context
|
||||||
def generate():
|
def generate():
|
||||||
yield 'Hello '
|
yield "Hello "
|
||||||
yield request.args['name']
|
yield request.args["name"]
|
||||||
yield '!'
|
yield "!"
|
||||||
|
|
||||||
return Response(generate())
|
return Response(generate())
|
||||||
|
|
||||||
Alternatively it can also be used around a specific generator::
|
Or use it as a wrapper around a created generator:
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
from flask import stream_with_context, request, Response
|
from flask import stream_with_context, request, Response
|
||||||
|
|
||||||
@app.route('/stream')
|
@app.get("/stream")
|
||||||
def streamed_response():
|
def streamed_response():
|
||||||
def generate():
|
def generate():
|
||||||
yield 'Hello '
|
yield "Hello "
|
||||||
yield request.args['name']
|
yield request.args["name"]
|
||||||
yield '!'
|
yield "!"
|
||||||
|
|
||||||
return Response(stream_with_context(generate()))
|
return Response(stream_with_context(generate()))
|
||||||
|
|
||||||
.. versionadded:: 0.9
|
.. versionadded:: 0.9
|
||||||
|
|
@ -105,35 +111,36 @@ def stream_with_context(
|
||||||
|
|
||||||
return update_wrapper(decorator, generator_or_function) # type: ignore[arg-type]
|
return update_wrapper(decorator, generator_or_function) # type: ignore[arg-type]
|
||||||
|
|
||||||
def generator() -> t.Iterator[t.AnyStr | None]:
|
def generator() -> t.Iterator[t.AnyStr]:
|
||||||
ctx = _cv_request.get(None)
|
if (req_ctx := _cv_request.get(None)) is None:
|
||||||
if ctx is None:
|
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
"'stream_with_context' can only be used when a request"
|
"'stream_with_context' can only be used when a request"
|
||||||
" context is active, such as in a view function."
|
" context is active, such as in a view function."
|
||||||
)
|
)
|
||||||
with ctx:
|
|
||||||
# Dummy sentinel. Has to be inside the context block or we're
|
|
||||||
# not actually keeping the context around.
|
|
||||||
yield None
|
|
||||||
|
|
||||||
# The try/finally is here so that if someone passes a WSGI level
|
app_ctx = _cv_app.get()
|
||||||
# iterator in we're still running the cleanup logic. Generators
|
# Setup code below will run the generator to this point, so that the
|
||||||
# don't need that because they are closed on their destruction
|
# current contexts are recorded. The contexts must be pushed after,
|
||||||
# automatically.
|
# otherwise their ContextVar will record the wrong event loop during
|
||||||
|
# async view functions.
|
||||||
|
yield None # type: ignore[misc]
|
||||||
|
|
||||||
|
# Push the app context first, so that the request context does not
|
||||||
|
# automatically create and push a different app context.
|
||||||
|
with app_ctx, req_ctx:
|
||||||
try:
|
try:
|
||||||
yield from gen
|
yield from gen
|
||||||
finally:
|
finally:
|
||||||
|
# Clean up in case the user wrapped a WSGI iterator.
|
||||||
if hasattr(gen, "close"):
|
if hasattr(gen, "close"):
|
||||||
gen.close()
|
gen.close()
|
||||||
|
|
||||||
# The trick is to start the generator. Then the code execution runs until
|
# Execute the generator to the sentinel value. This ensures the context is
|
||||||
# the first dummy None is yielded at which point the context was already
|
# preserved in the generator's state. Further iteration will push the
|
||||||
# pushed. This item is discarded. Then when the iteration continues the
|
# context and yield from the original iterator.
|
||||||
# real generator is executed.
|
|
||||||
wrapped_g = generator()
|
wrapped_g = generator()
|
||||||
next(wrapped_g)
|
next(wrapped_g)
|
||||||
return wrapped_g # type: ignore[return-value]
|
return wrapped_g
|
||||||
|
|
||||||
|
|
||||||
def make_response(*args: t.Any) -> Response:
|
def make_response(*args: t.Any) -> Response:
|
||||||
|
|
|
||||||
|
|
@ -306,6 +306,29 @@ class TestStreaming:
|
||||||
rv = client.get("/")
|
rv = client.get("/")
|
||||||
assert rv.data == b"flask"
|
assert rv.data == b"flask"
|
||||||
|
|
||||||
|
def test_async_view(self, app, client):
|
||||||
|
@app.route("/")
|
||||||
|
async def index():
|
||||||
|
flask.session["test"] = "flask"
|
||||||
|
|
||||||
|
@flask.stream_with_context
|
||||||
|
def gen():
|
||||||
|
yield flask.session["test"]
|
||||||
|
|
||||||
|
return flask.Response(gen())
|
||||||
|
|
||||||
|
# response is closed without reading stream
|
||||||
|
client.get().close()
|
||||||
|
# response stream is read
|
||||||
|
assert client.get().text == "flask"
|
||||||
|
|
||||||
|
# same as above, but with client context preservation
|
||||||
|
with client:
|
||||||
|
client.get().close()
|
||||||
|
|
||||||
|
with client:
|
||||||
|
assert client.get().text == "flask"
|
||||||
|
|
||||||
|
|
||||||
class TestHelpers:
|
class TestHelpers:
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue