Source code for icalendar.prop.dt.period

"""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"]