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
5 changes: 5 additions & 0 deletions docs/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,11 @@ See the [Contributing Guide](contributing.md) for details.
provided, rather than rendering them as `key="key"`, so the same source
renders consistently under both the `html` and `xhtml` output formats
(#1501).
* Stop list nesting short of the interpreter's recursion limit, as blockquote
nesting already is, instead of raising `RecursionError` on a deeply nested
list. The markers which no longer nest are kept as text (#1679).
* Fix quadratic-time splitting of a list block whose items span many lines,
which dominated the time taken to render a deeply nested list.

## [3.11.2] - 2026-10-19

Expand Down
25 changes: 17 additions & 8 deletions markdown/blockprocessors.py
Original file line number Diff line number Diff line change
Expand Up @@ -172,8 +172,12 @@ def __init__(self, *args):
self.INDENT_RE = re.compile(r'^(([ ]{%s})+)' % self.tab_length)

def test(self, parent: etree.Element, block: str) -> bool:
# A list calls `parseBlocks` once per level of nesting, so the
# nesting has to stop short of the interpreter's limit. The markers which no
# longer nest are kept as text, as they are for a blockquote.
return block.startswith(' '*self.tab_length) and \
not self.parser.state.isstate('detabbed') and \
not util.nearing_recursion_limit() and \
(parent.tag in self.ITEM_TYPES or
(len(parent) and parent[-1] is not None and
(parent[-1].tag in self.LIST_TYPES)))
Expand Down Expand Up @@ -354,7 +358,9 @@ def __init__(self, parser: BlockParser):
(self.tab_length, self.tab_length * 2 - 1))

def test(self, parent: etree.Element, block: str) -> bool:
return bool(self.RE.match(block))
# As in `ListIndentProcessor`, nesting stops short of the recursion limit.
# `UListProcessor` inherits this method, so unordered lists are covered too.
return bool(self.RE.match(block)) and not util.nearing_recursion_limit()

def run(self, parent: etree.Element, blocks: list[str]) -> None:
# Check for multiple items in one block.
Expand Down Expand Up @@ -417,7 +423,10 @@ def run(self, parent: etree.Element, blocks: list[str]) -> None:

def get_items(self, block: str) -> list[str]:
""" Break a block into list items. """
items = []
# The lines of an item are collected in a list and joined once at the end.
# Appending per line, as in `item = '{}\n{}'.format(item, line)`, copies the
# whole item every time, which is quadratic in the number of lines of an item.
items: list[list[str]] = []
for line in block.split('\n'):
m = self.CHILD_RE.match(line)
if m:
Expand All @@ -428,18 +437,18 @@ def get_items(self, block: str) -> list[str]:
INTEGER_RE = re.compile(r'(\d+)')
self.STARTSWITH = INTEGER_RE.match(m.group(1)).group()
# Append to the list
items.append(m.group(3))
items.append([m.group(3)])
elif self.INDENT_RE.match(line):
# This is an indented (possibly nested) item.
if items[-1].startswith(' '*self.tab_length):
if items[-1][0].startswith(' '*self.tab_length):
# Previous item was indented. Append to that item.
items[-1] = '{}\n{}'.format(items[-1], line)
items[-1].append(line)
else:
items.append(line)
items.append([line])
else:
# This is another line of previous item. Append to that item.
items[-1] = '{}\n{}'.format(items[-1], line)
return items
items[-1].append(line)
return ['\n'.join(item) for item in items]


class UListProcessor(OListProcessor):
Expand Down
82 changes: 82 additions & 0 deletions tests/test_syntax/blocks/test_list_nesting.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
"""
Python Markdown

A Python implementation of John Gruber's Markdown.

Documentation: https://python-markdown.github.io/
GitHub: https://github.com/Python-Markdown/markdown/
PyPI: https://pypi.org/project/Markdown/

Started by Manfred Stienstra (http://www.dwerg.net/).
Maintained for a few years by Yuri Takhteyev (http://www.freewisdom.org).
Currently maintained by Waylan Limberg (https://github.com/waylan),
Dmitry Shachnev (https://github.com/mitya57) and Isaac Muse (https://github.com/facelessuser).

Copyright 2007-2023 The Python Markdown Project (v. 1.7 and later)
Copyright 2004, 2005, 2006 Yuri Takhteyev (v. 0.2-1.6b)
Copyright 2004 Manfred Stienstra (the original version)

License: BSD (see LICENSE.md for details).
"""

import markdown
from markdown.test_tools import TestCase, recursionlimit


class TestListNestingLimit(TestCase):

# A list calls `parseBlocks` once per level of nesting, so the nesting
# has to stop short of the interpreter's limit. The remaining markers are kept as
# text, the same way a blockquote keeps its markers (see `TestBlockquoteBlocks`).

def test_nesting_stops_short_of_the_recursion_limit(self):
# Ten levels of markers is more than the limit below allows to nest.
with recursionlimit(120):
result = markdown.markdown('- ' * 10 + 'x')

self.assertLess(result.count('<ul>'), 10)
self.assertIn('- ', result)

def test_deeply_nested_list_does_not_raise(self):
# Around 1 KB of input, which raised `RecursionError` before the limit was applied.
result = markdown.markdown('- ' * 500 + 'x')

self.assertLess(result.count('<ul>'), 500)
self.assertIn('x', result)

def test_deeply_nested_ordered_list_does_not_raise(self):
result = markdown.markdown('1. ' * 500 + 'x')

self.assertLess(result.count('<ol>'), 500)
self.assertIn('x', result)

def test_deeply_indented_list_does_not_raise(self):
source = ''.join(' ' * i + '- x\n' for i in range(600))
result = markdown.markdown(source)

self.assertLess(result.count('<ul>'), 600)
self.assertEqual(result.count('x'), 600)

def test_nesting_below_the_limit_is_unchanged(self):
self.assertMarkdownRenders(
self.dedent(
"""
- x
- y
- z
"""
),
self.dedent(
"""
<ul>
<li>x<ul>
<li>y<ul>
<li>z</li>
</ul>
</li>
</ul>
</li>
</ul>
"""
)
)
Loading