diff --git a/news/+xml-declaration-whitespace.bugfix b/news/+xml-declaration-whitespace.bugfix new file mode 100644 index 000000000..90b419d3a --- /dev/null +++ b/news/+xml-declaration-whitespace.bugfix @@ -0,0 +1 @@ +Fixed the quadratic run time of :meth:`Component.to_xcal ` and :attr:`vXml.element ` for an ``XML`` property whose XML declaration contains a long run of whitespace. I used AI to assist me with this change. @shamsManna diff --git a/src/icalendar/prop/xml.py b/src/icalendar/prop/xml.py index 759854335..388747924 100644 --- a/src/icalendar/prop/xml.py +++ b/src/icalendar/prop/xml.py @@ -3,6 +3,7 @@ from __future__ import annotations import re +import sys from contextlib import contextmanager, suppress from copy import copy, deepcopy from io import BytesIO @@ -23,10 +24,23 @@ from icalendar.compatibility import Self # The text is already decoded, so an encoding in the XML declaration does not apply. -_DECLARED_ENCODING = re.compile( - r"""^(\ufeff?<\?xml\s[^>]*?)\s+encoding\s*=\s*("[^"]*"|'[^']*')""", +# A single ``\s`` before ``encoding`` keeps the match linear. +# The whitespace that stays in the declaration is valid XML. +_DECLARED_ENCODING_PY310 = re.compile( + r"""^(\ufeff?<\?xml\s[^>]*?)\sencoding\s*=\s*("[^"]*"|'[^']*')""", re.ASCII, ) +if sys.version_info >= (3, 11): + # Possessive quantifiers match the declaration as alternating runs of + # whitespace and other characters that are never given back, so the match is + # linear and all whitespace before ``encoding`` is removed. + _DECLARED_ENCODING = re.compile( + r"""^(\ufeff?<\?xml\s[^\s>]*+(?:\s++[^\s>]++)*?)\s++encoding\s*+=\s*+""" + r"""("[^"]*+"|'[^']*+')""", + re.ASCII, + ) +else: + _DECLARED_ENCODING = _DECLARED_ENCODING_PY310 # Bytes are decoded with the encoding of their XML declaration. _DECLARED_ENCODING_OF_BYTES = re.compile( rb"""^<\?xml\s[^>]*?\sencoding\s*=\s*["']([A-Za-z][A-Za-z0-9._-]*)["']""" diff --git a/src/icalendar/tests/rfc_6321_xcal/test_xml_property.py b/src/icalendar/tests/rfc_6321_xcal/test_xml_property.py index ff0a7125a..4d169f9a4 100644 --- a/src/icalendar/tests/rfc_6321_xcal/test_xml_property.py +++ b/src/icalendar/tests/rfc_6321_xcal/test_xml_property.py @@ -20,6 +20,8 @@ import base64 import re +import sys +import time import xml.etree.ElementTree as ET from io import BytesIO from xml.etree.ElementTree import Element, ParseError, fromstring, tostring @@ -40,6 +42,7 @@ ) from icalendar.parser.xcal.adapter import ElementAdapter from icalendar.parser.xcal.property import XCalPropertyParser +from icalendar.prop import xml as prop_xml XML = 'text' XML_BASE64 = base64.b64encode(XML.encode()).decode() @@ -269,6 +272,21 @@ def test_xml_property_to_xcal_and_back_is_equal(): assert str(copy.XML[1]) == '' +@pytest.fixture(params=["py310", "py311"]) +def declared_encoding(request, monkeypatch): + """Use the regex for the declared encoding of Python 3.10 and of Python 3.11+. + + Python 3.10 has no possessive quantifiers, so it uses a simpler regex. + Both are tested where they can be compiled. + """ + if request.param == "py310": + monkeypatch.setattr( + prop_xml, "_DECLARED_ENCODING", prop_xml._DECLARED_ENCODING_PY310 + ) + elif sys.version_info < (3, 11): + pytest.skip("Possessive quantifiers need Python 3.11 or later.") + + @pytest.mark.parametrize( "declaration", [ @@ -277,9 +295,13 @@ def test_xml_property_to_xcal_and_back_is_equal(): '', "", chr(0xFEFF) + '', + '', + '', ], ) -def test_xml_property_to_xcal_ignores_the_declared_encoding(declaration): +def test_xml_property_to_xcal_ignores_the_declared_encoding( + declaration, declared_encoding +): """The text is already decoded, so the encoding of its declaration is ignored.""" event = Event() event.add("XML", f'{declaration}café €') @@ -287,6 +309,54 @@ def test_xml_property_to_xcal_ignores_the_declared_encoding(declaration): assert Event.from_xcal(event.to_xcal())[0]["XML"].element.text == "café €" +@pytest.mark.parametrize("whitespace", [" ", "\t", "\n"]) +def test_xml_property_to_xcal_with_long_whitespace_in_the_declaration( + whitespace, declared_encoding +): + """A long run of whitespace in the XML declaration is parsed in linear time. + + The regex that removes the declared encoding used to retry the whitespace + before ``encoding`` from every position, so this took minutes. + The bound is generous enough to never flake on slow CI. + """ + event = Event() + event.add( + "XML", + 'text', + ) + start = time.perf_counter() + xcal = event.to_xcal() + assert time.perf_counter() - start < 5 + assert Event.from_xcal(xcal)[0]["XML"].element.text == "text" + + +@pytest.mark.parametrize("whitespace", [" ", "\t", "\n"]) +def test_xml_property_to_xcal_with_long_whitespace_around_the_encoding( + whitespace, declared_encoding +): + """Long runs of whitespace around the attributes are parsed in linear time. + + The run before ``version`` is not followed by ``encoding``, and the run + before ``encoding`` is. The declared encoding is still removed, + so the text is not decoded again. + """ + event = Event() + event.add( + "XML", + "café €', + ) + start = time.perf_counter() + xcal = event.to_xcal() + assert time.perf_counter() - start < 5 + assert Event.from_xcal(xcal)[0]["XML"].element.text == "café €" + + # icalendar objects -> iCal/jCal