Skip to content

Commit 7f75295

Browse files
authored
fix(sitemap): guard the unavailable ExpatParser.flush on older CPython patch releases (#2141)
### Description - `_XmlSitemapParser.flush` called `ExpatParser.flush()` unguarded. The method was added in CPython 3.10.14, 3.11.9, and 3.12.3, so on earlier patch releases every sitemap parse logged a spurious WARNING. It's now called only when it exists. - Moved the `yield` loop into a `finally`, so a raising `flush()` no longer discards the items the handler has already collected. ### Issues - Closes: #2119 ### Testing - Added tests for a parser without `flush` and for a failing `flush()`.
1 parent 4a67025 commit 7f75295

2 files changed

Lines changed: 59 additions & 5 deletions

File tree

src/crawlee/_utils/sitemap.py

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -201,16 +201,20 @@ async def process_chunk(self, chunk: str) -> AsyncGenerator[_SitemapItem, None]:
201201
async def flush(self) -> AsyncGenerator[_SitemapItem, None]:
202202
"""Process any remaining data in the buffer, yielding items one by one."""
203203
try:
204-
self._parser.flush()
204+
# `ExpatParser.flush` isn't part of the `IncrementalParser` interface and is missing before CPython
205+
# 3.10.14, 3.11.9 and 3.12.3, whose bundled expat predates reparse deferral, so nothing stays buffered.
206+
if (flush := getattr(self._parser, 'flush', None)) is not None:
207+
flush()
205208

209+
except Exception as e:
210+
logger.warning(f'Failed to parse remaining XML data: {e}')
211+
212+
finally:
206213
for item in self._handler.items:
207214
yield item
208215

209216
self._handler.items.clear()
210217

211-
except Exception as e:
212-
logger.warning(f'Failed to parse remaining XML data: {e}')
213-
214218
def close(self) -> None:
215219
"""Clean up resources."""
216220
with suppress(SAXParseException):

tests/unit/_utils/test_sitemap.py

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import base64
22
import gzip
3+
import logging
34
from contextlib import asynccontextmanager
45
from datetime import datetime, timedelta
56
from typing import TYPE_CHECKING, Any, cast
6-
from unittest.mock import AsyncMock, MagicMock
7+
from unittest.mock import AsyncMock, MagicMock, patch
78
from xml.sax.expatreader import ExpatParser
89

910
import pytest
@@ -16,6 +17,7 @@
1617
SitemapUrl,
1718
_TxtSitemapParser,
1819
_XMLSaxSitemapHandler,
20+
_XmlSitemapParser,
1921
discover_valid_sitemaps,
2022
parse_sitemap,
2123
)
@@ -319,6 +321,22 @@ async def test_sitemap_from_string() -> None:
319321
assert set(sitemap.urls) == get_basic_results()
320322

321323

324+
async def test_malformed_sitemap_keeps_urls() -> None:
325+
"""A parse error must not discard the URLs collected before it."""
326+
malformed = (
327+
'<?xml version="1.0" encoding="UTF-8"?>\n'
328+
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n'
329+
f'<url><loc>{DEFAULT_URL}first</loc></url>\n'
330+
f'<url><loc>{DEFAULT_URL}second</loc></url>\n'
331+
f'<url><loc>{DEFAULT_URL}third</loc></mismatched>\n'
332+
'</urlset>'
333+
)
334+
335+
sitemap = await Sitemap.from_xml_string(malformed)
336+
337+
assert sitemap.urls == [f'{DEFAULT_URL}first', f'{DEFAULT_URL}second']
338+
339+
322340
async def test_sitemap_fetch_retries_on_transient_error() -> None:
323341
"""Transient fetch errors are retried up to `sitemap_retries` times before giving up."""
324342
client, attempts = _make_flaky_stream_client(get_basic_sitemap().encode(), fail_times=2)
@@ -562,6 +580,38 @@ async def test_txt_parser_flush_clears_buffer() -> None:
562580
assert [item['loc'] for item in items] == ['https://a.com/', 'https://b.com/', 'https://c.com/']
563581

564582

583+
async def test_xml_parser_skips_missing_flush(
584+
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
585+
) -> None:
586+
"""A parser without `flush` is flushed silently, as on CPython before 3.10.14, 3.11.9 and 3.12.3."""
587+
monkeypatch.delattr(ExpatParser, 'flush', raising=False)
588+
parser = _XmlSitemapParser()
589+
590+
with caplog.at_level(logging.WARNING, logger='crawlee._utils.sitemap'):
591+
items = [item async for item in parser.process_chunk(get_basic_sitemap())]
592+
items += [item async for item in parser.flush()]
593+
594+
assert {item['loc'] for item in items} == get_basic_results()
595+
assert caplog.records == []
596+
597+
598+
async def test_xml_parser_keeps_items_on_flush_error(caplog: pytest.LogCaptureFixture) -> None:
599+
"""A failing `flush()` must not discard the items already collected."""
600+
parser = _XmlSitemapParser()
601+
parser._handler.items.append({'type': 'url', 'loc': f'{DEFAULT_URL}page'})
602+
603+
# `create=True` covers interpreters where `ExpatParser` has no `flush` to replace.
604+
with (
605+
caplog.at_level(logging.WARNING, logger='crawlee._utils.sitemap'),
606+
patch.object(parser._parser, 'flush', side_effect=RuntimeError('Broken parser'), create=True),
607+
):
608+
items = [item async for item in parser.flush()]
609+
610+
assert items == [{'type': 'url', 'loc': f'{DEFAULT_URL}page'}]
611+
assert parser._handler.items == []
612+
assert 'Failed to parse remaining XML data: Broken parser' in caplog.text
613+
614+
565615
async def test_discover_sitemap_url_without_host_skipped() -> None:
566616
"""URLs without a host are skipped."""
567617
http_client = _make_mock_client({})

0 commit comments

Comments
 (0)