jinja icon indicating copy to clipboard operation
jinja copied to clipboard

include without context doesn't work in macro

Open dseomn opened this issue 5 months ago • 4 comments

When I use include ... without context inside a macro like this:

import jinja2

print(
    jinja2.Environment(
        loader=jinja2.DictLoader(
            {
                "included": "foo",
                "main": """
                    {%- macro foo() %}
                        {%- include "included" without context %}
                    {%- endmacro %}
                    {{- foo() -}}
                """,
            }
        ),
    )
    .get_template("main")
    .render()
)

The macro evaluates to something that looks like a repr() string instead of the contents of the included template:

$ python3 foo.py 
<generator object root.<locals>.macro at 0x7f79f60a0580>

The same include line works outside of a macro, and it works inside the macro if I remove without context:

$ python3 foo.py 
foo

Environment:

  • Python version: 3.13.5
  • Jinja version: 3.1.6-1

dseomn avatar Jul 17 '25 00:07 dseomn

First step would be to compare the output from env.compile(..., raw=True) to see how the generated Python code differs, then figure out what the compiler is doing. It's complex, but I'm happy to answer questions or review a PR.

davidism avatar Jul 17 '25 00:07 davidism

It looks like the macro function is a generator in the without context case and a normal function in the other case. Totally guessing since I'm not familiar with the internal API at all, but should the first one return concat(template._get_default_module()._body_stream) instead of using yield from?

In [31]: print(jinja2.Environment().compile('{% macro foo() %}{% include "included" without context %}{% endmacro %}{{ foo() }}', raw=True))
from jinja2.runtime import LoopContext, Macro, Markup, Namespace, TemplateNotFound, TemplateReference, TemplateRuntimeError, Undefined, escape, identity, internalcode, markup_join, missing, str_join
name = None

def root(context, missing=missing, environment=environment):
    resolve = context.resolve_or_missing
    undefined = environment.undefined
    concat = environment.concat
    cond_expr_undefined = Undefined
    if 0: yield None
    l_0_foo = missing
    pass
    def macro():
        t_1 = []
        pass
        template = environment.get_template('included', None)
        yield from template._get_default_module()._body_stream
        return concat(t_1)
    context.exported_vars.add('foo')
    context.vars['foo'] = l_0_foo = Macro(environment, macro, 'foo', (), False, False, False, context.eval_ctx.autoescape)
    yield str(context.call((undefined(name='foo') if l_0_foo is missing else l_0_foo)))

blocks = {}
debug_info = '1=12'
In [32]: print(jinja2.Environment().compile('{% macro foo() %}{% include "included" %}{% endmacro %}{{ foo() }}', raw=True))
from jinja2.runtime import LoopContext, Macro, Markup, Namespace, TemplateNotFound, TemplateReference, TemplateRuntimeError, Undefined, escape, identity, internalcode, markup_join, missing, str_join
name = None

def root(context, missing=missing, environment=environment):
    resolve = context.resolve_or_missing
    undefined = environment.undefined
    concat = environment.concat
    cond_expr_undefined = Undefined
    if 0: yield None
    l_0_foo = missing
    pass
    def macro():
        t_1 = []
        pass
        template = environment.get_template('included', None)
        gen = template.root_render_func(template.new_context(context.get_all(), True, {'foo': l_0_foo}))
        try:
            for event in gen:
                t_1.append(event)
        finally: gen.close()
        return concat(t_1)
    context.exported_vars.add('foo')
    context.vars['foo'] = l_0_foo = Macro(environment, macro, 'foo', (), False, False, False, context.eval_ctx.autoescape)
    yield str(context.call((undefined(name='foo') if l_0_foo is missing else l_0_foo)))

blocks = {}
debug_info = '1=12'

P.S. I didn't know off the top of my head what returning a non-None value would do in a function that also had a yield from expression. To save anybody else time looking it up too, https://docs.python.org/3/reference/simple_stmts.html#the-return-statement says:

In a generator function, the return statement indicates that the generator is done and will cause StopIteration to be raised. The returned value (if any) is used as an argument to construct StopIteration and becomes the StopIteration.value attribute.

dseomn avatar Jul 17 '25 03:07 dseomn

And this is the relevant code, right?

https://github.com/pallets/jinja/blob/5ef70112a1ff19c05324ff889dd30405b1002044/src/jinja2/compiler.py#L1068-L1089

dseomn avatar Jul 17 '25 03:07 dseomn

I just looked at the compiled code when there's anything else in the macro other than the include statement. It looks like that other stuff appends to t_1. So the yield from that the include statement generates turns the macro function into a generator, which completely changes the meaning of return concat(t_1) at the end, breaking everything else in the macro definition. I think the right solution is to do t_1.extend(template._get_default_module()._body_stream) then or just use a for loop like the other branches in visit_Include?

dseomn avatar Jul 17 '25 03:07 dseomn