Skip to content

Fix quadratic processing time in fenced_code preprocessor (up to ~10x faster) - #1660

Merged
waylan merged 4 commits into
Python-Markdown:masterfrom
deadstrobe5:fenced-code-linear
Nov 9, 2026
Merged

waylan merged 4 commits into
Python-Markdown:masterfrom
deadstrobe5:fenced-code-linear

Conversation

@deadstrobe5

@deadstrobe5 deadstrobe5 commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

Description

The FencedBlockPreprocessor rebuilt the whole document text after every fenced code block it stashed (text = f'{text[:m.start()]}\n{placeholder}\n{text[m.end():]}'), so its cost grew with document size × number of blocks. This collects the output in pieces and joins them once.

Matching is unchanged: the search now resumes at m.end() in the original text instead of just after the placeholder in the rewritten text. The two differ only by the inserted \n, and since the fence pattern ends in [ ]*$, text[m.end()] is always \n or the end of the text, so no fence can begin at that position either way.

The project's own docs/*.md concatenated (250 KB, ~220 fenced blocks), and the same text ×4 (1 MB). Python 3.12, Apple M5, 7 alternating processes per side:

Case Before After
preprocessor alone, 250 KB 10.6 ms 1.95 ms (5.4x)
preprocessor alone, 1 MB 79.4 ms 8.2 ms (9.7x)
markdown(text, extensions=['extra']), 250 KB 81.9 ms 73.8 ms (~10%)
markdown(text, extensions=['extra']), 1 MB 371 ms 304 ms (~18%)
extra on a 10 KB document 2.40 ms 2.37 ms (unchanged)

Re-measured on CPython 3.14.7 (7 alternating processes per side, median): the docs concatenated with the extra extensions went from 89.3 ms to 78.7 ms (1.14x).

Output is byte-identical to the current code (same SHA-256) for 169 files (all tests/**/*.txt fixtures and docs/**/*.md) × 4 extension sets, plus 12,000 randomized documents with fence edge cases (unterminated fences, invalid attribute lists, missing trailing newline, fences inside blockquotes and lists) under fenced_code, fenced_code + attr_list, and extra. python -m unittest discover tests: 1127 OK; flake8 clean on the changed file. No new test, since the output does not change and the existing fenced_code tests cover the paths touched.

Sorry for dropping the template the first time.

AI Assistance Disclosure

  • No AI tools were used in preparing this PR.

  • AI tools were used and I have disclosed which ones, and have fully reviewed and verified their output.

    AI tools were use to assist with the following:

    • Code
    • Code Comments
    • Documentation
    • Changelog Entry
    • Commit Message
    • The Description Above
    • Other: the benchmark and the differential check (Claude Code)

Checklist

@waylan

waylan commented Oct 19, 2026

Copy link
Copy Markdown
Member

You deleted the PR template. Please provide the requested information or this issue will be closed.

@waylan waylan added the more-info-needed More information needs to be provided. label Oct 19, 2026
@deadstrobe5

Copy link
Copy Markdown
Contributor Author

Filled in the template, sorry about that.

@waylan waylan left a comment •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The code looks good. I do find it curious that no tests are provided. Are we certain that the existing tests properly cover this?

Also, there is a merge conflict to resolve with the changelog.

@waylan waylan added needs-review Needs to be reviewed and/or approved. and removed more-info-needed More information needs to be provided. labels Nov 9, 2026
Cover text before, between and after blocks, a block that opens on the
line right after another one closes, a block that ends the document
without a trailing newline, and fifty blocks in one document.
@deadstrobe5

Copy link
Copy Markdown
Contributor Author

Fair question. I checked it by mutating the new loop six ways (dropping the newline before or after the placeholder, dropping the text after the last block, resuming one character late, stopping after the first block, and not advancing the slice start): the existing test_fenced_code.py fails on every one, so the current tests do exercise this path.

Still, nothing in there says it directly, so I added four tests in db5968a: text before, between and after two blocks, a block that opens on the line right after another one closes, a block that ends the document without a trailing newline, and fifty blocks in one document. They pass on master as well, since the output is unchanged.

The changelog conflict is fixed too (merged master, entry kept under Unreleased / Fixed).

@waylan waylan left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Thanks for doing the work on this.

@waylan
waylan merged commit 86d1e46 into Python-Markdown:master Nov 9, 2026
14 checks passed
@waylan waylan added approved The pull request is ready to be merged. and removed needs-review Needs to be reviewed and/or approved. labels Nov 9, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

approved The pull request is ready to be merged.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants