diff options
author | David Lord <davidism@gmail.com> | 2024-05-13 08:47:27 -0700 |
---|---|---|
committer | David Lord <davidism@gmail.com> | 2024-05-13 08:47:27 -0700 |
commit | b002d9c6c3acf5c88167894a4963757d5d56b278 (patch) | |
tree | 6122790e5dcd142257561498e92a1ac4480b3f3d | |
parent | a516a99bab2f401771964e5c42f09bcf57492e41 (diff) | |
parent | e82013c39970c336ed33906b3c38464f1fadbd30 (diff) | |
download | jinja-upstream-main.tar.gz |
Merge branch '3.1.x'upstream-main
-rw-r--r-- | .github/workflows/tests.yaml | 1 | ||||
-rw-r--r-- | .pre-commit-config.yaml | 2 | ||||
-rw-r--r-- | CHANGES.rst | 21 | ||||
-rw-r--r-- | docs/api.rst | 3 | ||||
-rw-r--r-- | requirements/build.txt | 2 | ||||
-rw-r--r-- | requirements/dev.txt | 156 | ||||
-rw-r--r-- | requirements/docs.txt | 9 | ||||
-rw-r--r-- | requirements/tests.in | 1 | ||||
-rw-r--r-- | requirements/tests.txt | 14 | ||||
-rw-r--r-- | requirements/tests37.in | 2 | ||||
-rw-r--r-- | requirements/tests37.txt | 43 | ||||
-rw-r--r-- | requirements/typing.txt | 15 | ||||
-rw-r--r-- | src/jinja2/async_utils.py | 25 | ||||
-rw-r--r-- | src/jinja2/compiler.py | 44 | ||||
-rw-r--r-- | src/jinja2/environment.py | 26 | ||||
-rw-r--r-- | tests/test_async.py | 122 | ||||
-rw-r--r-- | tests/test_async_filters.py | 67 | ||||
-rw-r--r-- | tests/test_loader.py | 5 | ||||
-rw-r--r-- | tox.ini | 35 |
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): @@ -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} |