From 77b42e9e81c2eed601fda1a9b080c7c12d5cc438 Mon Sep 17 00:00:00 2001 From: Optifiner AI Date: Sat, 24 Jan 2026 16:26:46 +0800 Subject: [PATCH] Cache compiled templates in render_template_string --- src/flask/templating.py | 46 ++++++++++++++++- tests/test_templating.py | 103 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 147 insertions(+), 2 deletions(-) diff --git a/src/flask/templating.py b/src/flask/templating.py index 4bb86d59..55ce6b38 100644 --- a/src/flask/templating.py +++ b/src/flask/templating.py @@ -1,6 +1,7 @@ from __future__ import annotations import typing as t +from collections import OrderedDict from jinja2 import BaseLoader from jinja2 import Environment as BaseEnvironment @@ -38,11 +39,46 @@ class Environment(BaseEnvironment): name of the blueprint to referenced templates if necessary. """ + #: Maximum number of string templates to cache. When the cache is full, + #: the least recently used templates are evicted. + string_template_cache_size: t.ClassVar[int] = 100 + + #: Maximum length of a template source string to cache. Strings longer + #: than this are compiled but not cached. + string_template_cache_max_len: t.ClassVar[int] = 100_000 + def __init__(self, app: App, **options: t.Any) -> None: if "loader" not in options: options["loader"] = app.create_global_jinja_loader() BaseEnvironment.__init__(self, **options) self.app = app + self._string_template_cache: OrderedDict[str, Template] = OrderedDict() + + def get_or_compile_string(self, source: str) -> Template: + """Get a compiled template from a source string, using a cache to + avoid recompiling the same template multiple times. + + .. versionadded:: 3.2 + """ + # Skip caching for very large templates + if len(source) > self.string_template_cache_max_len: + return self.from_string(source) + + cache = self._string_template_cache + + if source in cache: + # Move to end to mark as recently used + cache.move_to_end(source) + return cache[source] + + template = self.from_string(source) + + # Evict oldest entries if cache is full + while len(cache) >= self.string_template_cache_size: + cache.popitem(last=False) + + cache[source] = template + return template class DispatchingJinjaLoader(BaseLoader): @@ -153,9 +189,12 @@ def render_template_string(source: str, **context: t.Any) -> str: :param source: The source code of the template to render. :param context: The variables to make available in the template. + + .. versionchanged:: 3.2 + Templates are cached to avoid recompiling the same source string. """ ctx = app_ctx._get_current_object() - template = ctx.app.jinja_env.from_string(source) + template = ctx.app.jinja_env.get_or_compile_string(source) return _render(ctx, template, context) @@ -205,7 +244,10 @@ def stream_template_string(source: str, **context: t.Any) -> t.Iterator[str]: :param context: The variables to make available in the template. .. versionadded:: 2.2 + + .. versionchanged:: 3.2 + Templates are cached to avoid recompiling the same source string. """ ctx = app_ctx._get_current_object() - template = ctx.app.jinja_env.from_string(source) + template = ctx.app.jinja_env.get_or_compile_string(source) return _stream(ctx, template, context) diff --git a/tests/test_templating.py b/tests/test_templating.py index 85549df0..8ca481f5 100644 --- a/tests/test_templating.py +++ b/tests/test_templating.py @@ -530,3 +530,106 @@ def test_custom_jinja_env(): app = CustomFlask(__name__) assert isinstance(app.jinja_env, CustomEnvironment) + + +def test_template_string_caching(app, app_ctx): + """Test that render_template_string caches compiled templates.""" + source = "Hello {{ name }}!" + cache = app.jinja_env._string_template_cache + + # Cache should be empty initially + assert len(cache) == 0 + + # First render should add to cache + result1 = flask.render_template_string(source, name="World") + assert result1 == "Hello World!" + assert len(cache) == 1 + assert source in cache + + # Second render should use cached template + cached_template = cache[source] + result2 = flask.render_template_string(source, name="Flask") + assert result2 == "Hello Flask!" + assert len(cache) == 1 + assert cache[source] is cached_template # Same object + + +def test_stream_template_string_caching(app, app_ctx): + """Test that stream_template_string uses the same cache.""" + source = "Hello {{ name }}!" + cache = app.jinja_env._string_template_cache + + # Render with stream_template_string + result = "".join(flask.stream_template_string(source, name="World")) + assert result == "Hello World!" + assert source in cache + + # Render same source with render_template_string should hit cache + cached_template = cache[source] + result2 = flask.render_template_string(source, name="Flask") + assert result2 == "Hello Flask!" + assert cache[source] is cached_template + + +def test_template_string_cache_per_app(app_ctx): + """Test that each app has its own template string cache.""" + app1 = flask.Flask(__name__) + app2 = flask.Flask(__name__) + source = "Hello {{ name }}!" + + with app1.app_context(): + flask.render_template_string(source, name="App1") + + with app2.app_context(): + flask.render_template_string(source, name="App2") + + # Each app should have its own cache with the template + assert source in app1.jinja_env._string_template_cache + assert source in app2.jinja_env._string_template_cache + # But they should be different template objects (different environments) + assert ( + app1.jinja_env._string_template_cache[source] + is not app2.jinja_env._string_template_cache[source] + ) + + +def test_template_string_cache_lru_eviction(app, app_ctx): + """Test that the cache evicts least recently used templates.""" + # Set a small cache size for testing + app.jinja_env.string_template_cache_size = 3 + cache = app.jinja_env._string_template_cache + + # Fill the cache + flask.render_template_string("{{ a }}", a=1) + flask.render_template_string("{{ b }}", b=2) + flask.render_template_string("{{ c }}", c=3) + assert len(cache) == 3 + assert list(cache.keys()) == ["{{ a }}", "{{ b }}", "{{ c }}"] + + # Access the first one to make it recently used + flask.render_template_string("{{ a }}", a=1) + assert list(cache.keys()) == ["{{ b }}", "{{ c }}", "{{ a }}"] + + # Add a new template, should evict "{{ b }}" (least recently used) + flask.render_template_string("{{ d }}", d=4) + assert len(cache) == 3 + assert "{{ b }}" not in cache + assert list(cache.keys()) == ["{{ c }}", "{{ a }}", "{{ d }}"] + + +def test_template_string_cache_max_len(app, app_ctx): + """Test that templates exceeding max length are not cached.""" + # Set a small max length for testing + app.jinja_env.string_template_cache_max_len = 20 + cache = app.jinja_env._string_template_cache + + # Short template should be cached + short_source = "{{ x }}" + flask.render_template_string(short_source, x=1) + assert short_source in cache + + # Long template should not be cached + long_source = "{{ x }}" + " " * 20 # exceeds max_len of 20 + result = flask.render_template_string(long_source, x=2) + assert result == "2" + " " * 20 + assert long_source not in cache