diff --git a/CHANGES.rst b/CHANGES.rst index 7c48ba9..9def3d5 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -17,6 +17,7 @@ New features: - Add attributes to the calendar for properties ``NAME``, ``DESCRIPTION``, and ``COLOR``. See `Issue 655 `_. - Add ``sequence`` attribute to ``Event``, ``Todo``, and ``Journal`` components. See `Issue 802 `_. +- Add compatibility to :rfc:`6868`. See `Issue 652 `_. Bug fixes: diff --git a/docs/usage.rst b/docs/usage.rst index fc474d4..b2ca7d1 100644 --- a/docs/usage.rst +++ b/docs/usage.rst @@ -12,14 +12,14 @@ Compatibility This package is compatible with the following standards: +- :rfc:`2445` - obsoleted by :rfc:`5545` - :rfc:`5545` - Internet Calendaring and Scheduling Core Object Specification (iCalendar) +- :rfc:`6868` - Parameter Value Encoding in iCalendar and vCard - :rfc:`7529` - Non-Gregorian Recurrence Rules in the Internet Calendaring and Scheduling Core Object Specification (iCalendar) - :rfc:`9074` - "VALARM" Extensions for iCalendar We do not claim compatibility to the following RFCs. They might work though. -- :rfc:`2445` - obsoleted by :rfc:`5545` -- :rfc:`6868` - Parameter Value Encoding in iCalendar and vCard - :rfc:`7953` - Calendar Availability - :rfc:`7986` - New Properties for iCalendar - :rfc:`9073` - Event Publishing Extensions to iCalendar diff --git a/src/icalendar/parser.py b/src/icalendar/parser.py index a751510..e08e19e 100644 --- a/src/icalendar/parser.py +++ b/src/icalendar/parser.py @@ -6,8 +6,9 @@ It is stupid in the sense that it treats the content purely as strings. No type conversion is attempted. """ +import os from icalendar.caselessdict import CaselessDict -from icalendar.parser_tools import DEFAULT_ENCODING +from icalendar.parser_tools import DEFAULT_ENCODING, ICAL_TYPE from icalendar.parser_tools import SEQUENCE_TYPES from icalendar.parser_tools import to_unicode @@ -94,10 +95,9 @@ def param_value(value): """Returns a parameter value.""" if isinstance(value, SEQUENCE_TYPES): return q_join(value) - elif isinstance(value, str): - return dquote(value) - else: - return dquote(value.to_ical().decode(DEFAULT_ENCODING)) + if isinstance(value, str): + return dquote(rfc_6868_escape(value)) + return dquote(rfc_6868_escape(value.to_ical().decode(DEFAULT_ENCODING))) # Could be improved @@ -232,13 +232,13 @@ class Parameters(CaselessDict): if v.startswith('"') and v.endswith('"'): v = v.strip('"') validate_param_value(v, quoted=True) - vals.append(v) + vals.append(rfc_6868_unescape(v)) else: validate_param_value(v, quoted=False) if strict: - vals.append(v.upper()) + vals.append(rfc_6868_unescape(v.upper())) else: - vals.append(v) + vals.append(rfc_6868_unescape(v)) if not vals: result[key] = val else: @@ -270,6 +270,43 @@ def unescape_string(val): ) +RFC_6868_UNESCAPE_REGEX = re.compile(r"\^\^|\^n|\^'") + + +def rfc_6868_unescape(param_value: str) -> str: + """Take care of :rfc:`6868` unescaping. + + - ^^ -> ^ + - ^n -> system specific newline + - ^' -> " + - ^ with others stay intact + """ + replacements = { + "^^": "^", + "^n": os.linesep, + "^'": '"', + } + return RFC_6868_UNESCAPE_REGEX.sub(lambda m: replacements.get(m.group(0), m.group(0)), param_value) + + +RFC_6868_ESCAPE_REGEX = re.compile(r'\^|\r\n|\r|\n|"') + +def rfc_6868_escape(param_value: str) -> str: + """Take care of :rfc:`6868` escaping. + + - ^ -> ^^ + - " -> ^' + - newline -> ^n + """ + replacements = { + "^": "^^", + "\n": "^n", + "\r": "^n", + "\r\n": "^n", + '"': "^'", + } + return RFC_6868_ESCAPE_REGEX.sub(lambda m: replacements.get(m.group(0), m.group(0)), param_value) + def unescape_list_or_string(val): if isinstance(val, list): return [unescape_string(s) for s in val] @@ -296,7 +333,7 @@ class Contentline(str): return self @classmethod - def from_parts(cls, name, params, values, sorted=True): + def from_parts(cls, name:ICAL_TYPE, params: Parameters, values, sorted=True): """Turn a parts into a content line.""" assert isinstance(params, Parameters) if hasattr(values, "to_ical"): @@ -351,7 +388,7 @@ class Contentline(str): except ValueError as exc: raise ValueError( f"Content line could not be parsed into parts: '{self}': {exc}" - ) + ) from exc @classmethod def from_ical(cls, ical, strict=False): @@ -408,6 +445,8 @@ __all__ = [ "param_value", "q_join", "q_split", + "rfc_6868_escape", + "rfc_6868_unescape", "uFOLD", "unescape_char", "unescape_list_or_string", diff --git a/src/icalendar/tests/calendars/rfc_6868.ics b/src/icalendar/tests/calendars/rfc_6868.ics new file mode 100644 index 0000000..a3dd173 --- /dev/null +++ b/src/icalendar/tests/calendars/rfc_6868.ics @@ -0,0 +1,6 @@ +BEGIN:VCALENDAR +X-PARAM;NEWLINE=^n;ALL=^^^'^n;UNKNOWN=^a^ ^asd:asd +BEGIN:VEVENT +ATTENDEE;CN=George Herman ^'Babe^' Ruth:mailto:babe@example.com +END:VEVENT +END:VCALENDAR diff --git a/src/icalendar/tests/test_property_params.py b/src/icalendar/tests/test_property_params.py index 6af3ec9..7cf928b 100644 --- a/src/icalendar/tests/test_property_params.py +++ b/src/icalendar/tests/test_property_params.py @@ -89,8 +89,9 @@ def test_parameter_keys_are_uppercase(): # a single quote in parameter value - double quote the value ("Aramis d'Alameda", '"Aramis d\'Alameda"'), ("Арамис д'Аламеда", '"Арамис д\'Аламеда"'), - # double quote is replaced with single quote - ('Aramis d"Alameda', '"Aramis d\'Alameda"'), + # Before, double quote is replaced with single quote + # Since RFC 6868, we replace this with ^' + ('Aramis d"Alameda', '"Aramis d^\'Alameda"'), ], ) def test_quoting(cn_param, cn_quoted): diff --git a/src/icalendar/tests/test_rfc_6868.py b/src/icalendar/tests/test_rfc_6868.py new file mode 100644 index 0000000..7a562c5 --- /dev/null +++ b/src/icalendar/tests/test_rfc_6868.py @@ -0,0 +1,108 @@ +"""This implemensts RFC 6868. + +There are only some changes to parameters needed. +""" +import os +from typing import TYPE_CHECKING + +import pytest + +from icalendar import Calendar +from icalendar.parser import dquote, rfc_6868_escape, rfc_6868_unescape + +if TYPE_CHECKING: + from icalendar import vCalAddress, vText + + +param_duplicate = pytest.mark.parametrize( + "duplicate", + [ lambda x:x, lambda cal: Calendar.from_ical(cal.to_ical()) ] +) + +@param_duplicate +def test_rfc_6868_example(calendars, duplicate): + """Check the example from the RFC.""" + cal : Calendar = duplicate(calendars.rfc_6868) + attendee : vCalAddress = cal.events[0]["attendee"] + assert attendee.name == 'George Herman "Babe" Ruth' + + +@param_duplicate +def test_all_parameters(calendars, duplicate): + """Check that all examples get decoded correctly.""" + cal : Calendar = duplicate(calendars.rfc_6868) + param = cal["X-PARAM"].params["ALL"] + assert param == '^"\n' + + +@param_duplicate +def test_unknown_character(calendars, duplicate): + """if a ^ (U+005E) character is followed by any character other than + the ones above, parsers MUST leave both the ^ and the following + character in place""" + cal : Calendar = duplicate(calendars.rfc_6868) + param = cal["X-PARAM"].params["UNKNOWN"] + assert param == "^a^ ^asd" + + +@pytest.mark.parametrize( + ("text", "expected"), + [ + ("^a", "^a"), + ("^^", "^"), + # ("^n", ), # see other test + ("^'", '"'), + ("^^a^b", "^a^b") + ], +) +@pytest.mark.parametrize( + "modify", [lambda x: x, lambda x: x*10, lambda x: f"asd{x}aaA^AA"] +) +def test_unescape(text, expected, modify): + """Check unescaping.""" + result = rfc_6868_unescape(modify(text)) + assert result == modify(expected) + + +@pytest.mark.parametrize( + "newline", ["\n", "\r\n", "\r", os.linesep]) +def test_unescape_newline(newline, monkeypatch): + """Unescape the newline.""" + monkeypatch.setattr(os, "linesep", newline) + assert rfc_6868_unescape("^n") == newline + + +param_values = pytest.mark.parametrize("text, expected", [ + ("", ""), + ("^", "^^"), + ("^n", "^^n"), + ("This text\n has\r several\r\n lines", "This text^n has^n several^n lines"), + ('Call me "Harry".', "Call me ^'Harry^'."), +] +) + +@param_values +def test_escape_rfc_6868(text, expected): + """Check that we can escape the content with special characters.""" + escaped = rfc_6868_escape(text) + assert escaped == expected, f"{escaped!r} == {expected!r}" + assert rfc_6868_escape(rfc_6868_unescape(escaped)) == expected + + +@param_values +def test_escaping_parameters(text, expected): + cal = Calendar() + cal.add("X-Param", "asd") + param : vText = cal["X-PARAM"] + param.params["RFC6868"] = text + ical = cal.to_ical().decode() + print(ical) + assert "X-PARAM;RFC6868=" + dquote(expected) in ical + + +def test_encode_example_again(calendars): + """The example file should yield its content again.""" + cal : Calendar = calendars.rfc_6868 + again = Calendar.from_ical(cal.to_ical()) + assert cal == again + assert cal.to_ical() == again.to_ical()