From 1b2e82c7db876745501ca0e9651d8a7510e72c85 Mon Sep 17 00:00:00 2001 From: Dickson Date: Fri, 6 Nov 2026 14:45:23 -0500 Subject: [PATCH 1/7] =?UTF-8?q?=F0=9F=91=8C=20IMPROVE:=20Buffer=20rendered?= =?UTF-8?q?=20HTML=20fragments=20on=20PyPy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- markdown_it/renderer.py | 133 ++++++++++++++++++------ tests/test_api/test_renderer_results.py | 63 +++++++++++ 2 files changed, 163 insertions(+), 33 deletions(-) create mode 100644 tests/test_api/test_renderer_results.py diff --git a/markdown_it/renderer.py b/markdown_it/renderer.py index 0020f35f..f1b2c0f8 100644 --- a/markdown_it/renderer.py +++ b/markdown_it/renderer.py @@ -10,12 +10,15 @@ class Renderer from collections.abc import Sequence import inspect +import sys from typing import Any, ClassVar, Protocol from .common.utils import MD_TRIM_RE, escapeHtml, mdTrim, unescapeAll from .token import Token from .utils import EnvType, OptionsDict +_IS_PYPY = sys.implementation.name == "pypy" + class RendererProtocol(Protocol): __output__: ClassVar[str] @@ -64,47 +67,74 @@ def __init__(self, parser: Any = None): if not k.startswith(("render", "_")) } - def render( - self, tokens: Sequence[Token], options: OptionsDict, env: EnvType - ) -> str: - """Takes token stream and generates HTML. + if _IS_PYPY: - :param tokens: list on block tokens to render - :param options: params of parser instance - :param env: additional data from parsed input + def render( + self, tokens: Sequence[Token], options: OptionsDict, env: EnvType + ) -> str: + """Takes token stream and generates HTML. - """ - result = "" + :param tokens: list on block tokens to render + :param options: params of parser instance + :param env: additional data from parsed input - for i, token in enumerate(tokens): - if token.type == "inline": - if token.children: - result += self.renderInline(token.children, options, env) - elif token.type in self.rules: - result += self.rules[token.type](tokens, i, options, env) - else: - result += self.renderToken(tokens, i, options, env) + """ + return _render_buffered(self, tokens, options, env, inline=False) - return result + def renderInline( + self, tokens: Sequence[Token], options: OptionsDict, env: EnvType + ) -> str: + """The same as ``render``, but for single token of `inline` type. - def renderInline( - self, tokens: Sequence[Token], options: OptionsDict, env: EnvType - ) -> str: - """The same as ``render``, but for single token of `inline` type. + :param tokens: list on block tokens to render + :param options: params of parser instance + :param env: additional data from parsed input (references, for example) + """ + return _render_buffered(self, tokens, options, env, inline=True) - :param tokens: list on block tokens to render - :param options: params of parser instance - :param env: additional data from parsed input (references, for example) - """ - result = "" + else: - for i, token in enumerate(tokens): - if token.type in self.rules: - result += self.rules[token.type](tokens, i, options, env) - else: - result += self.renderToken(tokens, i, options, env) + def render( + self, tokens: Sequence[Token], options: OptionsDict, env: EnvType + ) -> str: + """Takes token stream and generates HTML. - return result + :param tokens: list on block tokens to render + :param options: params of parser instance + :param env: additional data from parsed input + + """ + result = "" + + for i, token in enumerate(tokens): + if token.type == "inline": + if token.children: + result += self.renderInline(token.children, options, env) + elif token.type in self.rules: + result += self.rules[token.type](tokens, i, options, env) + else: + result += self.renderToken(tokens, i, options, env) + + return result + + def renderInline( + self, tokens: Sequence[Token], options: OptionsDict, env: EnvType + ) -> str: + """The same as ``render``, but for single token of `inline` type. + + :param tokens: list on block tokens to render + :param options: params of parser instance + :param env: additional data from parsed input (references, for example) + """ + result = "" + + for i, token in enumerate(tokens): + if token.type in self.rules: + result += self.rules[token.type](tokens, i, options, env) + else: + result += self.renderToken(tokens, i, options, env) + + return result def renderToken( self, @@ -358,3 +388,40 @@ def html_inline( self, tokens: Sequence[Token], idx: int, options: OptionsDict, env: EnvType ) -> str: return tokens[idx].content + + +def _render_buffered( + self: RendererHTML, + tokens: Sequence[Token], + options: OptionsDict, + env: EnvType, + *, + inline: bool, +) -> str: + """Render PyPy fragments without repeatedly copying the output prefix.""" + # PyPy cannot extend a local string in place like CPython. Collecting + # fragments avoids repeatedly copying the entire rendered prefix. + fragments: list[str] = [] + result = "" + collect = True + for i, token in enumerate(tokens): + if not inline and token.type == "inline": + if not token.children: + continue + fragment = self.renderInline(token.children, options, env) + elif token.type in self.rules: + fragment = self.rules[token.type](tokens, i, options, env) + else: + fragment = self.renderToken(tokens, i, options, env) + + if collect and type(fragment) is str: + fragments.append(fragment) + else: + # Preserve overloaded string addition and raise for an invalid + # result before invoking any subsequent rendering rule. + if collect: + result = "".join(fragments) + collect = False + result += fragment + + return "".join(fragments) if collect else result diff --git a/tests/test_api/test_renderer_results.py b/tests/test_api/test_renderer_results.py new file mode 100644 index 00000000..fd6228f5 --- /dev/null +++ b/tests/test_api/test_renderer_results.py @@ -0,0 +1,63 @@ +import pickle +from types import MethodType + +import pytest + +from markdown_it import MarkdownIt +from markdown_it.renderer import RendererHTML +from markdown_it.token import Token + + +@pytest.mark.parametrize("method", ["render", "renderInline"]) +def test_render_rule_string_subclass(method): + class Wrapped(str): + def __radd__(self, other): + return Wrapped(other + "<" + str(self) + ">") + + def __add__(self, other): + return Wrapped(str(self) + "{" + str(other) + "}") + + renderer = RendererHTML() + values = ["a", Wrapped("b"), "c"] + renderer.rules["custom"] = MethodType( + lambda self, tokens, idx, opts, env: values[idx], renderer + ) + tokens = [Token("custom", "", 0) for _ in values] + result = getattr(renderer, method)(tokens, MarkdownIt().options, {}) + assert type(result) is Wrapped + assert result == "a{c}" + + +@pytest.mark.parametrize("method", ["render", "renderInline"]) +def test_render_rule_invalid_result_stops_callbacks(method): + renderer = RendererHTML() + calls = [] + + def rule(self, tokens, idx, opts, env): + calls.append(idx) + return "first" if idx == 0 else None + + renderer.rules["custom"] = MethodType(rule, renderer) + tokens = [Token("custom", "", 0) for _ in range(3)] + with pytest.raises(TypeError): + getattr(renderer, method)(tokens, MarkdownIt().options, {}) + assert calls == [0, 1] + + +def test_render_private_methods_are_not_rules(): + class CustomRenderer(RendererHTML): + def _custom(self, tokens, idx, options, env): + return "private method" + + renderer = CustomRenderer() + assert "_custom" not in renderer.rules + assert all(not key.startswith("_") for key in renderer.rules) + + +@pytest.mark.parametrize("protocol", range(pickle.HIGHEST_PROTOCOL + 1)) +def test_renderer_pickle(protocol): + renderer = RendererHTML() + restored = pickle.loads(pickle.dumps(renderer, protocol)) + assert sorted(restored.rules) == sorted(renderer.rules) + tokens = [Token("text", "", 0, content="a&b")] + assert restored.renderInline(tokens, MarkdownIt().options, {}) == "a&b" From fa54cf383204fa93bd83647054967d8da4c1d12e Mon Sep 17 00:00:00 2001 From: Dickson Date: Fri, 6 Nov 2026 15:00:13 -0500 Subject: [PATCH 2/7] =?UTF-8?q?=F0=9F=90=9B=20FIX:=20Preserve=20PyPy=20ren?= =?UTF-8?q?dered=20prefix=20identity?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Retain the initial empty operand so buffered rendering preserves string identity visible to custom reflected addition and single-fragment callers. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + markdown_it/renderer.py | 5 ++- tests/test_api/test_renderer_results.py | 42 +++++++++++++++++++++++++ 3 files changed, 47 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 040ff138..008243b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased +* šŸ‘Œ Speed up rendering on PyPy by collecting rendered fragments in a list instead of repeated string concatenation * ✨ Add `--enable-tables` to the CLI for file, standard input and interactive parsing in [#422](https://github.com/executablebooks/markdown-it-py/pull/422) * šŸ› Fix CLI interactive mode joining input lines with an extra newline, which split every line into its own paragraph and broke hard line breaks, in [#172](https://github.com/executablebooks/markdown-it-py/issues/172) * šŸ› Fix trimming and splitting with the Python whitespace set instead of the CommonMark one, which dropped U+001C–U+001F and U+0085 from paragraphs, headings, table cells and fence info strings and let distinct reference labels resolve each other, in [#418](https://github.com/executablebooks/markdown-it-py/pull/418), thanks to [@Nexory](https://github.com/Nexory) diff --git a/markdown_it/renderer.py b/markdown_it/renderer.py index f1b2c0f8..46c14991 100644 --- a/markdown_it/renderer.py +++ b/markdown_it/renderer.py @@ -401,7 +401,10 @@ def _render_buffered( """Render PyPy fragments without repeatedly copying the output prefix.""" # PyPy cannot extend a local string in place like CPython. Collecting # fragments avoids repeatedly copying the entire rendered prefix. - fragments: list[str] = [] + # Keep the initial empty operand: joining one fragment would return that + # same object, whereas PyPy string addition creates a new string. A custom + # reflected addition can observe the identity of the accumulated prefix. + fragments: list[str] = [""] result = "" collect = True for i, token in enumerate(tokens): diff --git a/tests/test_api/test_renderer_results.py b/tests/test_api/test_renderer_results.py index fd6228f5..c226a47e 100644 --- a/tests/test_api/test_renderer_results.py +++ b/tests/test_api/test_renderer_results.py @@ -61,3 +61,45 @@ def test_renderer_pickle(protocol): assert sorted(restored.rules) == sorted(renderer.rules) tokens = [Token("text", "", 0, content="a&b")] assert restored.renderInline(tokens, MarkdownIt().options, {}) == "a&b" + + +@pytest.mark.parametrize("method", ["render", "renderInline"]) +@pytest.mark.parametrize("empty_before", [False, True]) +@pytest.mark.parametrize("empty_after", [False, True]) +def test_render_rule_prefix_identity(method, empty_before, empty_after): + source = "".join(["original", " fragment" * 10]) + + class InspectPrefix(str): + def __radd__(self, other): + return "original object" if other is source else "new object" + + values = ([""] if empty_before else []) + [source] + if empty_after: + values.append("") + values.append(InspectPrefix("probe")) + expected = "" + for value in values: + expected += value + + renderer = RendererHTML() + renderer.rules["custom"] = MethodType( + lambda self, tokens, idx, opts, env: values[idx], renderer + ) + tokens = [Token("custom", "", 0) for _ in values] + assert getattr(renderer, method)(tokens, MarkdownIt().options, {}) == expected + + +@pytest.mark.parametrize("method", ["render", "renderInline"]) +def test_render_single_fragment_identity(method): + source = "".join(["original", " fragment" * 10]) + expected = "" + expected += source + renderer = RendererHTML() + renderer.rules["custom"] = MethodType( + lambda self, tokens, idx, opts, env: source, renderer + ) + result = getattr(renderer, method)( + [Token("custom", "", 0)], MarkdownIt().options, {} + ) + assert result == expected + assert (result is source) == (expected is source) From 943735aeb053f47c3dd138524b4ba5f98f908cdc Mon Sep 17 00:00:00 2001 From: Dickson Date: Fri, 6 Nov 2026 17:25:47 -0500 Subject: [PATCH 3/7] =?UTF-8?q?=F0=9F=93=9A=20Link=20the=20changelog=20ent?= =?UTF-8?q?ry=20to=20#458?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 008243b8..3cbd0426 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## Unreleased -* šŸ‘Œ Speed up rendering on PyPy by collecting rendered fragments in a list instead of repeated string concatenation +* šŸ‘Œ Speed up rendering on PyPy by collecting rendered fragments in a list instead of repeated string concatenation in [#458](https://github.com/executablebooks/markdown-it-py/pull/458) * ✨ Add `--enable-tables` to the CLI for file, standard input and interactive parsing in [#422](https://github.com/executablebooks/markdown-it-py/pull/422) * šŸ› Fix CLI interactive mode joining input lines with an extra newline, which split every line into its own paragraph and broke hard line breaks, in [#172](https://github.com/executablebooks/markdown-it-py/issues/172) * šŸ› Fix trimming and splitting with the Python whitespace set instead of the CommonMark one, which dropped U+001C–U+001F and U+0085 from paragraphs, headings, table cells and fence info strings and let distinct reference labels resolve each other, in [#418](https://github.com/executablebooks/markdown-it-py/pull/418), thanks to [@Nexory](https://github.com/Nexory) From b22190f556822670c3fd94a692d94c513e9e053d Mon Sep 17 00:00:00 2001 From: Chris Sewell Date: Mon, 9 Nov 2026 20:22:14 +0100 Subject: [PATCH 4/7] =?UTF-8?q?=F0=9F=94=A7=20MAINTAIN:=20drop=20changelog?= =?UTF-8?q?=20entry=20(written=20at=20release)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 1 - 1 file changed, 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3cbd0426..040ff138 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,6 @@ ## Unreleased -* šŸ‘Œ Speed up rendering on PyPy by collecting rendered fragments in a list instead of repeated string concatenation in [#458](https://github.com/executablebooks/markdown-it-py/pull/458) * ✨ Add `--enable-tables` to the CLI for file, standard input and interactive parsing in [#422](https://github.com/executablebooks/markdown-it-py/pull/422) * šŸ› Fix CLI interactive mode joining input lines with an extra newline, which split every line into its own paragraph and broke hard line breaks, in [#172](https://github.com/executablebooks/markdown-it-py/issues/172) * šŸ› Fix trimming and splitting with the Python whitespace set instead of the CommonMark one, which dropped U+001C–U+001F and U+0085 from paragraphs, headings, table cells and fence info strings and let distinct reference labels resolve each other, in [#418](https://github.com/executablebooks/markdown-it-py/pull/418), thanks to [@Nexory](https://github.com/Nexory) From 05b47c539cf558fbf099e080ad638f6df9fbe1cd Mon Sep 17 00:00:00 2001 From: Chris Sewell Date: Mon, 9 Nov 2026 20:23:00 +0100 Subject: [PATCH 5/7] =?UTF-8?q?=F0=9F=A7=AA=20TEST:=20drop=20the=20two=20o?= =?UTF-8?q?bject-identity=20renderer=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit test_render_rule_prefix_identity and test_render_single_fragment_identity pin when the interpreter returns the same str object from "" + s or a join, which is an implementation detail rather than a user contract: a future CPython or PyPy could change it with no visible effect. --- tests/test_api/test_renderer_results.py | 42 ------------------------- 1 file changed, 42 deletions(-) diff --git a/tests/test_api/test_renderer_results.py b/tests/test_api/test_renderer_results.py index c226a47e..fd6228f5 100644 --- a/tests/test_api/test_renderer_results.py +++ b/tests/test_api/test_renderer_results.py @@ -61,45 +61,3 @@ def test_renderer_pickle(protocol): assert sorted(restored.rules) == sorted(renderer.rules) tokens = [Token("text", "", 0, content="a&b")] assert restored.renderInline(tokens, MarkdownIt().options, {}) == "a&b" - - -@pytest.mark.parametrize("method", ["render", "renderInline"]) -@pytest.mark.parametrize("empty_before", [False, True]) -@pytest.mark.parametrize("empty_after", [False, True]) -def test_render_rule_prefix_identity(method, empty_before, empty_after): - source = "".join(["original", " fragment" * 10]) - - class InspectPrefix(str): - def __radd__(self, other): - return "original object" if other is source else "new object" - - values = ([""] if empty_before else []) + [source] - if empty_after: - values.append("") - values.append(InspectPrefix("probe")) - expected = "" - for value in values: - expected += value - - renderer = RendererHTML() - renderer.rules["custom"] = MethodType( - lambda self, tokens, idx, opts, env: values[idx], renderer - ) - tokens = [Token("custom", "", 0) for _ in values] - assert getattr(renderer, method)(tokens, MarkdownIt().options, {}) == expected - - -@pytest.mark.parametrize("method", ["render", "renderInline"]) -def test_render_single_fragment_identity(method): - source = "".join(["original", " fragment" * 10]) - expected = "" - expected += source - renderer = RendererHTML() - renderer.rules["custom"] = MethodType( - lambda self, tokens, idx, opts, env: source, renderer - ) - result = getattr(renderer, method)( - [Token("custom", "", 0)], MarkdownIt().options, {} - ) - assert result == expected - assert (result is source) == (expected is source) From 9df6370e0a72a0f7234964fd56dba5f0fe6918f5 Mon Sep 17 00:00:00 2001 From: Chris Sewell Date: Mon, 9 Nov 2026 23:14:11 +0100 Subject: [PATCH 6/7] =?UTF-8?q?=F0=9F=91=8C=20IMPROVE:=20Join=20rendered?= =?UTF-8?q?=20fragments=20on=20every=20interpreter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the PyPy-only branch and its _render_buffered helper with a plain out.append(...) / "".join(out) in render, renderInline and renderInlineAsText, the same on every interpreter. Repeated str += is quadratic wherever the interpreter cannot extend a string in place: PyPy, GraalPy and free-threaded CPython 3.13 as well, which the PyPy check missed. CPython timings are unchanged. A rule that returns a str subclass (e.g. markupsafe.Markup) now comes back as a plain str, as rules are typed to return str. The renderer tests now pin that the fragments are joined into a plain str and that an invalid rule result raises TypeError; the pickle and private-method tests are dropped as outside this change. --- markdown_it/renderer.py | 154 +++++++----------------- tests/test_api/test_renderer_results.py | 64 ++++------ 2 files changed, 65 insertions(+), 153 deletions(-) diff --git a/markdown_it/renderer.py b/markdown_it/renderer.py index 28bd499b..189b809a 100644 --- a/markdown_it/renderer.py +++ b/markdown_it/renderer.py @@ -10,15 +10,12 @@ class Renderer from collections.abc import Sequence import inspect -import sys from typing import Any, ClassVar, Protocol from .common.utils import MD_TRIM_RE, escapeHtml, mdTrim, unescapeAll from .token import Token from .utils import EnvType, OptionsDict -_IS_PYPY = sys.implementation.name == "pypy" - class RendererProtocol(Protocol): """The interface a renderer passed to ``MarkdownIt(renderer_cls=...)`` must have. @@ -78,74 +75,51 @@ def __init__(self, parser: Any = None): if not k.startswith(("render", "_")) } - if _IS_PYPY: - - def render( - self, tokens: Sequence[Token], options: OptionsDict, env: EnvType - ) -> str: - """Takes token stream and generates HTML. - - :param tokens: list on block tokens to render - :param options: params of parser instance - :param env: additional data from parsed input - - """ - return _render_buffered(self, tokens, options, env, inline=False) - - def renderInline( - self, tokens: Sequence[Token], options: OptionsDict, env: EnvType - ) -> str: - """The same as ``render``, but for single token of `inline` type. - - :param tokens: list on block tokens to render - :param options: params of parser instance - :param env: additional data from parsed input (references, for example) - """ - return _render_buffered(self, tokens, options, env, inline=True) - - else: - - def render( - self, tokens: Sequence[Token], options: OptionsDict, env: EnvType - ) -> str: - """Takes token stream and generates HTML. - - :param tokens: list on block tokens to render - :param options: params of parser instance - :param env: additional data from parsed input + def render( + self, tokens: Sequence[Token], options: OptionsDict, env: EnvType + ) -> str: + """Takes token stream and generates HTML. - """ - result = "" + :param tokens: list on block tokens to render + :param options: params of parser instance + :param env: additional data from parsed input - for i, token in enumerate(tokens): - if token.type == "inline": - if token.children: - result += self.renderInline(token.children, options, env) - elif token.type in self.rules: - result += self.rules[token.type](tokens, i, options, env) - else: - result += self.renderToken(tokens, i, options, env) + """ + # 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: + out.append(self.renderInline(token.children, options, env)) + elif token.type in self.rules: + out.append(self.rules[token.type](tokens, i, options, env)) + else: + out.append(self.renderToken(tokens, i, options, env)) - return result + return "".join(out) - def renderInline( - self, tokens: Sequence[Token], options: OptionsDict, env: EnvType - ) -> str: - """The same as ``render``, but for single token of `inline` type. + def renderInline( + self, tokens: Sequence[Token], options: OptionsDict, env: EnvType + ) -> str: + """The same as ``render``, but for single token of `inline` type. - :param tokens: list on block tokens to render - :param options: params of parser instance - :param env: additional data from parsed input (references, for example) - """ - result = "" + :param tokens: list on block tokens to render + :param options: params of parser instance + :param env: additional data from parsed input (references, for example) + """ + out: list[str] = [] - for i, token in enumerate(tokens): - if token.type in self.rules: - result += self.rules[token.type](tokens, i, options, env) - else: - result += self.renderToken(tokens, i, options, env) + for i, token in enumerate(tokens): + if token.type in self.rules: + out.append(self.rules[token.type](tokens, i, options, env)) + else: + out.append(self.renderToken(tokens, i, options, env)) - return result + return "".join(out) def renderToken( self, @@ -235,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 == "text": - 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) ################################################### @@ -399,43 +373,3 @@ def html_inline( self, tokens: Sequence[Token], idx: int, options: OptionsDict, env: EnvType ) -> str: return tokens[idx].content - - -def _render_buffered( - self: RendererHTML, - tokens: Sequence[Token], - options: OptionsDict, - env: EnvType, - *, - inline: bool, -) -> str: - """Render PyPy fragments without repeatedly copying the output prefix.""" - # PyPy cannot extend a local string in place like CPython. Collecting - # fragments avoids repeatedly copying the entire rendered prefix. - # Keep the initial empty operand: joining one fragment would return that - # same object, whereas PyPy string addition creates a new string. A custom - # reflected addition can observe the identity of the accumulated prefix. - fragments: list[str] = [""] - result = "" - collect = True - for i, token in enumerate(tokens): - if not inline and token.type == "inline": - if not token.children: - continue - fragment = self.renderInline(token.children, options, env) - elif token.type in self.rules: - fragment = self.rules[token.type](tokens, i, options, env) - else: - fragment = self.renderToken(tokens, i, options, env) - - if collect and type(fragment) is str: - fragments.append(fragment) - else: - # Preserve overloaded string addition and raise for an invalid - # result before invoking any subsequent rendering rule. - if collect: - result = "".join(fragments) - collect = False - result += fragment - - return "".join(fragments) if collect else result diff --git a/tests/test_api/test_renderer_results.py b/tests/test_api/test_renderer_results.py index fd6228f5..28c928b0 100644 --- a/tests/test_api/test_renderer_results.py +++ b/tests/test_api/test_renderer_results.py @@ -1,4 +1,5 @@ -import pickle +"""How the renderer combines the strings returned by its rules.""" + from types import MethodType import pytest @@ -8,56 +9,33 @@ from markdown_it.token import Token -@pytest.mark.parametrize("method", ["render", "renderInline"]) -def test_render_rule_string_subclass(method): - class Wrapped(str): - def __radd__(self, other): - return Wrapped(other + "<" + str(self) + ">") - - def __add__(self, other): - return Wrapped(str(self) + "{" + str(other) + "}") - +def _renderer_with(values): + """A renderer whose ``custom`` rule returns ``values[idx]`` for token ``idx``.""" renderer = RendererHTML() - values = ["a", Wrapped("b"), "c"] renderer.rules["custom"] = MethodType( lambda self, tokens, idx, opts, env: values[idx], renderer ) - tokens = [Token("custom", "", 0) for _ in values] - result = getattr(renderer, method)(tokens, MarkdownIt().options, {}) - assert type(result) is Wrapped - assert result == "a{c}" + return renderer, [Token("custom", "", 0) for _ in values] @pytest.mark.parametrize("method", ["render", "renderInline"]) -def test_render_rule_invalid_result_stops_callbacks(method): - renderer = RendererHTML() - calls = [] - - def rule(self, tokens, idx, opts, env): - calls.append(idx) - return "first" if idx == 0 else None - - renderer.rules["custom"] = MethodType(rule, renderer) - tokens = [Token("custom", "", 0) for _ in range(3)] - with pytest.raises(TypeError): - getattr(renderer, method)(tokens, MarkdownIt().options, {}) - assert calls == [0, 1] - +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). + class Wrapped(str): + def __radd__(self, other): + raise AssertionError("not called") -def test_render_private_methods_are_not_rules(): - class CustomRenderer(RendererHTML): - def _custom(self, tokens, idx, options, env): - return "private method" + __add__ = __radd__ - renderer = CustomRenderer() - assert "_custom" not in renderer.rules - assert all(not key.startswith("_") for key in renderer.rules) + 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("protocol", range(pickle.HIGHEST_PROTOCOL + 1)) -def test_renderer_pickle(protocol): - renderer = RendererHTML() - restored = pickle.loads(pickle.dumps(renderer, protocol)) - assert sorted(restored.rules) == sorted(renderer.rules) - tokens = [Token("text", "", 0, content="a&b")] - assert restored.renderInline(tokens, MarkdownIt().options, {}) == "a&b" +@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, {}) From 6f15357ac52ae04fda6418becac70dbf0dd8e6a6 Mon Sep 17 00:00:00 2001 From: Chris Sewell Date: Mon, 9 Nov 2026 23:35:52 +0100 Subject: [PATCH 7/7] =?UTF-8?q?=F0=9F=A7=AA=20TEST:=20Pin=20the=20join=20i?= =?UTF-8?q?n=20renderInlineAsText?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit renderInlineAsText (image alt text) collects its fragments with a join too, but no test failed if it went back to str +=. Add one: text tokens around a softbreak, one of them a str subclass whose addition raises, come back as a plain str in order. The Wrapped helper moves to module level to share it. --- tests/test_api/test_renderer_results.py | 29 ++++++++++++++++++++----- 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/tests/test_api/test_renderer_results.py b/tests/test_api/test_renderer_results.py index 28c928b0..d19d627e 100644 --- a/tests/test_api/test_renderer_results.py +++ b/tests/test_api/test_renderer_results.py @@ -18,16 +18,19 @@ def _renderer_with(values): 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). - class Wrapped(str): - def __radd__(self, other): - raise AssertionError("not called") - - __add__ = __radd__ - renderer, tokens = _renderer_with(["a", Wrapped("b"), "c"]) result = getattr(renderer, method)(tokens, MarkdownIt().options, {}) assert type(result) is str @@ -39,3 +42,17 @@ 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"