From 46433e9807a1c960d6b2bb0e125cf16a90167d97 Mon Sep 17 00:00:00 2001 From: pgjones Date: Thu, 9 Jun 2022 09:21:48 +0100 Subject: [PATCH] add generate_template and generate_template_string functions --- CHANGES.rst | 2 + docs/api.rst | 4 ++ docs/patterns/streaming.rst | 75 ++++++++++++++++++++----------------- docs/templating.rst | 26 +++++++++++++ src/flask/__init__.py | 2 + src/flask/templating.py | 58 +++++++++++++++++++++++++++- tests/test_templating.py | 9 +++++ 7 files changed, 139 insertions(+), 37 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 694d7a56..64b4ef0b 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -40,6 +40,8 @@ Unreleased value. :pr:`4645` - View functions can return generators directly instead of wrapping them in a ``Response``. :pr:`4629` +- Add ``stream_template`` and ``stream_template_string`` functions to + render a template as a stream of pieces. :pr:`4629` Version 2.1.3 diff --git a/docs/api.rst b/docs/api.rst index b3cffde2..217473bc 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -287,6 +287,10 @@ Template Rendering .. autofunction:: render_template_string +.. autofunction:: stream_template + +.. autofunction:: stream_template_string + .. autofunction:: get_template_attribute Configuration diff --git a/docs/patterns/streaming.rst b/docs/patterns/streaming.rst index e8571ffd..e35ac4ab 100644 --- a/docs/patterns/streaming.rst +++ b/docs/patterns/streaming.rst @@ -20,7 +20,7 @@ data and to then invoke that function and pass it to a response object:: def generate(): for row in iter_all_rows(): yield f"{','.join(row)}\n" - return app.response_class(generate(), mimetype='text/csv') + return generate(), {"Content-Type": "text/csv") Each ``yield`` expression is directly sent to the browser. Note though that some WSGI middlewares might break streaming, so be careful there in @@ -29,52 +29,57 @@ debug environments with profilers and other things you might have enabled. Streaming from Templates ------------------------ -The Jinja2 template engine also supports rendering templates piece by -piece. This functionality is not directly exposed by Flask because it is -quite uncommon, but you can easily do it yourself:: +The Jinja2 template engine supports rendering a template piece by +piece, returning an iterator of strings. Flask provides the +:func:`~flask.stream_template` and :func:`~flask.stream_template_string` +functions to make this easier to use. - def stream_template(template_name, **context): - app.update_template_context(context) - t = app.jinja_env.get_template(template_name) - rv = t.stream(context) - rv.enable_buffering(5) - return rv +.. code-block:: python - @app.route('/my-large-page.html') - def render_large_template(): - rows = iter_all_rows() - return app.response_class(stream_template('the_template.html', rows=rows)) + from flask import stream_template + + @app.get("/timeline") + def timeline(): + return stream_template("timeline.html") + +The parts yielded by the render stream tend to match statement blocks in +the template. -The trick here is to get the template object from the Jinja2 environment -on the application and to call :meth:`~jinja2.Template.stream` instead of -:meth:`~jinja2.Template.render` which returns a stream object instead of a -string. Since we're bypassing the Flask template render functions and -using the template object itself we have to make sure to update the render -context ourselves by calling :meth:`~flask.Flask.update_template_context`. -The template is then evaluated as the stream is iterated over. Since each -time you do a yield the server will flush the content to the client you -might want to buffer up a few items in the template which you can do with -``rv.enable_buffering(size)``. ``5`` is a sane default. Streaming with Context ---------------------- -.. versionadded:: 0.9 +The :data:`~flask.request` will not be active while the generator is +running, because the view has already returned at that point. If you try +to access ``request``, you'll get a ``RuntimeError``. -Note that when you stream data, the request context is already gone the -moment the function executes. Flask 0.9 provides you with a helper that -can keep the request context around during the execution of the -generator:: +If your generator function relies on data in ``request``, use the +:func:`~flask.stream_with_context` wrapper. This will keep the request +context active during the generator. + +.. code-block:: python from flask import stream_with_context, request + from markupsafe import escape @app.route('/stream') def streamed_response(): def generate(): - yield 'Hello ' - yield request.args['name'] - yield '!' - return app.response_class(stream_with_context(generate())) + yield '

Hello ' + yield escape(request.args['name']) + yield '!

' + return stream_with_context(generate()) -Without the :func:`~flask.stream_with_context` function you would get a -:class:`RuntimeError` at that point. +It can also be used as a decorator. + +.. code-block:: python + + @stream_with_context + def generate(): + ... + + return generate() + +The :func:`~flask.stream_template` and +:func:`~flask.stream_template_string` functions automatically +use :func:`~flask.stream_with_context` if a request is active. diff --git a/docs/templating.rst b/docs/templating.rst index dcc757c3..3cda995e 100644 --- a/docs/templating.rst +++ b/docs/templating.rst @@ -201,3 +201,29 @@ templates:: You could also build `format_price` as a template filter (see :ref:`registering-filters`), but this demonstrates how to pass functions in a context processor. + +Streaming +--------- + +It can be useful to not render the whole template as one complete +string, instead render it as a stream, yielding smaller incremental +strings. This can be used for streaming HTML in chunks to speed up +initial page load, or to save memory when rendering a very large +template. + +The Jinja2 template engine supports rendering a template piece +by piece, returning an iterator of strings. Flask provides the +:func:`~flask.stream_template` and :func:`~flask.stream_template_string` +functions to make this easier to use. + +.. code-block:: python + + from flask import stream_template + + @app.get("/timeline") + def timeline(): + return stream_template("timeline.html") + +These functions automatically apply the +:func:`~flask.stream_with_context` wrapper if a request is active, so +that it remains available in the template. diff --git a/src/flask/__init__.py b/src/flask/__init__.py index bc93e0a3..8ca1dbad 100644 --- a/src/flask/__init__.py +++ b/src/flask/__init__.py @@ -41,5 +41,7 @@ from .signals import signals_available as signals_available from .signals import template_rendered as template_rendered from .templating import render_template as render_template from .templating import render_template_string as render_template_string +from .templating import stream_template as stream_template +from .templating import stream_template_string as stream_template_string __version__ = "2.2.0.dev0" diff --git a/src/flask/templating.py b/src/flask/templating.py index 36a8645c..7d92cf1e 100644 --- a/src/flask/templating.py +++ b/src/flask/templating.py @@ -7,6 +7,9 @@ from jinja2 import TemplateNotFound from .globals import _app_ctx_stack from .globals import _request_ctx_stack +from .globals import current_app +from .globals import request +from .helpers import stream_with_context from .signals import before_render_template from .signals import template_rendered @@ -122,8 +125,6 @@ class DispatchingJinjaLoader(BaseLoader): def _render(template: Template, context: dict, app: "Flask") -> str: - """Renders the template and fires the signal""" - before_render_template.send(app, template=template, context=context) rv = template.render(context) template_rendered.send(app, template=template, context=context) @@ -164,3 +165,56 @@ def render_template_string(source: str, **context: t.Any) -> str: ctx = _app_ctx_stack.top ctx.app.update_template_context(context) return _render(ctx.app.jinja_env.from_string(source), context, ctx.app) + + +def _stream( + app: "Flask", template: Template, context: t.Dict[str, t.Any] +) -> t.Iterator[str]: + app.update_template_context(context) + before_render_template.send(app, template=template, context=context) + + def generate() -> t.Iterator[str]: + yield from template.generate(context) + template_rendered.send(app, template=template, context=context) + + rv = generate() + + # If a request context is active, keep it while generating. + if request: + rv = stream_with_context(rv) + + return rv + + +def stream_template( + template_name_or_list: t.Union[str, Template, t.List[t.Union[str, Template]]], + **context: t.Any +) -> t.Iterator[str]: + """Render a template by name with the given context as a stream. + This returns an iterator of strings, which can be used as a + streaming response from a view. + + :param template_name_or_list: The name of the template to render. If + a list is given, the first name to exist will be rendered. + :param context: The variables to make available in the template. + + .. versionadded:: 2.2 + """ + app = current_app._get_current_object() # type: ignore[attr-defined] + template = app.jinja_env.get_or_select_template(template_name_or_list) + return _stream(app, template, context) + + +def stream_template_string(source: str, **context: t.Any) -> t.Iterator[str]: + """Render a template from the given source string with the given + context as a stream. This returns an iterator of strings, which can + be used as a streaming response from a view. + + :param source: The source code of the template to render. + :param context: The variables to make available in the template. + + .. versionadded:: 2.2 + """ + app = current_app._get_current_object() # type: ignore[attr-defined] + template = app.jinja_env.from_string(source) + return _stream(app, template, context) diff --git a/tests/test_templating.py b/tests/test_templating.py index a53120ea..f0d7c156 100644 --- a/tests/test_templating.py +++ b/tests/test_templating.py @@ -29,6 +29,15 @@ def test_original_win(app, client): assert rv.data == b"42" +def test_simple_stream(app, client): + @app.route("/") + def index(): + return flask.stream_template_string("{{ config }}", config=42) + + rv = client.get("/") + assert rv.data == b"42" + + def test_request_less_rendering(app, app_ctx): app.config["WORLD_NAME"] = "Special World"