Skip to content

Commit

Permalink
Merge pull request #667 from niccokunzmann/rfc-7529
Browse files Browse the repository at this point in the history
RFC 7529 compatibility
  • Loading branch information
niccokunzmann authored Jul 3, 2024
2 parents 0e93f36 + dfb4254 commit 02bd019
Show file tree
Hide file tree
Showing 7 changed files with 265 additions and 45 deletions.
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

0 comments on commit 02bd019

Please sign in to comment.