Jinja trim_blocks Swallows the Newline After a Trailing Comment and Joins Two Config Lines
A Jinja comment is meant to vanish from the output. Put one at the end of a config line and, with the settings most config generators use, it takes the line break with it.
The render succeeds. Nothing is logged. The next command is simply on the end of the previous one.
I generate FortiGate configuration from Jinja templates and push the result to FortiManager as a CLI script. This is a short note on a quiet way to break that, because the same thing applies to any line-based config: Cisco, Junos, Arista, FortiOS.
Everything below was run on Jinja 3.0.3 with Python 3. I have not sent the broken output to a device, and I would not.
The setup
Config templates are almost always rendered with two options on:
from jinja2 import Environment, StrictUndefined
env = Environment(
undefined=StrictUndefined,
trim_blocks=True,
lstrip_blocks=True,
)
trim_blocks removes the first newline after a block tag, so {% if %} and {% endif %} lines do not leave blank lines behind. lstrip_blocks strips the leading whitespace before a block tag, so you can indent tags for readability. Without them, a templated config is full of empty lines.
The template
An ordinary template with a note on the end of one line:
config system ntp
set ntpsync enable {# keep sync on #}
set type custom
set syncinterval 60
end
What it renders
With both options off, the comment disappears and the line breaks stay put:
config system ntp
set ntpsync enable
set type custom
set syncinterval 60
end
With trim_blocks=True, the output is:
config system ntp
set ntpsync enable set type custom
set syncinterval 60
end
set ntpsync enable and set type custom are now one line. I confirmed it is trim_blocks doing it: trim_blocks=True on its own reproduces it, and lstrip_blocks=True on its own does not.
The option is documented as removing the first newline after a block. A comment tag gets the same treatment. When the comment sits on its own line that is exactly what you want, because the whole line disappears cleanly. When the comment follows content, the newline you lose is the one that ends your command.
StrictUndefined does not help. It guards against missing variables, and nothing here is missing. There is also no exception, so a pipeline that only checks that the render did not fail will pass this straight through.
Three fixes
1. Put the comment on its own line. This is the one I use as a rule:
config system ntp
{# keep sync on #}
set ntpsync enable
set type custom
set syncinterval 60
end
lstrip_blocks removes the indentation in front of the comment and trim_blocks removes the newline after it, so the comment leaves no trace:
config system ntp
set ntpsync enable
set type custom
set syncinterval 60
end
2. Add a blank line after the trailing comment. trim_blocks removes the first newline and the second one ends your command. The blank line is consumed, so the output is correct, but it looks like a typo to the next person editing the template.
3. End the comment with +#}. The plus sign switches off the newline trimming for that one tag:
set ntpsync enable {# keep sync on +#}
The output keeps its line breaks. The space before the comment stays as trailing whitespace on the rendered line, which is harmless in most places and untidy in a diff.
A cheap check
Two habits catch this class of mistake.
The first is to search your templates for a comment that follows content on the same line:
grep -rnE '\{#.*#\}[[:space:]]*$' templates | grep -vE ':[0-9]+:[[:space:]]*\{#'
The first pattern finds lines ending in a comment. The second grep drops lines where the comment is the first thing on the line, which are the safe ones. On my templates today this returns nothing.
The second is a check on the rendered output. In FortiOS CLI a line carries one set, so a line with two is a joined line:
import re
def joined_lines(rendered: str) -> list[str]:
return [
line for line in rendered.splitlines()
if len(re.findall(r"\bset\b", line)) > 1
]
Against the two renders above, it flags the broken one and passes the fixed one:
guard flags: [' set ntpsync enable set type custom']
guard flags: []
It will not catch every possible join. It is a smoke test for the commonest one, and it runs in milliseconds.
The stronger habit is to read the diff before anything is installed. My pipeline stages a script and previews the change before install, which is where a joined line would be expected to show up, and it is a good reason never to skip the preview on a template change that looks trivial.
The summary
- With
trim_blocks=True, a Jinja comment removes the newline that follows it, the same as a block tag. - A trailing comment on a config line therefore joins that line to the next one. The render succeeds and
StrictUndefineddoes not object. - Keep comments on their own line. Use
+#}or a blank line only when you have a reason to. - Search templates for trailing comments, and add a render check that flags lines holding two commands.
I tested on Jinja 3.0.3. If you run a different version, the reproduction takes a minute and is worth doing before you rely on any of this.