aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorDavid Lord <davidism@gmail.com>2024-05-13 08:47:27 -0700
committerDavid Lord <davidism@gmail.com>2024-05-13 08:47:27 -0700
commitb002d9c6c3acf5c88167894a4963757d5d56b278 (patch)
tree6122790e5dcd142257561498e92a1ac4480b3f3d
parenta516a99bab2f401771964e5c42f09bcf57492e41 (diff)
parente82013c39970c336ed33906b3c38464f1fadbd30 (diff)
downloadjinja-upstream-main.tar.gz
Merge branch '3.1.x'upstream-main
-rw-r--r--.github/workflows/tests.yaml1
-rw-r--r--.pre-commit-config.yaml2
-rw-r--r--CHANGES.rst21
-rw-r--r--docs/api.rst3
-rw-r--r--requirements/build.txt2
-rw-r--r--requirements/dev.txt156
-rw-r--r--requirements/docs.txt9
-rw-r--r--requirements/tests.in1
-rw-r--r--requirements/tests.txt14
-rw-r--r--requirements/tests37.in2
-rw-r--r--requirements/tests37.txt43
-rw-r--r--requirements/typing.txt15
-rw-r--r--src/jinja2/async_utils.py25
-rw-r--r--src/jinja2/compiler.py44
-rw-r--r--src/jinja2/environment.py26
-rw-r--r--tests/test_async.py122
-rw-r--r--tests/test_async_filters.py67
-rw-r--r--tests/test_loader.py5
-rw-r--r--tox.ini35
19 files changed, 394 insertions, 199 deletions
diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml
index c852936..a7ad601 100644
--- a/.github/workflows/tests.yaml
+++ b/.github/workflows/tests.yaml
@@ -21,6 +21,7 @@ jobs:
fail-fast: false
matrix:
include:
+ - {python: '3.13'}
- {python: '3.12'}
- {name: Windows, python: '3.12', os: windows-latest}
- {name: Mac, python: '3.12', os: macos-latest}
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index ed8d790..5b7ebb8 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -2,7 +2,7 @@ ci:
autoupdate_schedule: monthly
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
- rev: v0.4.3
+ rev: v0.4.4
hooks:
- id: ruff
- id: ruff-format
diff --git a/CHANGES.rst b/CHANGES.rst
index 758beae..f23b6c9 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -11,6 +11,22 @@ Unreleased
- Use ``flit_core`` instead of ``setuptools`` as build backend.
+Version 3.1.5
+-------------
+
+Unreleased
+
+- Calling sync ``render`` for an async template uses ``asyncio.run``.
+ :pr:`1952`
+- Avoid unclosed ``auto_aiter`` warnings. :pr:`1960`
+- Return an ``aclose``-able ``AsyncGenerator`` from
+ ``Template.generate_async``. :pr:`1960`
+- Avoid leaving ``root_render_func()`` unclosed in
+ ``Template.generate_async``. :pr:`1960`
+- Avoid leaving async generators unclosed in blocks, includes and extends.
+ :pr:`1960`
+
+
Version 3.1.4
-------------
@@ -143,9 +159,8 @@ Released 2021-05-18
extensions shows more relevant context. :issue:`1429`
- Fixed calling deprecated ``jinja2.Markup`` without an argument.
Use ``markupsafe.Markup`` instead. :issue:`1438`
-- Calling sync ``render`` for an async template uses ``asyncio.run``
- on Python >= 3.7. This fixes a deprecation that Python 3.10
- introduces. :issue:`1443`
+- Calling sync ``render`` for an async template uses ``asyncio.new_event_loop``
+ This fixes a deprecation that Python 3.10 introduces. :issue:`1443`
Version 3.0.0
diff --git a/docs/api.rst b/docs/api.rst
index e2c9bd5..cb62f6c 100644
--- a/docs/api.rst
+++ b/docs/api.rst
@@ -515,9 +515,6 @@ environment to compile different code behind the scenes in order to
handle async and sync code in an asyncio event loop. This has the
following implications:
-- Template rendering requires an event loop to be available to the
- current thread. :func:`asyncio.get_running_loop` must return an
- event loop.
- The compiled code uses ``await`` for functions and attributes, and
uses ``async for`` loops. In order to support using both async and
sync functions in this context, a small wrapper is placed around
diff --git a/requirements/build.txt b/requirements/build.txt
index 9ecc489..52fd1f6 100644
--- a/requirements/build.txt
+++ b/requirements/build.txt
@@ -8,5 +8,5 @@ build==1.2.1
# via -r build.in
packaging==24.0
# via build
-pyproject-hooks==1.0.0
+pyproject-hooks==1.1.0
# via build
diff --git a/requirements/dev.txt b/requirements/dev.txt
index 5f01aa3..076912b 100644
--- a/requirements/dev.txt
+++ b/requirements/dev.txt
@@ -5,36 +5,36 @@
# pip-compile dev.in
#
alabaster==0.7.16
- # via
- # -r docs.txt
- # sphinx
-babel==2.14.0
- # via
- # -r docs.txt
- # sphinx
+ # via sphinx
+attrs==23.2.0
+ # via
+ # outcome
+ # trio
+babel==2.15.0
+ # via sphinx
+build==1.2.1
+ # via pip-tools
cachetools==5.3.3
# via tox
certifi==2024.2.2
- # via
- # -r docs.txt
- # requests
+ # via requests
cfgv==3.4.0
# via pre-commit
chardet==5.2.0
# via tox
charset-normalizer==3.3.2
+ # via requests
+click==8.1.7
# via
- # -r docs.txt
- # requests
+ # pip-compile-multi
+ # pip-tools
colorama==0.4.6
# via tox
distlib==0.3.8
# via virtualenv
docutils==0.21.2
- # via
- # -r docs.txt
- # sphinx
-filelock==3.13.4
+ # via sphinx
+filelock==3.14.0
# via
# tox
# virtualenv
@@ -42,127 +42,107 @@ identify==2.5.36
# via pre-commit
idna==3.7
# via
- # -r docs.txt
# requests
+ # trio
imagesize==1.4.1
- # via
- # -r docs.txt
- # sphinx
+ # via sphinx
iniconfig==2.0.0
- # via
- # -r tests.txt
- # -r typing.txt
- # pytest
-jinja2==3.1.3
- # via
- # -r docs.txt
- # sphinx
+ # via pytest
+jinja2==3.1.4
+ # via sphinx
markupsafe==2.1.5
- # via
- # -r docs.txt
- # jinja2
+ # via jinja2
mypy==1.10.0
- # via -r typing.txt
+ # via -r typing.in
mypy-extensions==1.0.0
- # via
- # -r typing.txt
- # mypy
+ # via mypy
nodeenv==1.8.0
- # via
- # -r typing.txt
- # pre-commit
- # pyright
+ # via pre-commit
+outcome==1.3.0.post0
+ # via trio
packaging==24.0
# via
- # -r docs.txt
- # -r tests.txt
- # -r typing.txt
+ # build
# pallets-sphinx-themes
# pyproject-api
# pytest
# sphinx
# tox
pallets-sphinx-themes==2.1.3
- # via -r docs.txt
+ # via -r docs.in
+pip-compile-multi==2.6.3
+ # via -r dev.in
+pip-tools==7.4.1
+ # via pip-compile-multi
platformdirs==4.2.1
# via
# tox
# virtualenv
pluggy==1.5.0
# via
- # -r tests.txt
- # -r typing.txt
# pytest
# tox
-pre-commit==3.7.0
+pre-commit==3.7.1
# via -r dev.in
-pygments==2.17.2
- # via
- # -r docs.txt
- # sphinx
+pygments==2.18.0
+ # via sphinx
pyproject-api==1.6.1
# via tox
-pyright==1.1.360
- # via -r typing.txt
-pytest==8.2.0
+pyproject-hooks==1.1.0
# via
- # -r tests.txt
- # -r typing.txt
+ # build
+ # pip-tools
+pytest==8.2.0
+ # via -r tests.in
pyyaml==6.0.1
# via pre-commit
requests==2.31.0
- # via
- # -r docs.txt
- # sphinx
+ # via sphinx
+sniffio==1.3.1
+ # via trio
snowballstemmer==2.2.0
- # via
- # -r docs.txt
- # sphinx
+ # via sphinx
+sortedcontainers==2.4.0
+ # via trio
sphinx==7.3.7
# via
- # -r docs.txt
+ # -r docs.in
# pallets-sphinx-themes
+ # sphinx-issues
# sphinxcontrib-log-cabinet
+sphinx-issues==4.1.0
+ # via -r docs.in
sphinxcontrib-applehelp==1.0.8
- # via
- # -r docs.txt
- # sphinx
+ # via sphinx
sphinxcontrib-devhelp==1.0.6
- # via
- # -r docs.txt
- # sphinx
+ # via sphinx
sphinxcontrib-htmlhelp==2.0.5
- # via
- # -r docs.txt
- # sphinx
+ # via sphinx
sphinxcontrib-jsmath==1.0.1
- # via
- # -r docs.txt
- # sphinx
+ # via sphinx
sphinxcontrib-log-cabinet==1.0.1
- # via -r docs.txt
+ # via -r docs.in
sphinxcontrib-qthelp==1.0.7
- # via
- # -r docs.txt
- # sphinx
+ # via sphinx
sphinxcontrib-serializinghtml==1.1.10
- # via
- # -r docs.txt
- # sphinx
+ # via sphinx
+toposort==1.10
+ # via pip-compile-multi
tox==4.15.0
# via -r dev.in
+trio==0.25.0
+ # via -r tests.in
typing-extensions==4.11.0
- # via
- # -r typing.txt
- # mypy
+ # via mypy
urllib3==2.2.1
- # via
- # -r docs.txt
- # requests
-virtualenv==20.26.0
+ # via requests
+virtualenv==20.26.1
# via
# pre-commit
# tox
+wheel==0.43.0
+ # via pip-tools
# The following packages are considered to be unsafe in a requirements file:
+# pip
# setuptools
diff --git a/requirements/docs.txt b/requirements/docs.txt
index 8cb0acd..2cbd73f 100644
--- a/requirements/docs.txt
+++ b/requirements/docs.txt
@@ -6,7 +6,7 @@
#
alabaster==0.7.16
# via sphinx
-babel==2.14.0
+babel==2.15.0
# via sphinx
certifi==2024.2.2
# via requests
@@ -18,7 +18,7 @@ idna==3.7
# via requests
imagesize==1.4.1
# via sphinx
-jinja2==3.1.3
+jinja2==3.1.4
# via sphinx
markupsafe==2.1.5
# via jinja2
@@ -28,7 +28,7 @@ packaging==24.0
# sphinx
pallets-sphinx-themes==2.1.3
# via -r docs.in
-pygments==2.17.2
+pygments==2.18.0
# via sphinx
requests==2.31.0
# via sphinx
@@ -38,7 +38,10 @@ sphinx==7.3.7
# via
# -r docs.in
# pallets-sphinx-themes
+ # sphinx-issues
# sphinxcontrib-log-cabinet
+sphinx-issues==4.1.0
+ # via -r docs.in
sphinxcontrib-applehelp==1.0.8
# via sphinx
sphinxcontrib-devhelp==1.0.6
diff --git a/requirements/tests.in b/requirements/tests.in
index e079f8a..5669c6e 100644
--- a/requirements/tests.in
+++ b/requirements/tests.in
@@ -1 +1,2 @@
pytest
+trio
diff --git a/requirements/tests.txt b/requirements/tests.txt
index c69278b..de18d47 100644
--- a/requirements/tests.txt
+++ b/requirements/tests.txt
@@ -4,11 +4,25 @@
#
# pip-compile tests.in
#
+attrs==23.2.0
+ # via
+ # outcome
+ # trio
+idna==3.7
+ # via trio
iniconfig==2.0.0
# via pytest
+outcome==1.3.0.post0
+ # via trio
packaging==24.0
# via pytest
pluggy==1.5.0
# via pytest
pytest==8.2.0
# via -r tests.in
+sniffio==1.3.1
+ # via trio
+sortedcontainers==2.4.0
+ # via trio
+trio==0.25.0
+ # via -r tests.in
diff --git a/requirements/tests37.in b/requirements/tests37.in
new file mode 100644
index 0000000..9c2bed1
--- /dev/null
+++ b/requirements/tests37.in
@@ -0,0 +1,2 @@
+pytest
+trio==0.22.2
diff --git a/requirements/tests37.txt b/requirements/tests37.txt
new file mode 100644
index 0000000..578789e
--- /dev/null
+++ b/requirements/tests37.txt
@@ -0,0 +1,43 @@
+#
+# This file is autogenerated by pip-compile with Python 3.7
+# by the following command:
+#
+# pip-compile tests37.in
+#
+attrs==23.2.0
+ # via
+ # outcome
+ # trio
+exceptiongroup==1.2.1
+ # via
+ # pytest
+ # trio
+idna==3.7
+ # via trio
+importlib-metadata==6.7.0
+ # via
+ # attrs
+ # pluggy
+ # pytest
+iniconfig==2.0.0
+ # via pytest
+outcome==1.3.0.post0
+ # via trio
+packaging==24.0
+ # via pytest
+pluggy==1.2.0
+ # via pytest
+pytest==7.4.4
+ # via -r tests37.in
+sniffio==1.3.1
+ # via trio
+sortedcontainers==2.4.0
+ # via trio
+tomli==2.0.1
+ # via pytest
+trio==0.22.2
+ # via -r tests37.in
+typing-extensions==4.7.1
+ # via importlib-metadata
+zipp==3.15.0
+ # via importlib-metadata
diff --git a/requirements/typing.txt b/requirements/typing.txt
index b3291cd..c08a537 100644
--- a/requirements/typing.txt
+++ b/requirements/typing.txt
@@ -4,24 +4,9 @@
#
# pip-compile typing.in
#
-iniconfig==2.0.0
- # via pytest
mypy==1.10.0
# via -r typing.in
mypy-extensions==1.0.0
# via mypy
-nodeenv==1.8.0
- # via pyright
-packaging==24.0
- # via pytest
-pluggy==1.5.0
- # via pytest
-pyright==1.1.360
- # via -r typing.in
-pytest==8.2.0
- # via -r typing.in
typing-extensions==4.11.0
# via mypy
-
-# The following packages are considered to be unsafe in a requirements file:
-# setuptools
diff --git a/src/jinja2/async_utils.py b/src/jinja2/async_utils.py
index e65219e..b0d277d 100644
--- a/src/jinja2/async_utils.py
+++ b/src/jinja2/async_utils.py
@@ -6,6 +6,9 @@ from functools import wraps
from .utils import _PassArg
from .utils import pass_eval_context
+if t.TYPE_CHECKING:
+ import typing_extensions as te
+
V = t.TypeVar("V")
@@ -67,15 +70,27 @@ async def auto_await(value: t.Union[t.Awaitable["V"], "V"]) -> "V":
return t.cast("V", value)
-async def auto_aiter(
+class _IteratorToAsyncIterator(t.Generic[V]):
+ def __init__(self, iterator: "t.Iterator[V]"):
+ self._iterator = iterator
+
+ def __aiter__(self) -> "te.Self":
+ return self
+
+ async def __anext__(self) -> V:
+ try:
+ return next(self._iterator)
+ except StopIteration as e:
+ raise StopAsyncIteration(e.value) from e
+
+
+def auto_aiter(
iterable: "t.Union[t.AsyncIterable[V], t.Iterable[V]]",
) -> "t.AsyncIterator[V]":
if hasattr(iterable, "__aiter__"):
- async for item in t.cast("t.AsyncIterable[V]", iterable):
- yield item
+ return iterable.__aiter__()
else:
- for item in iterable:
- yield item
+ return _IteratorToAsyncIterator(iter(iterable))
async def auto_to_list(
diff --git a/src/jinja2/compiler.py b/src/jinja2/compiler.py
index e12f437..91720c5 100644
--- a/src/jinja2/compiler.py
+++ b/src/jinja2/compiler.py
@@ -902,12 +902,15 @@ class CodeGenerator(NodeVisitor):
if not self.environment.is_async:
self.writeline("yield from parent_template.root_render_func(context)")
else:
- self.writeline(
- "async for event in parent_template.root_render_func(context):"
- )
+ self.writeline("agen = parent_template.root_render_func(context)")
+ self.writeline("try:")
+ self.indent()
+ self.writeline("async for event in agen:")
self.indent()
self.writeline("yield event")
self.outdent()
+ self.outdent()
+ self.writeline("finally: await agen.aclose()")
self.outdent(1 + (not self.has_known_extends))
# at this point we now have the blocks collected and can visit them too.
@@ -977,14 +980,20 @@ class CodeGenerator(NodeVisitor):
f"yield from context.blocks[{node.name!r}][0]({context})", node
)
else:
+ self.writeline(f"gen = context.blocks[{node.name!r}][0]({context})")
+ self.writeline("try:")
+ self.indent()
self.writeline(
- f"{self.choose_async()}for event in"
- f" context.blocks[{node.name!r}][0]({context}):",
+ f"{self.choose_async()}for event in gen:",
node,
)
self.indent()
self.simple_write("event", frame)
self.outdent()
+ self.outdent()
+ self.writeline(
+ f"finally: {self.choose_async('await gen.aclose()', 'gen.close()')}"
+ )
self.outdent(level)
@@ -1057,26 +1066,33 @@ class CodeGenerator(NodeVisitor):
self.writeline("else:")
self.indent()
- skip_event_yield = False
+ def loop_body() -> None:
+ self.indent()
+ self.simple_write("event", frame)
+ self.outdent()
+
if node.with_context:
self.writeline(
- f"{self.choose_async()}for event in template.root_render_func("
+ f"gen = template.root_render_func("
"template.new_context(context.get_all(), True,"
- f" {self.dump_local_context(frame)})):"
+ f" {self.dump_local_context(frame)}))"
+ )
+ self.writeline("try:")
+ self.indent()
+ self.writeline(f"{self.choose_async()}for event in gen:")
+ loop_body()
+ self.outdent()
+ self.writeline(
+ f"finally: {self.choose_async('await gen.aclose()', 'gen.close()')}"
)
elif self.environment.is_async:
self.writeline(
"for event in (await template._get_default_module_async())"
"._body_stream:"
)
+ loop_body()
else:
self.writeline("yield from template._get_default_module()._body_stream")
- skip_event_yield = True
-
- if not skip_event_yield:
- self.indent()
- self.simple_write("event", frame)
- self.outdent()
if node.ignore_missing:
self.outdent()
diff --git a/src/jinja2/environment.py b/src/jinja2/environment.py
index 09bfa0e..78657c6 100644
--- a/src/jinja2/environment.py
+++ b/src/jinja2/environment.py
@@ -1284,19 +1284,7 @@ class Template:
if self.environment.is_async:
import asyncio
- close = False
-
- try:
- loop = asyncio.get_running_loop()
- except RuntimeError:
- loop = asyncio.new_event_loop()
- close = True
-
- try:
- return loop.run_until_complete(self.render_async(*args, **kwargs))
- finally:
- if close:
- loop.close()
+ return asyncio.run(self.render_async(*args, **kwargs))
ctx = self.new_context(dict(*args, **kwargs))
@@ -1360,7 +1348,7 @@ class Template:
async def generate_async(
self, *args: t.Any, **kwargs: t.Any
- ) -> t.AsyncIterator[str]:
+ ) -> t.AsyncGenerator[str, object]:
"""An async version of :meth:`generate`. Works very similarly but
returns an async iterator instead.
"""
@@ -1372,8 +1360,14 @@ class Template:
ctx = self.new_context(dict(*args, **kwargs))
try:
- async for event in self.root_render_func(ctx): # type: ignore
- yield event
+ agen = self.root_render_func(ctx)
+ try:
+ async for event in agen: # type: ignore
+ yield event
+ finally:
+ # we can't use async with aclosing(...) because that's only
+ # in 3.10+
+ await agen.aclose() # type: ignore
except Exception:
yield self.environment.handle_exception()
diff --git a/tests/test_async.py b/tests/test_async.py
index c9ba70c..4edced9 100644
--- a/tests/test_async.py
+++ b/tests/test_async.py
@@ -1,6 +1,7 @@
import asyncio
import pytest
+import trio
from jinja2 import ChainableUndefined
from jinja2 import DictLoader
@@ -13,7 +14,16 @@ from jinja2.exceptions import UndefinedError
from jinja2.nativetypes import NativeEnvironment
-def test_basic_async():
+def _asyncio_run(async_fn, *args):
+ return asyncio.run(async_fn(*args))
+
+
+@pytest.fixture(params=[_asyncio_run, trio.run], ids=["asyncio", "trio"])
+def run_async_fn(request):
+ return request.param
+
+
+def test_basic_async(run_async_fn):
t = Template(
"{% for item in [1, 2, 3] %}[{{ item }}]{% endfor %}", enable_async=True
)
@@ -21,11 +31,11 @@ def test_basic_async():
async def func():
return await t.render_async()
- rv = asyncio.run(func())
+ rv = run_async_fn(func)
assert rv == "[1][2][3]"
-def test_await_on_calls():
+def test_await_on_calls(run_async_fn):
t = Template("{{ async_func() + normal_func() }}", enable_async=True)
async def async_func():
@@ -37,7 +47,7 @@ def test_await_on_calls():
async def func():
return await t.render_async(async_func=async_func, normal_func=normal_func)
- rv = asyncio.run(func())
+ rv = run_async_fn(func)
assert rv == "65"
@@ -54,7 +64,7 @@ def test_await_on_calls_normal_render():
assert rv == "65"
-def test_await_and_macros():
+def test_await_and_macros(run_async_fn):
t = Template(
"{% macro foo(x) %}[{{ x }}][{{ async_func() }}]{% endmacro %}{{ foo(42) }}",
enable_async=True,
@@ -66,11 +76,11 @@ def test_await_and_macros():
async def func():
return await t.render_async(async_func=async_func)
- rv = asyncio.run(func())
+ rv = run_async_fn(func)
assert rv == "[42][42]"
-def test_async_blocks():
+def test_async_blocks(run_async_fn):
t = Template(
"{% block foo %}<Test>{% endblock %}{{ self.foo() }}",
enable_async=True,
@@ -80,7 +90,7 @@ def test_async_blocks():
async def func():
return await t.render_async()
- rv = asyncio.run(func())
+ rv = run_async_fn(func)
assert rv == "<Test><Test>"
@@ -156,8 +166,8 @@ class TestAsyncImports:
test_env_async.from_string('{% from "foo" import bar, with, context %}')
test_env_async.from_string('{% from "foo" import bar, with with context %}')
- def test_exports(self, test_env_async):
- coro = test_env_async.from_string(
+ def test_exports(self, test_env_async, run_async_fn):
+ coro_fn = test_env_async.from_string(
"""
{% macro toplevel() %}...{% endmacro %}
{% macro __private() %}...{% endmacro %}
@@ -166,9 +176,9 @@ class TestAsyncImports:
{% macro notthere() %}{% endmacro %}
{% endfor %}
"""
- )._get_default_module_async()
- m = asyncio.run(coro)
- assert asyncio.run(m.toplevel()) == "..."
+ )._get_default_module_async
+ m = run_async_fn(coro_fn)
+ assert run_async_fn(m.toplevel) == "..."
assert not hasattr(m, "__missing")
assert m.variable == 42
assert not hasattr(m, "notthere")
@@ -457,17 +467,19 @@ class TestAsyncForLoop:
)
assert tmpl.render(items=reversed([3, 2, 1])) == "1,2,3"
- def test_loop_errors(self, test_env_async):
+ def test_loop_errors(self, test_env_async, run_async_fn):
tmpl = test_env_async.from_string(
"""{% for item in [1] if loop.index
== 0 %}...{% endfor %}"""
)
- pytest.raises(UndefinedError, tmpl.render)
+ with pytest.raises(UndefinedError):
+ run_async_fn(tmpl.render_async)
+
tmpl = test_env_async.from_string(
"""{% for item in [] %}...{% else
%}{{ loop }}{% endfor %}"""
)
- assert tmpl.render() == ""
+ assert run_async_fn(tmpl.render_async) == ""
def test_loop_filter(self, test_env_async):
tmpl = test_env_async.from_string(
@@ -597,7 +609,7 @@ class TestAsyncForLoop:
assert t.render(a=dict(b=[1, 2, 3])) == "1"
-def test_namespace_awaitable(test_env_async):
+def test_namespace_awaitable(test_env_async, run_async_fn):
async def _test():
t = test_env_async.from_string(
'{% set ns = namespace(foo="Bar") %}{{ ns.foo }}'
@@ -605,10 +617,10 @@ def test_namespace_awaitable(test_env_async):
actual = await t.render_async()
assert actual == "Bar"
- asyncio.run(_test())
+ run_async_fn(_test)
-def test_chainable_undefined_aiter():
+def test_chainable_undefined_aiter(run_async_fn):
async def _test():
t = Template(
"{% for x in a['b']['c'] %}{{ x }}{% endfor %}",
@@ -618,7 +630,7 @@ def test_chainable_undefined_aiter():
rv = await t.render_async(a={})
assert rv == ""
- asyncio.run(_test())
+ run_async_fn(_test)
@pytest.fixture
@@ -626,22 +638,22 @@ def async_native_env():
return NativeEnvironment(enable_async=True)
-def test_native_async(async_native_env):
+def test_native_async(async_native_env, run_async_fn):
async def _test():
t = async_native_env.from_string("{{ x }}")
rv = await t.render_async(x=23)
assert rv == 23
- asyncio.run(_test())
+ run_async_fn(_test)
-def test_native_list_async(async_native_env):
+def test_native_list_async(async_native_env, run_async_fn):
async def _test():
t = async_native_env.from_string("{{ x }}")
rv = await t.render_async(x=list(range(3)))
assert rv == [0, 1, 2]
- asyncio.run(_test())
+ run_async_fn(_test)
def test_getitem_after_filter():
@@ -658,3 +670,65 @@ def test_getitem_after_call():
t = env.from_string("{{ add_each(a, 2)[1:] }}")
out = t.render(a=range(3))
assert out == "[3, 4]"
+
+
+def test_basic_generate_async(run_async_fn):
+ t = Template(
+ "{% for item in [1, 2, 3] %}[{{ item }}]{% endfor %}", enable_async=True
+ )
+
+ async def func():
+ agen = t.generate_async()
+ try:
+ return await agen.__anext__()
+ finally:
+ await agen.aclose()
+
+ rv = run_async_fn(func)
+ assert rv == "["
+
+
+def test_include_generate_async(run_async_fn, test_env_async):
+ t = test_env_async.from_string('{% include "header" %}')
+
+ async def func():
+ agen = t.generate_async()
+ try:
+ return await agen.__anext__()
+ finally:
+ await agen.aclose()
+
+ rv = run_async_fn(func)
+ assert rv == "["
+
+
+def test_blocks_generate_async(run_async_fn):
+ t = Template(
+ "{% block foo %}<Test>{% endblock %}{{ self.foo() }}",
+ enable_async=True,
+ autoescape=True,
+ )
+
+ async def func():
+ agen = t.generate_async()
+ try:
+ return await agen.__anext__()
+ finally:
+ await agen.aclose()
+
+ rv = run_async_fn(func)
+ assert rv == "<Test>"
+
+
+def test_async_extend(run_async_fn, test_env_async):
+ t = test_env_async.from_string('{% extends "header" %}')
+
+ async def func():
+ agen = t.generate_async()
+ try:
+ return await agen.__anext__()
+ finally:
+ await agen.aclose()
+
+ rv = run_async_fn(func)
+ assert rv == "["
diff --git a/tests/test_async_filters.py b/tests/test_async_filters.py
index f5b2627..e8cc350 100644
--- a/tests/test_async_filters.py
+++ b/tests/test_async_filters.py
@@ -1,6 +1,9 @@
+import asyncio
+import contextlib
from collections import namedtuple
import pytest
+import trio
from markupsafe import Markup
from jinja2 import Environment
@@ -26,10 +29,39 @@ def env_async():
return Environment(enable_async=True)
+def _asyncio_run(async_fn, *args):
+ return asyncio.run(async_fn(*args))
+
+
+@pytest.fixture(params=[_asyncio_run, trio.run], ids=["asyncio", "trio"])
+def run_async_fn(request):
+ return request.param
+
+
+@contextlib.asynccontextmanager
+async def closing_factory():
+ async with contextlib.AsyncExitStack() as stack:
+
+ def closing(maybe_agen):
+ try:
+ aclose = maybe_agen.aclose
+ except AttributeError:
+ pass
+ else:
+ stack.push_async_callback(aclose)
+ return maybe_agen
+
+ yield closing
+
+
@mark_dualiter("foo", lambda: range(10))
-def test_first(env_async, foo):
- tmpl = env_async.from_string("{{ foo()|first }}")
- out = tmpl.render(foo=foo)
+def test_first(env_async, foo, run_async_fn):
+ async def test():
+ async with closing_factory() as closing:
+ tmpl = env_async.from_string("{{ closing(foo())|first }}")
+ return await tmpl.render_async(foo=foo, closing=closing)
+
+ out = run_async_fn(test)
assert out == "0"
@@ -245,18 +277,23 @@ def test_slice(env_async, items):
)
-def test_custom_async_filter(env_async):
+def test_custom_async_filter(env_async, run_async_fn):
async def customfilter(val):
return str(val)
- env_async.filters["customfilter"] = customfilter
- tmpl = env_async.from_string("{{ 'static'|customfilter }} {{ arg|customfilter }}")
- out = tmpl.render(arg="dynamic")
+ async def test():
+ env_async.filters["customfilter"] = customfilter
+ tmpl = env_async.from_string(
+ "{{ 'static'|customfilter }} {{ arg|customfilter }}"
+ )
+ return await tmpl.render_async(arg="dynamic")
+
+ out = run_async_fn(test)
assert out == "static dynamic"
@mark_dualiter("items", lambda: range(10))
-def test_custom_async_iteratable_filter(env_async, items):
+def test_custom_async_iteratable_filter(env_async, items, run_async_fn):
async def customfilter(iterable):
items = []
async for item in auto_aiter(iterable):
@@ -265,9 +302,13 @@ def test_custom_async_iteratable_filter(env_async, items):
break
return ",".join(items)
- env_async.filters["customfilter"] = customfilter
- tmpl = env_async.from_string(
- "{{ items()|customfilter }} .. {{ [3, 4, 5, 6]|customfilter }}"
- )
- out = tmpl.render(items=items)
+ async def test():
+ async with closing_factory() as closing:
+ env_async.filters["customfilter"] = customfilter
+ tmpl = env_async.from_string(
+ "{{ closing(items())|customfilter }} .. {{ [3, 4, 5, 6]|customfilter }}"
+ )
+ return await tmpl.render_async(items=items, closing=closing)
+
+ out = run_async_fn(test)
assert out == "0,1,2 .. 3,4,5"
diff --git a/tests/test_loader.py b/tests/test_loader.py
index 77d686e..3e64f62 100644
--- a/tests/test_loader.py
+++ b/tests/test_loader.py
@@ -2,7 +2,6 @@ import importlib.abc
import importlib.machinery
import importlib.util
import os
-import platform
import shutil
import sys
import tempfile
@@ -364,8 +363,8 @@ def test_package_zip_source(package_zip_loader, template, expect):
@pytest.mark.xfail(
- platform.python_implementation() == "PyPy",
- reason="PyPy's zipimporter doesn't have a '_files' attribute.",
+ sys.implementation.name == "pypy" or sys.version_info > (3, 13),
+ reason="zipimporter doesn't have a '_files' attribute",
raises=TypeError,
)
def test_package_zip_list(package_zip_loader):
diff --git a/tox.ini b/tox.ini
index f7bc0b3..bb84c67 100644
--- a/tox.ini
+++ b/tox.ini
@@ -1,6 +1,6 @@
[tox]
envlist =
- py3{12,11,10,9,8}
+ py3{13,12,11,10,9,8}
pypy310
style
typing
@@ -15,6 +15,9 @@ use_frozen_constraints = true
deps = -r requirements/tests.txt
commands = pytest -v --tb=short --basetemp={envtmpdir} {posargs}
+[testenv:py37,py3.7]
+deps = -r requirements/tests37.txt
+
[testenv:style]
deps = pre-commit
skip_install = true
@@ -28,16 +31,28 @@ commands = mypy
deps = -r requirements/docs.txt
commands = sphinx-build -E -W -b dirhtml docs docs/_build/dirhtml
+[testenv:update-pre_commit]
+labels = update
+deps = pre-commit
+skip_install = true
+commands = pre-commit autoupdate -j4
+
[testenv:update-requirements]
-deps =
- pip-tools
- pre-commit
+labels = update
+deps = pip-tools
skip_install = true
change_dir = requirements
commands =
- pre-commit autoupdate -j4
- pip-compile -U build.in
- pip-compile -U docs.in
- pip-compile -U tests.in
- pip-compile -U typing.in
- pip-compile -U dev.in
+ pip-compile build.in -q {posargs:-U}
+ pip-compile docs.in -q {posargs:-U}
+ pip-compile tests.in -q {posargs:-U}
+ pip-compile typing.in -q {posargs:-U}
+ pip-compile dev.in -q {posargs:-U}
+
+[testenv:update-requirements37]
+base_python = 3.7
+labels = update
+deps = pip-tools
+skip_install = true
+change_dir = requirements
+commands = pip-compile tests37.in -q {posargs:-U}