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
1 change: 1 addition & 0 deletions news/+xml-declaration-whitespace.bugfix
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Fixed the quadratic run time of :meth:`Component.to_xcal <icalendar.cal.component.Component.to_xcal>` and :attr:`vXml.element <icalendar.prop.xml.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
18 changes: 16 additions & 2 deletions src/icalendar/prop/xml.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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*+"""

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.

I think double-quotes need to be escaped. Please check against this test, and let me know what you think.

https://regex101.com/r/mXQ2XG/1

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

they don't need escaping in the source. both patterns are in r"""...""" triple-quoted raw strings, so a single " inside is just a literal quote and doesn't end the string. regex101 asks for \" because your link uses " as the delimiter, which corresponds to r"...".

i ran your test string through the compiled pattern on 3.11+ and it strips the encoding together with all the whitespace before it, giving <?xml version="1.0"\n\t\t\t\n\n?><a xmlns="urn:x">text</a>. a declaration like it is now in the tests.

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._-]*)["']"""
Expand Down
72 changes: 71 additions & 1 deletion src/icalendar/tests/rfc_6321_xcal/test_xml_property.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 = '<a xmlns="http://example.com/ns">text</a>'
XML_BASE64 = base64.b64encode(XML.encode()).decode()
Expand Down Expand Up @@ -269,6 +272,21 @@ def test_xml_property_to_xcal_and_back_is_equal():
assert str(copy.XML[1]) == '<ns0:x xmlns:ns0="http://example.com/b" ns0:a="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",
[
Expand All @@ -277,16 +295,68 @@ def test_xml_property_to_xcal_and_back_is_equal():
'<?xml version="1.0" encoding="US-ASCII"?>',
"<?xml version='1.0' encoding='UTF-16' standalone='yes'?>",
chr(0xFEFF) + '<?xml version="1.0" encoding="ISO-8859-1"?>',
'<?xml version="1.0" \n\t\t\t\n encoding="ISO-8859-1"\n\t\t\t\n\n?>',
'<?xml version="1.0"\tencoding = "ISO-8859-1"\nstandalone="yes"?>',
],
)
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}<a xmlns="urn:x">café €</a>')
assert event["XML"].element.text == "café €"
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",
'<?xml version="1.0"' + whitespace * 200_000 + '?><a xmlns="urn:x">text</a>',

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.

How does test the presence of whitespace relative to encoding? Should there be another test for that case?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

it didn't, that test has no encoding in the declaration at all. added test_xml_property_to_xcal_with_long_whitespace_around_the_encoding: 200k runs before version, before encoding and after it, and it checks the encoding still gets stripped (encoding="ISO-8859-1" with café € as the text, so a leftover encoding would garble it).

the run before version is the important one. it's whitespace that isn't followed by encoding, which is what made the old \s+ quadratic even when an encoding is present (40k tabs there took 4.9 s with the old pattern, under 1 ms now). i also added two short declarations with tabs and newlines around encoding to the parametrized encoding test.

)
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",
"<?xml"
+ whitespace * 200_000
+ 'version="1.0"'
+ whitespace * 200_000
+ 'encoding="ISO-8859-1"'
+ whitespace * 200_000
+ '?><a xmlns="urn:x">café €</a>',
)
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


Expand Down
Loading