diff --git a/markdown_it/renderer.py b/markdown_it/renderer.py index 37f6a1d1..6ccdee9b 100644 --- a/markdown_it/renderer.py +++ b/markdown_it/renderer.py @@ -85,18 +85,22 @@ def render( :param env: additional data from parsed input """ - result = "" + # Collect the fragments and join them once: repeated ``str +=`` is only + # linear on interpreters that can extend a string in place (the default + # CPython build), and quadratic in the output size elsewhere (e.g. PyPy, + # GraalPy and free-threaded CPython 3.13). + out: list[str] = [] for i, token in enumerate(tokens): if token.type == "inline": if token.children: - result += self.renderInline(token.children, options, env) + out.append(self.renderInline(token.children, options, env)) elif token.type in self.rules: - result += self.rules[token.type](tokens, i, options, env) + out.append(self.rules[token.type](tokens, i, options, env)) else: - result += self.renderToken(tokens, i, options, env) + out.append(self.renderToken(tokens, i, options, env)) - return result + return "".join(out) def renderInline( self, tokens: Sequence[Token], options: OptionsDict, env: EnvType @@ -107,15 +111,15 @@ def renderInline( :param options: params of parser instance :param env: additional data from parsed input (references, for example) """ - result = "" + out: list[str] = [] for i, token in enumerate(tokens): if token.type in self.rules: - result += self.rules[token.type](tokens, i, options, env) + out.append(self.rules[token.type](tokens, i, options, env)) else: - result += self.renderToken(tokens, i, options, env) + out.append(self.renderToken(tokens, i, options, env)) - return result + return "".join(out) def renderToken( self, @@ -205,20 +209,20 @@ def renderInlineAsText( :param options: params of parser instance :param env: additional data from parsed input """ - result = "" + out: list[str] = [] for token in tokens or []: if token.type in ("text", "code_inline"): - result += token.content + out.append(token.content) elif token.type == "image": if token.children: - result += self.renderInlineAsText(token.children, options, env) + out.append(self.renderInlineAsText(token.children, options, env)) elif token.type in ("html_inline", "html_block"): - result += token.content + out.append(token.content) elif token.type in ("softbreak", "hardbreak"): - result += "\n" + out.append("\n") - return result + return "".join(out) ################################################### diff --git a/tests/test_api/test_renderer_results.py b/tests/test_api/test_renderer_results.py new file mode 100644 index 00000000..d19d627e --- /dev/null +++ b/tests/test_api/test_renderer_results.py @@ -0,0 +1,58 @@ +"""How the renderer combines the strings returned by its rules.""" + +from types import MethodType + +import pytest + +from markdown_it import MarkdownIt +from markdown_it.renderer import RendererHTML +from markdown_it.token import Token + + +def _renderer_with(values): + """A renderer whose ``custom`` rule returns ``values[idx]`` for token ``idx``.""" + renderer = RendererHTML() + renderer.rules["custom"] = MethodType( + lambda self, tokens, idx, opts, env: values[idx], renderer + ) + return renderer, [Token("custom", "", 0) for _ in values] + + +class Wrapped(str): + """A ``str`` subclass whose overloaded addition must not be used.""" + + def __radd__(self, other): + raise AssertionError("not called") + + __add__ = __radd__ + + +@pytest.mark.parametrize("method", ["render", "renderInline"]) +def test_render_rule_results_are_joined_into_a_plain_str(method): + # The fragments are joined in order, and the result is a plain ``str`` + # even when a rule returns a ``str`` subclass (overloaded addition is not used). + renderer, tokens = _renderer_with(["a", Wrapped("b"), "c"]) + result = getattr(renderer, method)(tokens, MarkdownIt().options, {}) + assert type(result) is str + assert result == "abc" + + +@pytest.mark.parametrize("method", ["render", "renderInline"]) +def test_render_rule_invalid_result_raises(method): + renderer, tokens = _renderer_with(["first", None, "third"]) + with pytest.raises(TypeError): + getattr(renderer, method)(tokens, MarkdownIt().options, {}) + + +def test_render_inline_as_text_is_joined_into_a_plain_str(): + # Image ``alt`` text is collected the same way: joined in order into a plain + # ``str``, without using a ``str`` subclass's overloaded addition. + def text(content): + token = Token("text", "", 0) + token.content = content + return token + + tokens = [text("y"), Token("softbreak", "br", 0), text(Wrapped("x"))] + result = RendererHTML().renderInlineAsText(tokens, MarkdownIt().options, {}) + assert type(result) is str + assert result == "y\nx"