Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/docs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,10 @@ jobs:
run: python -m development.docs.write_openapi_spec
working-directory: ./inference_repo

- name: Validate generated docs (Jinja2 syntax)
run: python -m development.docs.validate_docs_jinja2
working-directory: ./inference_repo

- name: Deploy docs
# Only deploy if release event OR if deploy input was set to true
if: ${{ github.event_name == 'release' || github.event.inputs.deploy == 'true' }}
Expand Down
21 changes: 21 additions & 0 deletions development/docs/build_block_docs.py
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,11 @@ def render_template(template_name, **kwargs):
"""

INLINE_UQL_PARAMETER_PATTERN = re.compile(r"({{\s*\$parameters\.(\w+)\s*}})")
# Pattern to match any {{ ... }} containing a $ sign that hasn't already been escaped.
# This catches patterns like {{ $parameters.xxx }} in LONG_DESCRIPTION strings,
# Field descriptions, and other generated content that would cause mkdocs-macros
# Jinja2 parse errors ("unexpected char '$'").
JINJA2_DOLLAR_EXPRESSION_PATTERN = re.compile(r"(\{\{(?:(?!\}\}).)*?\$(?:(?!\}\}).)*?\}\})")

BLOCK_SECTIONS = [
{
Expand Down Expand Up @@ -330,6 +335,7 @@ def write_individual_block_pages(block_families, blocks_description):
family_name=family_name,
content=all_versions_combined,
)
family_document_content = _escape_jinja2_expressions(family_document_content)
with open(documentation_file_path, "w") as documentation_file:
documentation_file.write(family_document_content)

Expand Down Expand Up @@ -442,6 +448,20 @@ def _escape_uql_brackets(match: re.Match) -> str:
return "{{ '{{' }}" + content[2:-2] + "{{ '}}' }}"


def _escape_jinja2_expressions(content: str) -> str:
"""Escape any {{ ... }} expressions containing $ signs so mkdocs-macros
(Jinja2) does not attempt to evaluate them. Already-escaped expressions
like ``{{ '{{' }}`` are left untouched because they do not contain ``$``.
"""
return JINJA2_DOLLAR_EXPRESSION_PATTERN.sub(_escape_jinja2_dollar_expression, content)


def _escape_jinja2_dollar_expression(match: re.Match) -> str:
content = match.group(0)
# Replace outer {{ }} with Jinja2 literal escapes, keep inner content
return "{{ '{{' }}" + content[2:-2] + "{{ '}}' }}"


def get_source_link_for_block_class(block_class: Type[WorkflowBlock]) -> str:
try:
filename = inspect.getfile(block_class).split("inference/core/workflows/")[1]
Expand Down Expand Up @@ -658,6 +678,7 @@ def write_kinds_docs(blocks_description):
f"* [`{declared_kind.name}`]({relative_link}): {description}\n"
)
kind_file_path = build_kind_page_path(kind_name=declared_kind.name)
kind_page = _escape_jinja2_expressions(kind_page)
with open(kind_file_path, "w") as documentation_file:
documentation_file.write(kind_page)

Expand Down
79 changes: 79 additions & 0 deletions development/docs/validate_docs_jinja2.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
"""Validate that generated documentation files do not contain Jinja2 syntax
errors.

mkdocs-macros processes all Markdown files through Jinja2 before rendering.
Patterns like ``{{ $parameters.xxx }}`` cause "unexpected char '$'" errors
because ``$`` is not valid inside Jinja2 expressions. The doc build pipeline
(``build_block_docs.py``) escapes these patterns, but this script acts as a
safety net to catch any regressions.

Usage:
python -m development.docs.validate_docs_jinja2 [docs_dir]

If no directory is given, it defaults to ``docs/``. The script exits with
code 1 if any Jinja2 parse errors are found.
"""

import glob
import os
import sys

from jinja2 import Environment


def validate_markdown_files(docs_dir: str) -> list[tuple[str, str]]:
"""Parse every ``.md`` file under *docs_dir* as a Jinja2 template.

Returns a list of ``(relative_path, error_message)`` tuples for files that
fail to parse.
"""
env = Environment()
errors: list[tuple[str, str]] = []

md_files = sorted(glob.glob(os.path.join(docs_dir, "**", "*.md"), recursive=True))
for md_file in md_files:
with open(md_file, encoding="utf-8") as fh:
content = fh.read()
try:
env.parse(content)
except Exception as exc:
rel_path = os.path.relpath(md_file, docs_dir)
errors.append((rel_path, str(exc)))

return errors


def main() -> None:
if len(sys.argv) > 1:
docs_dir = sys.argv[1]
else:
docs_dir = os.path.join(os.path.dirname(__file__), "..", "..", "docs")

docs_dir = os.path.abspath(docs_dir)
if not os.path.isdir(docs_dir):
print(f"Error: {docs_dir} is not a directory", file=sys.stderr)
sys.exit(2)

print(f"Validating Jinja2 syntax in {docs_dir} ...")
errors = validate_markdown_files(docs_dir)

if errors:
print(f"\n{len(errors)} file(s) with Jinja2 syntax errors:\n", file=sys.stderr)
for rel_path, err_msg in errors:
print(f" {rel_path}: {err_msg}", file=sys.stderr)
print(
"\nThese files will cause 'Macro Syntax Error' when rendered by "
"mkdocs-macros. Ensure that {{ ... }} expressions containing '$' "
"are escaped (e.g. {{ '{{' }} $parameters.xxx {{ '}}' }}).",
file=sys.stderr,
)
sys.exit(1)
else:
md_count = len(
glob.glob(os.path.join(docs_dir, "**", "*.md"), recursive=True)
)
print(f"All {md_count} Markdown files pass Jinja2 syntax validation.")


if __name__ == "__main__":
main()
Empty file added tests/docs/__init__.py
Empty file.
100 changes: 100 additions & 0 deletions tests/docs/test_build_block_docs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
"""Tests for the documentation build pipeline's Jinja2 escaping logic.

These tests ensure that generated Markdown docs do not contain raw
``{{ $parameters.xxx }}`` expressions that would cause mkdocs-macros
(Jinja2) parse errors like "unexpected char '$'".
"""

import pytest
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

unused import

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@claude fix pls

from jinja2 import Environment

from development.docs.build_block_docs import (
_escape_jinja2_expressions,
)

jinja_env = Environment()


def _parses_as_jinja2(text: str) -> bool:
"""Return True if *text* can be parsed by Jinja2 without errors."""
try:
jinja_env.parse(text)
return True
except Exception:
return False


class TestEscapeJinja2Expressions:
"""Tests for ``_escape_jinja2_expressions``."""

def test_bare_dollar_parameters_in_braces(self):
"""The main bug: ``{{ $parameters.xxx }}`` must be escaped."""
raw = 'replaces placeholders like `{{ $parameters.parameter_name }}`'
result = _escape_jinja2_expressions(raw)
assert _parses_as_jinja2(result)
# The rendered output should still contain the human-readable text
assert "$parameters.parameter_name" in result

def test_multiple_dollar_expressions(self):
raw = (
"Detected {{ $parameters.num_objects }} objects. "
"Classes: {{ $parameters.classes }}."
)
result = _escape_jinja2_expressions(raw)
assert _parses_as_jinja2(result)
assert "$parameters.num_objects" in result
assert "$parameters.classes" in result

def test_already_escaped_expression_not_double_escaped(self):
"""Expressions that are already escaped must not be broken."""
already_escaped = "{{ '{{' }} $parameters.predicted_classes {{ '}}' }}"
result = _escape_jinja2_expressions(already_escaped)
assert _parses_as_jinja2(result)

def test_normal_jinja2_variables_untouched(self):
"""Legitimate Jinja2 variables (no $) must not be modified."""
normal = "Version: {{ VERSION }}"
result = _escape_jinja2_expressions(normal)
assert result == normal

def test_no_braces_dollar_untouched(self):
"""Bare $inputs.xxx outside {{ }} must not be modified."""
bare = '"smtp_server": "$inputs.smtp_server"'
result = _escape_jinja2_expressions(bare)
assert result == bare
assert _parses_as_jinja2(result)

def test_dollar_parameters_with_extra_spaces(self):
raw = "{{ $parameters.foo }}"
result = _escape_jinja2_expressions(raw)
assert _parses_as_jinja2(result)

def test_css_double_braces_untouched(self):
"""CSS rules using ``{{ }}`` for escaping Python .format() must survive."""
css = "article > a.md-content__button.md-icon:first-child {{ display: none; }}"
result = _escape_jinja2_expressions(css)
# CSS doesn't contain $ so should be untouched
assert result == css

def test_real_world_email_notification_description(self):
"""Reproduce the exact text that caused the original bug report."""
text = (
"3. Formats the email message by processing dynamic parameters "
"(replaces placeholders like `{{ $parameters.parameter_name }}` "
"with actual workflow data from `message_parameters`)"
)
assert not _parses_as_jinja2(text) # Confirm it's broken before fix
result = _escape_jinja2_expressions(text)
assert _parses_as_jinja2(result)

def test_real_world_field_description(self):
"""Field description with multiple {{ $parameters }} references."""
text = (
"SMS message content (plain text). Supports dynamic parameters "
"using placeholder syntax: {{ $parameters.parameter_name }}. "
"Example: 'Detected {{ $parameters.num_objects }} objects. "
"Alert: {{ $parameters.classes }}.'"
)
assert not _parses_as_jinja2(text)
result = _escape_jinja2_expressions(text)
assert _parses_as_jinja2(result)