"""PERIOD property type from :rfc:`5545`."""
from __future__ import annotations
from datetime import date, datetime, timedelta, tzinfo
from typing import TYPE_CHECKING, Any, ClassVar
from xml.etree.ElementTree import Element, SubElement
from icalendar.compatibility import Self
from icalendar.error import JCalParsingError, XCalParsingError
from icalendar.parser import Parameters
from icalendar.parser.xcal.base import XCalParser
from icalendar.parser.xcal.wrapper import from_xcal_wrapper
from icalendar.timezone import tzp
from icalendar.tools import is_date, is_datetime, is_pytz, normalize_pytz, to_datetime
from .base import TimeBase
from .datetime import vDatetime
from .duration import vDuration
if TYPE_CHECKING:
from icalendar.compatibility import Self
from icalendar.parser.xcal.base import XCalParser
def _to_midnight(dt: date, tz: tzinfo | None) -> datetime:
"""Convert a date to the datetime at midnight.
Parameters:
dt: The date to convert.
tz: The timezone of the result, or ``None`` for a naive result.
Returns:
The datetime at midnight.
"""
midnight = to_datetime(dt)
if tz is None:
return midnight
if is_pytz(tz):
return tz.localize(midnight) # type: ignore[attr-defined]
return midnight.replace(tzinfo=tz)
def _to_period_datetimes(
start: date | datetime, end_or_duration: date | datetime | timedelta
) -> tuple[datetime, datetime | timedelta]:
"""Convert the dates of a period to datetimes.
:rfc:`5545#section-3.3.9` builds a period from datetimes only, but calendars in the
wild use dates. A date becomes midnight so that the period can be used
and written back out.
A converted half takes the timezone of the other half. Without this, an
aware and a naive datetime could not be subtracted to compute the
duration.
Parameters:
start: The start of the period.
end_or_duration: The end of the period or its duration.
Returns:
The start and the end or duration, with any date converted.
"""
if is_date(start):
start = _to_midnight(start, getattr(end_or_duration, "tzinfo", None))
if is_date(end_or_duration):
end_or_duration = _to_midnight(end_or_duration, getattr(start, "tzinfo", None))
return start, end_or_duration
[docs]
class vPeriod(TimeBase):
"""A span of time, written either as a start and an end or as a start and a duration.
The value is a tuple of two :class:`datetime.datetime` objects, or of a
datetime and a :class:`datetime.timedelta`. Whichever way it was written,
:attr:`start`, :attr:`end`, and :attr:`duration` are all available, and
:attr:`by_duration` says which of the two forms is written back out.
Conforming with :rfc:`5545#section-3.3.9`, a period is built from datetimes
only, the start must come before the end, and a duration must be positive.
A half written as a date does not conform. Such a half is read as midnight,
in the timezone of the other half where it has one, so that a calendar that
gets this wrong can still be read and written.
``FREEBUSY`` holds periods directly. ``RDATE`` holds them through
:class:`~icalendar.prop.dt.list.vDDDLists`, which splits a comma separated
list and hands each value to :class:`~icalendar.prop.dt.types.vDDDTypes`.
The halves themselves are parsed by
:class:`~icalendar.prop.dt.datetime.vDatetime` and
:class:`~icalendar.prop.dt.duration.vDuration`.
Parameters:
per: The start of the period, and either its end or its duration.
params: The parameters of the property.
Raises:
TypeError: If the start is not a date or a datetime, or if the end is
not a date, a datetime, or a duration.
ValueError: If the start is after the end.
Examples:
A period from 18:00:00 UTC on January 1, 1997 to 07:00:00 UTC on
January 2, 1997, and one that starts at 18:00:00 UTC and lasts 5 hours
and 30 minutes:
.. code-block:: ics
19970101T180000Z/19970102T070000Z
19970101T180000Z/PT5H30M
.. code-block:: pycon
>>> from icalendar.prop import vPeriod
>>> period = vPeriod.from_ical("19970101T180000Z/19970102T070000Z")
>>> vPeriod(period).to_ical()
b'19970101T180000Z/19970102T070000Z'
>>> period = vPeriod.from_ical("19970101T180000Z/PT5H30M")
>>> vPeriod(period).duration
datetime.timedelta(seconds=19800)
A half written as a date is read as midnight:
.. code-block:: pycon
>>> vPeriod(vPeriod.from_ical("19970101/19970102")).to_ical()
b'19970101T000000/19970102T000000'
.. versionchanged:: 7.3.0
A period written with dates is read as midnight.
"""
default_value: ClassVar[str] = "PERIOD"
params: Parameters
#: Whether the value is written as a duration rather than as an end.
by_duration: bool
#: The start of the period.
start: datetime
#: The end of the period, computed from the duration where there is one.
end: datetime
#: The time between the start and the end.
duration: timedelta
def __init__(
self,
per: tuple[date | datetime, date | datetime | timedelta],
params: dict[str, Any] | None = None,
) -> None:
start, end_or_duration = per
if not (isinstance(start, (datetime, date))):
raise TypeError("Start value MUST be a datetime or date instance")
if not (isinstance(end_or_duration, (datetime, date, timedelta))):
raise TypeError(
"end_or_duration MUST be a datetime, date or timedelta instance"
)
start, end_or_duration = _to_period_datetimes(start, end_or_duration)
by_duration = isinstance(end_or_duration, timedelta)
if by_duration:
duration = end_or_duration
end = normalize_pytz(start + duration)
else:
end = end_or_duration
duration = normalize_pytz(end - start)
if start > end:
raise ValueError("Start time is greater than end time")
self.params = Parameters(params or {"value": "PERIOD"})
# set the timezone identifier
# does not support different timezones for start and end
self.params.update_tzid_from(start)
self.start = start
self.end = end
self.by_duration = by_duration
self.duration = duration
[docs]
def overlaps(self, other: vPeriod) -> bool:
if self.start > other.start:
return other.overlaps(self)
return self.start <= other.start < self.end
[docs]
def to_ical(self) -> bytes:
if self.by_duration:
return (
vDatetime(self.start).to_ical()
+ b"/"
+ vDuration(self.duration).to_ical()
)
return vDatetime(self.start).to_ical() + b"/" + vDatetime(self.end).to_ical()
[docs]
@staticmethod
def from_ical(ical, timezone=None):
from icalendar.prop.dt.types import vDDDTypes
try:
start, end_or_duration = ical.split("/")
start = vDDDTypes.from_ical(start, timezone=timezone)
end_or_duration = vDDDTypes.from_ical(end_or_duration, timezone=timezone)
except Exception as e:
raise ValueError(f"Expected period format, got: {ical}") from e
return _to_period_datetimes(start, end_or_duration)
def __repr__(self) -> str:
p = (self.start, self.duration) if self.by_duration else (self.start, self.end)
return f"vPeriod({p!r})"
@property
def dt(self):
"""Make this cooperate with the other vDDDTypes."""
return (self.start, (self.duration if self.by_duration else self.end))
@property
def ical_value(self) -> tuple[datetime, timedelta | datetime]:
"""
Returns the period as a tuple of its start datetime
and either its end datetime or duration.
"""
return self.dt
from icalendar.param import FBTYPE
[docs]
@classmethod
def examples(cls) -> list[Self]:
"""Examples of vPeriod."""
return [
vPeriod((datetime(2025, 11, 10, 16, 35), timedelta(hours=1, minutes=30))),
vPeriod((datetime(2025, 11, 10, 16, 35), datetime(2025, 11, 10, 18, 5))),
]
from icalendar.param import VALUE
[docs]
def to_jcal(self, name: str) -> list:
"""The jCal representation of this property according to :rfc:`7265`."""
value = [vDatetime(self.start).to_jcal(name)[-1]]
if self.by_duration:
value.append(vDuration(self.duration).to_jcal(name)[-1])
else:
value.append(vDatetime(self.end).to_jcal(name)[-1])
return [name, self.params.to_jcal(exclude_utc=True), self.VALUE.lower(), value]
[docs]
@classmethod
def parse_jcal_value(
cls, jcal: str | list
) -> tuple[datetime, datetime] | tuple[datetime, timedelta]:
"""Parse a jCal value.
Raises:
~icalendar.error.JCalParsingError: If the period is not a list with exactly two items,
or it can't parse a date-time or duration.
"""
if isinstance(jcal, str) and "/" in jcal:
# only occurs in the example of RFC7265, Section B.2.2.
jcal = jcal.split("/")
if not isinstance(jcal, list) or len(jcal) != 2:
raise JCalParsingError(
"A period must be a list with exactly 2 items.", cls, value=jcal
)
with JCalParsingError.reraise_with_path_added(0):
start = vDatetime.parse_jcal_value(jcal[0])
with JCalParsingError.reraise_with_path_added(1):
JCalParsingError.validate_value_type(jcal[1], str, cls)
if jcal[1].startswith(("P", "-P", "+P")):
end_or_duration = vDuration.parse_jcal_value(jcal[1])
else:
try:
end_or_duration = vDatetime.parse_jcal_value(jcal[1])
except JCalParsingError as e:
raise JCalParsingError(
"Cannot parse date-time or duration.",
cls,
value=jcal[1],
) from e
return start, end_or_duration
[docs]
@classmethod
def from_jcal(cls, jcal_property: list) -> Self:
"""Parse jCal from :rfc:`7265`.
Parameters:
jcal_property: The jCal property to parse.
Raises:
~icalendar.error.JCalParsingError: If the provided jCal is invalid.
"""
JCalParsingError.validate_property(jcal_property, cls)
with JCalParsingError.reraise_with_path_added(3):
start, end_or_duration = cls.parse_jcal_value(jcal_property[3])
params = Parameters.from_jcal_property(jcal_property)
tzid = params.tzid
if tzid:
start = tzp.localize(start, tzid)
if is_datetime(end_or_duration):
end_or_duration = tzp.localize(end_or_duration, tzid)
return cls((start, end_or_duration), params=params)
[docs]
def to_xcal(self, element: Element) -> None:
"""The xCal representation of this property according to :rfc:`6321`."""
self.params.to_xcal(element)
period_element = SubElement(element, self.default_value.lower())
vDatetime(self.start).to_xcal_value_only(period_element, "start")
if self.by_duration:
vDuration(self.duration).to_xcal(period_element)
else:
vDatetime(self.end).to_xcal_value_only(period_element, "end")
[docs]
@classmethod
@from_xcal_wrapper
def from_xcal(cls, parser: XCalParser, params: Parameters) -> Self:
"""Parse xCal from :rfc:`6321`.
Parameters:
xml: The XML to parse or a parser.
Raises:
~error.XCalParsingError: If the provided xCal is invalid.
"""
element = parser.parse_tag(cls.default_value)
start_element = element.get_child_with_tag("start")
if start_element is None:
raise XCalParsingError("Expected <start>", None, element)
start = vDatetime.from_xcal_element(start_element)
tzid = params.tzid
if tzid:
start = tzp.localize(start, tzid)
end_element = element.get_child_with_tag("end")
if end_element:
end = vDatetime.from_xcal_element(end_element)
if tzid:
end = tzp.localize(end, tzid)
else:
duration_element = element.get_child_with_tag("duration")
if duration_element is None:
raise XCalParsingError("Expected <end> or <duration>", None, element)
end = vDuration.from_xcal(duration_element).td
if end < timedelta(0):
raise XCalParsingError(
"Expected positive duration",
duration_element.get_xsd_token(),
duration_element,
)
return cls((start, end), params=params)
__all__ = ["vPeriod"]