kopia lustrzana https://github.com/collective/icalendar
commit
02bd0198ea
|
@ -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.
|
||||
|
|
|
@ -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
|
||||
--------------
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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.
|
||||
"""
|
||||
|
|
|
@ -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.
|
||||
"""
|
||||
|
|
|
@ -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
|
|
@ -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
|
Ładowanie…
Reference in New Issue