From e686a5d50b40e53a1d86be2c4d1b6482f134f232 Mon Sep 17 00:00:00 2001 From: Kuxin Date: Sat, 3 Oct 2026 19:49:01 +0800 Subject: [PATCH 1/2] fix(rss): isolate per-conversion markdown options RssConverter stored per-call Markdown options on the converter instance (self._kwargs). When the same MarkItDown instance converted two feeds concurrently, one call could overwrite options such as keep_data_uris while the other call was still rendering, so both outputs could use whichever options were written last. Pass Markdown options explicitly through the RSS/Atom rendering path (_parse_rss_type / _parse_atom_type / _parse_content) instead of stashing them on the converter, so each conversion renders with its own options. Rebased onto current main, which had moved the tests into test_rss.py; the concurrency regression test is added there. Verified the test fails on the pre-fix code (keep_data_uris=True result is clobbered by the False call) and passes after the fix. Validation: - pytest packages/markitdown/tests/test_rss.py -q -> 165 passed --- .../markitdown/converters/_rss_converter.py | 44 ++++++++++++++----- packages/markitdown/tests/test_rss.py | 35 +++++++++++++++ 2 files changed, 69 insertions(+), 10 deletions(-) diff --git a/packages/markitdown/src/markitdown/converters/_rss_converter.py b/packages/markitdown/src/markitdown/converters/_rss_converter.py index ce768b8473..c66ef971d9 100644 --- a/packages/markitdown/src/markitdown/converters/_rss_converter.py +++ b/packages/markitdown/src/markitdown/converters/_rss_converter.py @@ -86,7 +86,6 @@ class RssConverter(DocumentConverter): def __init__(self): super().__init__() - self._kwargs = {} def accepts( self, @@ -147,20 +146,23 @@ def convert( # Pop our own keyword before forwarding the rest to markdownify. # strict=True raises RecursionError instead of falling back to plain text. strict: bool = kwargs.pop("strict", False) - self._kwargs = kwargs doc = minidom.parse(file_stream) doc.documentURI = stream_info.url or kwargs.get("url") feed_type = self._feed_type(doc) if feed_type == "rss": - return self._parse_rss_type(doc, strict=strict) + return self._parse_rss_type(doc, strict=strict, markdown_options=kwargs) elif feed_type == "atom": - return self._parse_atom_type(doc, strict=strict) + return self._parse_atom_type(doc, strict=strict, markdown_options=kwargs) else: raise ValueError("Unknown feed type") def _parse_atom_type( - self, doc: Document, *, strict: bool = False + self, + doc: Document, + *, + strict: bool = False, + markdown_options: dict[str, Any] | None = None, ) -> DocumentConverterResult: """Parse the type of an Atom feed. @@ -191,6 +193,7 @@ def _parse_atom_type( is_markup=is_markup, base_url=self._get_field_base_url(entry, tag_name), strict=strict, + markdown_options=markdown_options, ) for value, is_markup, tag_name in ( (entry_summary, summary_is_markup, "summary"), @@ -242,14 +245,25 @@ def _get_atom_content( return text.strip(), False def _render_atom_content( - self, value: str, *, is_markup: bool, base_url: str = "", strict: bool = False + self, + value: str, + *, + is_markup: bool, + base_url: str = "", + strict: bool = False, + markdown_options: dict[str, Any] | None = None, ) -> str: """Render one Atom summary or content value as markdown.""" if not is_markup: # Plain text is returned verbatim: routing it through the HTML # parser drops tag-shaped text such as ```` entirely. return value - return self._parse_content(value, base_url=base_url, strict=strict) + return self._parse_content( + value, + base_url=base_url, + strict=strict, + markdown_options=markdown_options, + ) def _get_flattened_text( self, element: Element, tag_name: str, *, atom_text: bool = False @@ -282,7 +296,11 @@ def _get_flattened_text( return " ".join(part for part in parts if part) or None def _parse_rss_type( - self, doc: Document, *, strict: bool = False + self, + doc: Document, + *, + strict: bool = False, + markdown_options: dict[str, Any] | None = None, ) -> DocumentConverterResult: """Parse the type of an RSS feed. @@ -318,6 +336,7 @@ def _parse_rss_type( value, base_url=self._get_field_base_url(item, tag_name), strict=strict, + markdown_options=markdown_options, ) for value, tag_name in ( (description, "description"), @@ -336,14 +355,19 @@ def _parse_rss_type( ) def _parse_content( - self, content: str, *, base_url: str = "", strict: bool = False + self, + content: str, + *, + base_url: str = "", + strict: bool = False, + markdown_options: dict[str, Any] | None = None, ) -> str: """Parse the content of an RSS feed item""" try: # using bs4 because many RSS feeds have HTML-styled content soup = BeautifulSoup(content, "html.parser") self._resolve_content_links(soup, base_url) - return _CustomMarkdownify(**self._kwargs).convert_soup(soup) + return _CustomMarkdownify(**(markdown_options or {})).convert_soup(soup) except RecursionError: if strict: raise diff --git a/packages/markitdown/tests/test_rss.py b/packages/markitdown/tests/test_rss.py index 89d2cbb796..455822a58a 100644 --- a/packages/markitdown/tests/test_rss.py +++ b/packages/markitdown/tests/test_rss.py @@ -2,6 +2,8 @@ import io import sys +import threading +from concurrent.futures import ThreadPoolExecutor import pytest @@ -81,6 +83,39 @@ def test_atom_without_namespace_is_still_supported() -> None: assert "Entry content." in result.markdown +class _SynchronizedRssConverter(RssConverter): + """Pause both conversions after their call-specific options are received.""" + + def __init__(self) -> None: + super().__init__() + self._barrier = threading.Barrier(2) + + def _parse_content(self, content: str, **kwargs) -> str: + self._barrier.wait(timeout=5) + return super()._parse_content(content, **kwargs) + + +def test_concurrent_conversions_keep_call_specific_markdown_options() -> None: + embedded = "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///w==" + feed = _feed_with_body("atom-content", f'Pixel') + converter = _SynchronizedRssConverter() + stream_info = StreamInfo(extension=".atom") + + def convert(keep_data_uris: bool) -> str: + return converter.convert( + io.BytesIO(feed), + stream_info, + keep_data_uris=keep_data_uris, + ).markdown + + with ThreadPoolExecutor(max_workers=2) as pool: + kept = pool.submit(convert, True) + truncated = pool.submit(convert, False) + + assert f"![Pixel]({embedded})" in kept.result() + assert "![Pixel](data:image/gif;base64...)" in truncated.result() + + def test_atom_ignores_elements_from_other_namespaces() -> None: feed = b""" Unrelated title From b32b1d7a5ef6d57b2474acc9f79d9baffb711002 Mon Sep 17 00:00:00 2001 From: Kuxin Date: Tue, 6 Oct 2026 22:38:23 +0800 Subject: [PATCH 2/2] test(rss): assert concurrent conversions keep every call-specific option The concurrent-options regression test only pinned keep_data_uris, so it would still pass if only that option were threaded while another kept leaking. Add heading_style (atx vs underlined) and bullets ("+" vs "*") as additional dimensions and assert each conversion keeps all of its own options. Uses valid markdownify values: heading_style "atx"/"underlined" (not "setext", which markdownify silently ignores and would make the test pass for the wrong reason). Verified: the test fails on the pre-patch RssConverter (all three options leak to the other call) and passes on the patched one; full test_rss.py -> 165 passed; black --check clean. --- packages/markitdown/tests/test_rss.py | 33 ++++++++++++++++++++++----- 1 file changed, 27 insertions(+), 6 deletions(-) diff --git a/packages/markitdown/tests/test_rss.py b/packages/markitdown/tests/test_rss.py index 455822a58a..5cd2d949ba 100644 --- a/packages/markitdown/tests/test_rss.py +++ b/packages/markitdown/tests/test_rss.py @@ -97,23 +97,44 @@ def _parse_content(self, content: str, **kwargs) -> str: def test_concurrent_conversions_keep_call_specific_markdown_options() -> None: embedded = "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///w==" - feed = _feed_with_body("atom-content", f'Pixel') + feed = _feed_with_body( + "atom-content", + f'

Section

  • item
Pixel', + ) converter = _SynchronizedRssConverter() stream_info = StreamInfo(extension=".atom") - def convert(keep_data_uris: bool) -> str: + def convert(keep_data_uris: bool, heading_style: str, bullets: str) -> str: return converter.convert( io.BytesIO(feed), stream_info, keep_data_uris=keep_data_uris, + heading_style=heading_style, + bullets=bullets, ).markdown with ThreadPoolExecutor(max_workers=2) as pool: - kept = pool.submit(convert, True) - truncated = pool.submit(convert, False) + a = pool.submit(convert, True, "atx", "+") + b = pool.submit(convert, False, "underlined", "*") + + a_md = a.result() + b_md = b.result() + + # Dimensions are asserted together so a leak in any one of them fails the test, + # not just keep_data_uris (see the PR review note about single-dimension coverage). + + # keep_data_uris + assert f"![Pixel]({embedded})" in a_md + assert "![Pixel](data:image/gif;base64...)" in b_md + + # heading_style: atx ("##") on A, setext/underlined on B + assert "## Section" in a_md + assert "## Section" not in b_md + assert "Section\n------" in b_md - assert f"![Pixel]({embedded})" in kept.result() - assert "![Pixel](data:image/gif;base64...)" in truncated.result() + # bullets: "+" on A, "*" on B + assert "+ item" in a_md + assert "* item" in b_md and "+ item" not in b_md def test_atom_ignores_elements_from_other_namespaces() -> None: