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
57 changes: 39 additions & 18 deletions myst_parser/mdit_to_docutils/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -1973,25 +1973,46 @@ def render_substitution(self, token: SyntaxTreeNode, inline: bool) -> None:
# fail on undefined variables
env = jinja2.Environment(undefined=jinja2.StrictUndefined)

# try rendering
try:
rendered = env.from_string(f"{{{{{token.content}}}}}").render(
variable_context
)
except Exception as error:
self.create_warning(
f"Substitution error:{error.__class__.__name__}: {error}",
MystWarnings.SUBSTITUTION,
line=position,
append_to=self.current_node,
)
return
content = token.content.strip()

if content in variable_context:
# Fast path for a substitution whose content is exactly a
# known key, looked up directly rather than through Jinja2
# expression parsing. This is required (not just an
# optimisation) for keys containing characters that are
# invalid in a Jinja2 identifier -- most commonly a dash,
# e.g. `foo-bar` -- which Jinja2 would otherwise parse as
# an *expression* (`foo - bar`, a subtraction of two
# separate, likely-undefined names) rather than as a
# single variable reference. See GH #1007.
#
# This only applies when the whole substitution is exactly
# one known key; anything else (arbitrary Jinja2
# expressions, filters, dotted attribute access, etc.)
# still goes through the normal Jinja2 rendering path
# below, unaffected.
rendered = str(variable_context[content])
references = set() if content == "env" else {content}
else:
# try rendering
try:
rendered = env.from_string(f"{{{{{token.content}}}}}").render(
variable_context
)
except Exception as error:
self.create_warning(
f"Substitution error:{error.__class__.__name__}: {error}",
MystWarnings.SUBSTITUTION,
line=position,
append_to=self.current_node,
)
return

# handle circular references
ast = env.parse(f"{{{{{token.content}}}}}")
references = {
n.name for n in ast.find_all(jinja2.nodes.Name) if n.name != "env"
}
# handle circular references
ast = env.parse(f"{{{{{token.content}}}}}")
references = {
n.name for n in ast.find_all(jinja2.nodes.Name) if n.name != "env"
}
self.document.sub_references = getattr(self.document, "sub_references", set())
cyclic = references.intersection(self.document.sub_references)
if cyclic:
Expand Down
9 changes: 9 additions & 0 deletions tests/test_sphinx/sourcedirs/substitutions_dashed_key/conf.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
extensions = ["myst_parser"]
exclude_patterns = ["_build"]
myst_enable_extensions = ["substitution"]
myst_substitutions = {
"foo-bar": "Foobar",
"plain": "PlainValue",
"circ-a": "{{circ-b}}",
"circ-b": "{{circ-a}}",
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# Title

Dashed key: {{foo-bar}}

Plain key: {{plain}}

Circular: {{circ-a}}
36 changes: 36 additions & 0 deletions tests/test_sphinx/test_sphinx_builds.py
Original file line number Diff line number Diff line change
Expand Up @@ -472,6 +472,42 @@ def test_substitutions_missing(
)


@pytest.mark.sphinx(
buildername="html",
srcdir=os.path.join(SOURCE_DIR, "substitutions_dashed_key"),
freshenv=True,
)
def test_substitutions_dashed_key(
app,
status,
warning,
get_sphinx_app_output,
):
"""
Regression test for
https://github.com/executablebooks/MyST-Parser/issues/1007

A substitution key containing a dash (e.g. ``foo-bar``) must be
looked up as a single key, not parsed by Jinja2 as a subtraction
expression (``foo - bar``, two separate, likely-undefined names).
Plain keys and circular-reference detection must continue to work
as before.
"""
app.build()
assert "build succeeded" in status.getvalue()

warnings = warning.getvalue().strip().splitlines()
assert len(warnings) == 1
assert "circular substitution reference: {'circ-a'}" in warnings[0]

output = get_sphinx_app_output(app, filename="index.html")
assert "Foobar" in output
assert "PlainValue" in output
# The circular substitution should not have rendered a value.
assert "{{circ-a}}" not in output
assert "{{circ-b}}" not in output


@pytest.mark.sphinx(
buildername="gettext", srcdir=os.path.join(SOURCE_DIR, "gettext"), freshenv=True
)
Expand Down