Merge pull request #667 from niccokunzmann/rfc-7529

RFC 7529 compatibility
pull/684/head
Nicco Kunzmann 2024-07-03 14:46:01 +01:00 zatwierdzone przez GitHub
commit 02bd0198ea
Nie znaleziono w bazie danych klucza dla tego podpisu
ID klucza GPG: B5690EEEBB952194
7 zmienionych plików z 265 dodań i 45 usunięć

Wyświetl plik

@ -65,6 +65,9 @@ New features:
- Test compatibility with Python 3.12
- Add function ``icalendar.use_pytz()``.
- Allows selecting components with ``walk(select=func)`` where ``func`` takes a
component and returns ``True`` or ``False``.
- Add compatibility to :rfc:`7529`, adding ``vMonth`` and ``vSkip``
- Add ``sphinx-autobuild`` for ``livehtml`` Makefile target.
- Add pull request preview on Read the Docs, building only on changes to documentation-related files.
- Add link to pull request preview builds in the pull request description only when there are changes to documentation-related files.

Wyświetl plik

@ -7,6 +7,23 @@ standard in RFC 5545.
It should be fully compliant, but it is possible to generate and parse invalid
files if you really want to.
Compatibility
-------------
This package is compatible with the following standards:
- :rfc:`5545`
- :rfc:`7529`
We do not claim compatibility to:
- :rfc:`2445` - which is obsoleted by :rfc:`5545`
- :rfc:`6886`
- :rfc:`7953`
- :rfc:`7986`
- :rfc:`9073`
- :rfc:`9074`
- :rfc:`9253`
File structure
--------------

Wyświetl plik

@ -275,23 +275,29 @@ class Component(CaselessDict):
"""
self.subcomponents.append(component)
def _walk(self, name):
def _walk(self, name, select):
"""Walk to given component.
"""
result = []
if name is None or self.name == name:
if (name is None or self.name == name) and select(self):
result.append(self)
for subcomponent in self.subcomponents:
result += subcomponent._walk(name)
result += subcomponent._walk(name, select)
return result
def walk(self, name=None):
def walk(self, name=None, select=lambda c: True):
"""Recursively traverses component and subcomponents. Returns sequence
of same. If name is passed, only components with name will be returned.
:param name: The name of the component or None such as ``VEVENT``.
:param select: A function that takes the component as first argument
and returns True/False.
:returns: A list of components that match.
:rtype: list[Component]
"""
if name is not None:
name = name.upper()
return self._walk(name)
return self._walk(name, select)
#####################
# Generation

Wyświetl plik

@ -1,10 +1,11 @@
from typing import Any
from typing import Any, Union
SEQUENCE_TYPES = (list, tuple)
DEFAULT_ENCODING = 'utf-8'
ICAL_TYPE = Union[str, bytes]
def from_unicode(value: Any, encoding='utf-8') -> bytes:
def from_unicode(value: ICAL_TYPE, encoding='utf-8') -> bytes:
"""
Converts a value to bytes, even if it already is bytes
:param value: The value to convert
@ -21,7 +22,7 @@ def from_unicode(value: Any, encoding='utf-8') -> bytes:
return value
def to_unicode(value, encoding='utf-8'):
def to_unicode(value: ICAL_TYPE, encoding='utf-8') -> str:
"""Converts a value to unicode, even if it is already a unicode string.
"""
if isinstance(value, str):
@ -34,7 +35,7 @@ def to_unicode(value, encoding='utf-8'):
return value
def data_encode(data, encoding=DEFAULT_ENCODING):
def data_encode(data: Union[ICAL_TYPE, dict, list], encoding=DEFAULT_ENCODING) -> bytes:
"""Encode all datastructures to the given encoding.
Currently unicode strings, dicts and lists are supported.
"""

Wyświetl plik

@ -44,10 +44,9 @@ from icalendar.caselessdict import CaselessDict
from icalendar.parser import Parameters
from icalendar.parser import escape_char
from icalendar.parser import unescape_char
from icalendar.parser_tools import DEFAULT_ENCODING
from icalendar.parser_tools import SEQUENCE_TYPES
from icalendar.parser_tools import to_unicode
from icalendar.parser_tools import from_unicode
from icalendar.parser_tools import (
DEFAULT_ENCODING, SEQUENCE_TYPES, to_unicode, from_unicode, ICAL_TYPE
)
import base64
import binascii
@ -55,7 +54,8 @@ from .timezone import tzp
import re
import time as _time
from typing import Optional
from typing import Optional, Union
from enum import Enum, auto
DURATION_REGEX = re.compile(r'([-+]?)P(?:(\d+)W)?(?:(\d+)D)?'
@ -126,6 +126,29 @@ class vBoolean(int):
raise ValueError(f"Expected 'TRUE' or 'FALSE'. Got {ical}")
class vText(str):
"""Simple text.
"""
def __new__(cls, value, encoding=DEFAULT_ENCODING):
value = to_unicode(value, encoding=encoding)
self = super().__new__(cls, value)
self.encoding = encoding
self.params = Parameters()
return self
def __repr__(self) -> str:
return f"vText({self.to_ical()!r})"
def to_ical(self) -> bytes:
return escape_char(self).encode(self.encoding)
@classmethod
def from_ical(cls, ical:ICAL_TYPE):
ical_unesc = unescape_char(ical)
return cls(ical_unesc)
class vCalAddress(str):
"""This just returns an unquoted string.
"""
@ -176,11 +199,11 @@ class vInt(int):
self.params = Parameters()
return self
def to_ical(self):
def to_ical(self) -> bytes:
return str(self).encode('utf-8')
@classmethod
def from_ical(cls, ical):
def from_ical(cls, ical:ICAL_TYPE):
try:
return cls(ical)
except Exception:
@ -610,6 +633,92 @@ class vFrequency(str):
raise ValueError(f'Expected frequency, got: {ical}')
class vMonth(int):
"""The number of the month for recurrence.
In :rfc:`5545`, this is just an int.
In :rfc:`7529`, this can be followed by `L` to indicate a leap month.
>>> vMonth(1) # first month January
vMonth('1')
>>> vMonth("5L") # leap month in Hebrew calendar
vMonth('5L')
>>> vMonth(1).leap
False
>>> vMonth("5L").leap
True
Definition from RFC::
type-bymonth = element bymonth {
xsd:positiveInteger |
xsd:string
}
"""
def __new__(cls, month:Union[str, int]):
if isinstance(month, vMonth):
return cls(month.to_ical().decode())
if isinstance(month, str):
if month.isdigit():
month_index = int(month)
leap = False
else:
if not month[-1] == "L" and month[:-1].isdigit():
raise ValueError(f"Invalid month: {month!r}")
month_index = int(month[:-1])
leap = True
else:
leap = False
month_index = int(month)
self = super().__new__(cls, month_index)
self.leap = leap
self.params = Parameters()
return self
def to_ical(self) -> bytes:
"""The ical representation."""
return str(self).encode('utf-8')
@classmethod
def from_ical(cls, ical: str):
return cls(ical)
def leap():
doc = "Whether this is a leap month."
def fget(self) -> bool:
return self._leap
def fset(self, value:bool) -> None:
self._leap = value
return locals()
leap = property(**leap())
def __repr__(self) -> str:
"""repr(self)"""
return f"{self.__class__.__name__}({str(self)!r})"
def __str__(self) -> str:
"""str(self)"""
return f"{int(self)}{'L' if self.leap else ''}"
class vSkip(vText, Enum):
"""Skip values for RRULE.
These are defined in :rfc:`7529`.
OMIT is the default value.
"""
OMIT = "OMIT"
FORWARD = "FORWARD"
BACKWARD = "BACKWARD"
def __reduce_ex__(self, _p):
"""For pickling."""
return self.__class__, (self._name_,)
class vRecur(CaselessDict):
"""Recurrence definition.
"""
@ -619,10 +728,10 @@ class vRecur(CaselessDict):
# Mac iCal ignores RRULEs where FREQ is not the first rule part.
# Sorts parts according to the order listed in RFC 5545, section 3.3.10.
canonical_order = ("FREQ", "UNTIL", "COUNT", "INTERVAL",
canonical_order = ("RSCALE", "FREQ", "UNTIL", "COUNT", "INTERVAL",
"BYSECOND", "BYMINUTE", "BYHOUR", "BYDAY", "BYWEEKDAY",
"BYMONTHDAY", "BYYEARDAY", "BYWEEKNO", "BYMONTH",
"BYSETPOS", "WKST")
"BYSETPOS", "WKST", "SKIP")
types = CaselessDict({
'COUNT': vInt,
@ -633,16 +742,20 @@ class vRecur(CaselessDict):
'BYWEEKNO': vInt,
'BYMONTHDAY': vInt,
'BYYEARDAY': vInt,
'BYMONTH': vInt,
'BYMONTH': vMonth,
'UNTIL': vDDDTypes,
'BYSETPOS': vInt,
'WKST': vWeekday,
'BYDAY': vWeekday,
'FREQ': vFrequency,
'BYWEEKDAY': vWeekday,
'SKIP': vSkip,
})
def __init__(self, *args, **kwargs):
for k, v in kwargs.items():
if not isinstance(v, SEQUENCE_TYPES):
kwargs[k] = [v]
super().__init__(*args, **kwargs)
self.params = Parameters()
@ -667,7 +780,7 @@ class vRecur(CaselessDict):
return [parser.from_ical(v) for v in values.split(',')]
@classmethod
def from_ical(cls, ical):
def from_ical(cls, ical: str):
if isinstance(ical, cls):
return ical
try:
@ -680,34 +793,13 @@ class vRecur(CaselessDict):
# FREQ=YEARLY;BYMONTH=11;BYDAY=1SU;
continue
recur[key] = cls.parse_type(key, vals)
return dict(recur)
except Exception:
return cls(recur)
except ValueError:
raise
except:
raise ValueError(f'Error in recurrence rule: {ical}')
class vText(str):
"""Simple text.
"""
def __new__(cls, value, encoding=DEFAULT_ENCODING):
value = to_unicode(value, encoding=encoding)
self = super().__new__(cls, value)
self.encoding = encoding
self.params = Parameters()
return self
def __repr__(self):
return f"vText('{self.to_ical()!r}')"
def to_ical(self):
return escape_char(self).encode(self.encoding)
@classmethod
def from_ical(cls, ical):
ical_unesc = unescape_char(ical)
return cls(ical_unesc)
class vTime(TimeBase):
"""Render and generates iCalendar time format.
"""

Wyświetl plik

@ -0,0 +1,29 @@
BEGIN:VCALENDAR
VERSION:2.0
PRODID://RESEARCH IN MOTION//BIS 3.0
METHOD:REQUEST
BEGIN:VEVENT
UID:4.3.1
DTSTART;VALUE=DATE:20130210
RRULE:RSCALE=CHINESE;FREQ=YEARLY
SUMMARY:Chinese New Year
END:VEVENT
BEGIN:VEVENT
UID:4.3.2
DTSTART;VALUE=DATE:20130906
RRULE:RSCALE=ETHIOPIC;FREQ=MONTHLY;BYMONTH=13
SUMMARY:First day of 13th month
END:VEVENT
BEGIN:VEVENT
UID:4.3.3
DTSTART;VALUE=DATE:20140208
RRULE:RSCALE=HEBREW;FREQ=YEARLY;BYMONTH=5L;BYMONTHDAY=8;SKIP=FORWARD
SUMMARY:Anniversary
END:VEVENT
BEGIN:VEVENT
UID:4.3.4
DTSTART;VALUE=DATE:20120229
RRULE:RSCALE=GREGORIAN;FREQ=YEARLY;SKIP=FORWARD
SUMMARY:Anniversary
END:VEVENT
END:VCALENDAR

Wyświetl plik

@ -0,0 +1,72 @@
"""This tests the compatibility with RFC 7529.
See
- https://github.com/collective/icalendar/issues/653
- https://www.rfc-editor.org/rfc/rfc7529.html
"""
import pytest
from icalendar.prop import vRecur, vMonth, vSkip
@pytest.mark.parametrize(
"uid,scale",
[
("4.3.1", "CHINESE"),
("4.3.2", "ETHIOPIC"),
("4.3.3", "HEBREW"),
("4.3.4", "GREGORIAN"),
]
)
def test_rscale(calendars, uid, scale):
"""Check that the RSCALE is parsed correctly."""
event = calendars.rfc_7529.walk(select=lambda c: c.get("UID") == uid)[0]
print(event.errors)
rrule = event["RRULE"]
print(rrule)
assert rrule["RSCALE"] == [scale]
@pytest.mark.parametrize(
"uid,skip",
[
("4.3.2", None),
("4.3.3", ["FORWARD"]),
]
)
def test_rscale(calendars, uid, skip):
"""Check that the RSCALE is parsed correctly."""
event = calendars.rfc_7529.walk(select=lambda c: c.get("UID") == uid)[0]
recur = event["RRULE"]
assert recur.get("SKIP") == skip
def test_leap_month(calendars):
"""Check that we can parse the leap month."""
event = calendars.rfc_7529.walk(select=lambda c: c.get("UID") == "4.3.3")[0]
recur = event["RRULE"]
assert recur["BYMONTH"][0].leap is True
@pytest.mark.parametrize(
"ty, recur, ics",
[
(vRecur, vRecur(rscale="CHINESE", freq="YEARLY"), b"RSCALE=CHINESE;FREQ=YEARLY"),
(vRecur, vRecur(bymonth=vMonth(10)), b"BYMONTH=10"),
(vRecur, vRecur(bymonth=vMonth("5L")), b"BYMONTH=5L"),
(vMonth, vMonth(10), b"10"),
(vMonth, vMonth("5L"), b"5L"),
(vSkip, vSkip.OMIT, b"OMIT"),
(vSkip, vSkip.BACKWARD, b"BACKWARD"),
(vSkip, vSkip.FORWARD, b"FORWARD"),
(vSkip, vSkip("OMIT"), b"OMIT"),
(vSkip, vSkip("BACKWARD"), b"BACKWARD"),
(vSkip, vSkip("FORWARD"), b"FORWARD"),
(vRecur, vRecur(rscale="GREGORIAN", freq="YEARLY", skip='FORWARD'), b"RSCALE=GREGORIAN;FREQ=YEARLY;SKIP=FORWARD"),
(vRecur, vRecur(rscale="GREGORIAN", freq="YEARLY", skip=vSkip.FORWARD), b"RSCALE=GREGORIAN;FREQ=YEARLY;SKIP=FORWARD"),
]
)
def test_conversion(ty, recur, ics):
"""Test string conversion."""
assert recur.to_ical() == ics
assert ty.from_ical(ics.decode()) == recur
assert ty.from_ical(ics.decode()).to_ical() == ics