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/1445.bugfix.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Values of unrecognized properties and ``X-`` properties without a ``VALUE`` parameter were altered by escaping when parsed, serialized, or converted to and from jCal, so they did not round-trip unchanged as :rfc:`7265` specifies. These values are now preserved verbatim. Additionally, the ``PROXIMITY`` property (:rfc:`9074`) was treated as an unknown value type instead of ``TEXT`` and is now recognized correctly. AI disclosure: I used Claude Code (Anthropic's Claude Opus) to draft and refine this change and its tests; I reviewed the output and validated the change locally. @lcampanella98
3 changes: 2 additions & 1 deletion src/icalendar/attr.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
vRecur,
vText,
vUid,
vUnknown,
vUri,
vXmlReference,
)
Expand Down Expand Up @@ -418,7 +419,7 @@ def fget(self: Component) -> datetime | None:
if name not in self:
return None
dt = self.get(name)
if isinstance(dt, vText):
if isinstance(dt, (vText, vUnknown)):
# we might be in an attribute that is not typed
value = vDDDTypes.from_ical(dt)
else:
Expand Down
7 changes: 4 additions & 3 deletions src/icalendar/cal/component.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
)
from icalendar.parser.ical.component import ComponentIcalParser
from icalendar.parser_tools import DEFAULT_ENCODING
from icalendar.prop import VPROPERTY, TypesFactory, vDDDLists, vText
from icalendar.prop import VPROPERTY, TypesFactory, vDDDLists, vText, vUnknown
from icalendar.timezone import tzp
from icalendar.tools import is_date

Expand Down Expand Up @@ -367,8 +367,9 @@ def _decode(self, name: str, value: VPROPERTY):
return value
decoded = self.types_factory.from_ical(name, value)
# TODO: remove when proper decoded is implemented in every prop.* class
# Workaround to decode vText properly
if isinstance(decoded, vText):
# Workaround to decode vText properly. vUnknown is not a vText
# subclass (RFC 7265), but its value is decoded the same way here.
if isinstance(decoded, (vText, vUnknown)):
decoded = decoded.encode(DEFAULT_ENCODING)
return decoded

Expand Down
20 changes: 17 additions & 3 deletions src/icalendar/parser/content_line.py
Original file line number Diff line number Diff line change
Expand Up @@ -160,12 +160,21 @@ def from_parts(
return cls(f"{name};{params}:{values}")
return cls(f"{name}:{values}")

def parts(self) -> tuple[str, Parameters, str]:
def parts(self, should_unescape: bool = True) -> tuple[str, Parameters, str]:
"""Split the content line into ``name``, ``parameters``, and ``values`` parts.

Properly handles escaping with backslashes and double-quote sections
to avoid corrupting URL-encoded characters in values.

When ``should_unescape`` is ``True``, unescape backslashes. This is used
for the values of ``TEXT`` properties.

When ``should_unescape`` is ``False`` the value is returned raw, without
backslash unescaping. This is used for :rfc:`7265` ``UNKNOWN`` values,
which must be preserved verbatim.

The default is ``True``.

Example with parameter:

.. code-block:: ics
Expand Down Expand Up @@ -226,8 +235,13 @@ def parts(self) -> tuple[str, Parameters, str]:
(_unescape_string(key), unescape_list_or_string(value))
for key, value in iter(params.items())
)
# Unescape backslash sequences in values but preserve URL encoding
values = unescape_backslash(self[value_split + 1 :])
if should_unescape:
Comment thread
lcampanella98 marked this conversation as resolved.
# Unescape backslash sequences in TEXT values,
# while preserving URL encoding
values = unescape_backslash(self[value_split + 1 :])
else:
# Preserve both backslash sequences and URL encoding in UNKNOWN values
values = self[value_split + 1 :]
Comment thread
lcampanella98 marked this conversation as resolved.
except ValueError as exc:
raise ValueError(
f"Content line could not be parsed into parts: '{self}': {exc}"
Expand Down
6 changes: 6 additions & 0 deletions src/icalendar/parser/ical/component.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from icalendar.parser.content_line import Contentline, Contentlines
from icalendar.parser.property import split_on_unescaped_comma
from icalendar.prop import vBroken
from icalendar.prop.unknown import vUnknown
from icalendar.timezone import tzp

if TYPE_CHECKING:
Expand Down Expand Up @@ -203,6 +204,11 @@ def handle_property(
elif name == "RDATE" and vals == "":
vals_list = []
else:
factory = self.get_factory_for_property(name, params)
if factory is vUnknown:
# RFC 7265 unknown values are taken verbatim, so skip the
# backslash unescaping that parts() applies.
_, _, vals = line.parts(should_unescape=False)
vals_list = [vals]

# Parse all properties eagerly
Expand Down
1 change: 1 addition & 0 deletions src/icalendar/prop/factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,7 @@ def __init__(self, *args, **kwargs):
"repeat": "integer",
"trigger": "duration",
"acknowledged": "date-time",
"proximity": "text", # RFC 9074
# Change Management Component Properties
"created": "date-time",
"dtstamp": "date-time",
Expand Down
7 changes: 7 additions & 0 deletions src/icalendar/prop/text.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,13 @@ class vText(str):
characters are escaped or changed.
These characters include the COMMA, SEMICOLON, BACKSLASH, and line breaks.

Contrast TEXT with the UNKNOWN value data type specified in :rfc:`7265#section-5`.
UNKNOWN is implemented in the Python class :class:`~icalendar.prop.unknown.vUnknown`,
which does **not** apply this escaping and preserves its value verbatim,
because the escaping rules of an unrecognized value type are not known.
``vUnknown`` deliberately does not inherit from ``vText``, so the two
don't share escaping behavior.

Examples:

vText property as a TEXT value type.
Expand Down
122 changes: 114 additions & 8 deletions src/icalendar/prop/unknown.py
Original file line number Diff line number Diff line change
@@ -1,26 +1,132 @@
"""UNKNOWN values from :rfc:`7265`."""

from typing import ClassVar
from typing import Any, ClassVar

from icalendar.compatibility import Self
from icalendar.prop.text import vText
from icalendar.error import JCalParsingError
from icalendar.parser import Parameters
from icalendar.parser_tools import DEFAULT_ENCODING, ICAL_TYPE, to_unicode


class vUnknown(vText):
"""This is text but the VALUE parameter is unknown.
class vUnknown(str):
"""A property value of the :rfc:`7265#section-5` reserved UNKNOWN value data type.

Since :rfc:`7265`, it is important to record if values are unknown.
For :rfc:`5545`, we could just assume TEXT.
.. versionchanged:: 7.2.0

Previously ``vUnknown`` inherited from ``vText``, which unescapes values.
Now ``vUnknown`` doesn't unescape its values, which is the correct behavior.

Unlike :class:`~icalendar.prop.text.vText`, the value is preserved verbatim
when imported from and exported to iCalendar data, without :rfc:`5545` escaping
or unescaping. When the value type of an unrecognized property is not known,
then no escaping rules can be applied, and the value must be preserved as is
round-trip.

See also:

:rfc:`7265#section-5.1`
"""

default_value: ClassVar[str] = "UNKNOWN"
params: Parameters
__slots__ = ("encoding", "params")

def __new__(
cls,
value: ICAL_TYPE,
encoding: str = DEFAULT_ENCODING,
/,
params: dict[str, Any] | None = None,
) -> Self:
value = to_unicode(value, encoding=encoding)
self = super().__new__(cls, value)
self.encoding = encoding
self.params = Parameters(params)
return self

def __repr__(self) -> str:
return f"vUnknown({self.to_ical()!r})"

def to_ical(self) -> bytes:
r"""Return the value verbatim, without :rfc:`5545` escaping.

This method's implementation is different from that in
:class:`~icalendar.prop.text.vText`, whose
:meth:`~icalendar.prop.text.vText.to_ical` method escapes ``;``, ``,``,
``\``, and newlines.

Example:

The semicolon is kept verbatim for UNKNOWN, unlike a TEXT value
which would escape it as ``\\;``.

.. code-block:: pycon

>>> from icalendar.prop import vText, vUnknown
>>> vUnknown("a;b").to_ical()
b'a;b'
Comment thread
lcampanella98 marked this conversation as resolved.
>>> vText("a;b").to_ical()
b'a\\;b'

See also:

:rfc:`7265#section-5.2`

"""
return self.encode(self.encoding)

@classmethod
def from_ical(cls, ical: ICAL_TYPE) -> Self:
"""Take the value verbatim, without unescaping."""
return cls(ical)

@property
def ical_value(self) -> str:
"""The string value of the property."""
return str(self)

from icalendar.param import ALTREP, GAP, LANGUAGE, RELTYPE, VALUE
Comment thread
stevepiercy marked this conversation as resolved.

def to_jcal(self, name: str) -> list:
"""The jCal representation of this property, according to :rfc:`7265#section-5.1`.

If the property doesn't include a VALUE property parameter and its value
type is not known, then its value type is set to ``"unknown"``. Else the
property's value type is converted to lowercase.

The property's value is the unprocessed value text, aside from standard
JSON string escaping.
"""
return [name, self.params.to_jcal(), self.VALUE.lower(), str(self)]

@classmethod
def examples(cls) -> list[Self]:
"""Examples of vUnknown."""
return [vUnknown("Some property text.")]
return [cls("Some property text.")]

@classmethod
def from_jcal(cls, jcal_property: list) -> Self:
"""Parse jCal from :rfc:`7265`, taking the value verbatim.

from icalendar.param import VALUE
Parameters:
jcal_property: The jCal property to parse.

Raises:
~error.JCalParsingError: If the provided jCal is invalid.
"""
JCalParsingError.validate_property(jcal_property, cls)
string = jcal_property[3]
JCalParsingError.validate_value_type(string, str, cls, 3)
return cls(
string,
params=Parameters.from_jcal_property(jcal_property),
)

@classmethod
def parse_jcal_value(cls, jcal_value: Any) -> Self:
"""Parse a jCal value into a vUnknown."""
JCalParsingError.validate_value_type(jcal_value, (str, int, float), cls)
return cls(str(jcal_value))


__all__ = ["vUnknown"]
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ def test_convert_coffee(calendars):
calendar = calendars.rfc_7265_example_2
ical = calendar.to_ical().decode()
print(to_unicode(ical))
assert r"X-COFFEE-DATA:Stenophylla\;Guinea\\\,Africa" in ical
assert r"X-COFFEE-DATA:Stenophylla;Guinea\,Africa" in ical


@pytest.mark.parametrize(
Expand Down
Loading