Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

RFC 7529 compatibility #667

Merged
merged 13 commits into from
Jul 3, 2024
3 changes: 3 additions & 0 deletions CHANGES.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
17 changes: 17 additions & 0 deletions docs/usage.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
--------------
Expand Down
16 changes: 11 additions & 5 deletions src/icalendar/cal.py
Original file line number Diff line number Diff line change
Expand Up @@ -275,23 +275,29 @@ def add_component(self, component):
"""
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
Expand Down
9 changes: 5 additions & 4 deletions src/icalendar/parser_tools.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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):
Expand All @@ -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.
"""
Expand Down
164 changes: 128 additions & 36 deletions src/icalendar/prop.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,18 +44,18 @@
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
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)?'
Expand Down Expand Up @@ -126,6 +126,29 @@ def from_ical(cls, ical):
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.
"""
Expand Down Expand Up @@ -176,11 +199,11 @@ def __new__(cls, *args, **kwargs):
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:
Expand Down Expand Up @@ -610,6 +633,92 @@ def from_ical(cls, ical):
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.
"""
Expand All @@ -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,
Expand All @@ -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()

Expand All @@ -667,7 +780,7 @@ def parse_type(cls, key, values):
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:
Expand All @@ -680,34 +793,13 @@ def from_ical(cls, ical):
# 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.
"""
Expand Down
29 changes: 29 additions & 0 deletions src/icalendar/tests/calendars/rfc_7529.ics
Original file line number Diff line number Diff line change
@@ -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
Loading