1"""PERIOD property type from :rfc:`5545`."""
2
3from datetime import date, datetime, timedelta, tzinfo
4from typing import Any, ClassVar
5from xml.etree.ElementTree import Element, SubElement
6
7from icalendar.compatibility import Self
8from icalendar.error import JCalParsingError, XCalParsingError
9from icalendar.parser import Parameters
10from icalendar.parser.xcal.base import XCalParser
11from icalendar.parser.xcal.wrapper import from_xcal_wrapper
12from icalendar.timezone import tzp
13from icalendar.tools import is_date, is_datetime, is_pytz, normalize_pytz, to_datetime
14
15from .base import TimeBase
16from .datetime import vDatetime
17from .duration import vDuration
18
19
20def _to_midnight(dt: date, tz: tzinfo | None) -> datetime:
21 """Convert a date to the datetime at midnight.
22
23 Parameters:
24 dt: The date to convert.
25 tz: The timezone of the result, or ``None`` for a naive result.
26
27 Returns:
28 The datetime at midnight.
29 """
30 midnight = to_datetime(dt)
31 if tz is None:
32 return midnight
33 if is_pytz(tz):
34 return tz.localize(midnight) # type: ignore[attr-defined]
35 return midnight.replace(tzinfo=tz)
36
37
38def _to_period_datetimes(
39 start: date | datetime, end_or_duration: date | datetime | timedelta
40) -> tuple[datetime, datetime | timedelta]:
41 """Convert the dates of a period to datetimes.
42
43 :rfc:`5545#section-3.3.9` builds a period from datetimes only, but calendars in the
44 wild use dates. A date becomes midnight so that the period can be used
45 and written back out.
46
47 A converted half takes the timezone of the other half. Without this, an
48 aware and a naive datetime could not be subtracted to compute the
49 duration.
50
51 Parameters:
52 start: The start of the period.
53 end_or_duration: The end of the period or its duration.
54
55 Returns:
56 The start and the end or duration, with any date converted.
57 """
58 if is_date(start):
59 start = _to_midnight(start, getattr(end_or_duration, "tzinfo", None))
60 if is_date(end_or_duration):
61 end_or_duration = _to_midnight(end_or_duration, getattr(start, "tzinfo", None))
62 return start, end_or_duration
63
64
65class vPeriod(TimeBase):
66 """A span of time, written either as a start and an end or as a start and a duration.
67
68 The value is a tuple of two :class:`datetime.datetime` objects, or of a
69 datetime and a :class:`datetime.timedelta`. Whichever way it was written,
70 :attr:`start`, :attr:`end`, and :attr:`duration` are all available, and
71 :attr:`by_duration` says which of the two forms is written back out.
72
73 Conforming with :rfc:`5545#section-3.3.9`, a period is built from datetimes
74 only, the start must come before the end, and a duration must be positive.
75 A half written as a date does not conform. Such a half is read as midnight,
76 in the timezone of the other half where it has one, so that a calendar that
77 gets this wrong can still be read and written.
78
79 ``FREEBUSY`` holds periods directly. ``RDATE`` holds them through
80 :class:`~icalendar.prop.dt.list.vDDDLists`, which splits a comma separated
81 list and hands each value to :class:`~icalendar.prop.dt.types.vDDDTypes`.
82 The halves themselves are parsed by
83 :class:`~icalendar.prop.dt.datetime.vDatetime` and
84 :class:`~icalendar.prop.dt.duration.vDuration`.
85
86 Parameters:
87 per: The start of the period, and either its end or its duration.
88 params: The parameters of the property.
89
90 Raises:
91 TypeError: If the start is not a date or a datetime, or if the end is
92 not a date, a datetime, or a duration.
93 ValueError: If the start is after the end.
94
95 Examples:
96 A period from 18:00:00 UTC on January 1, 1997 to 07:00:00 UTC on
97 January 2, 1997, and one that starts at 18:00:00 UTC and lasts 5 hours
98 and 30 minutes:
99
100 .. code-block:: ics
101
102 19970101T180000Z/19970102T070000Z
103 19970101T180000Z/PT5H30M
104
105 .. code-block:: pycon
106
107 >>> from icalendar.prop import vPeriod
108 >>> period = vPeriod.from_ical("19970101T180000Z/19970102T070000Z")
109 >>> vPeriod(period).to_ical()
110 b'19970101T180000Z/19970102T070000Z'
111 >>> period = vPeriod.from_ical("19970101T180000Z/PT5H30M")
112 >>> vPeriod(period).duration
113 datetime.timedelta(seconds=19800)
114
115 A half written as a date is read as midnight:
116
117 .. code-block:: pycon
118
119 >>> vPeriod(vPeriod.from_ical("19970101/19970102")).to_ical()
120 b'19970101T000000/19970102T000000'
121
122 .. versionchanged:: 7.3.0
123
124 A period written with dates is read as midnight.
125 """
126
127 default_value: ClassVar[str] = "PERIOD"
128 params: Parameters
129 #: Whether the value is written as a duration rather than as an end.
130 by_duration: bool
131 #: The start of the period.
132 start: datetime
133 #: The end of the period, computed from the duration where there is one.
134 end: datetime
135 #: The time between the start and the end.
136 duration: timedelta
137
138 def __init__(
139 self,
140 per: tuple[date | datetime, date | datetime | timedelta],
141 params: dict[str, Any] | None = None,
142 ) -> None:
143 start, end_or_duration = per
144 if not (isinstance(start, (datetime, date))):
145 raise TypeError("Start value MUST be a datetime or date instance")
146 if not (isinstance(end_or_duration, (datetime, date, timedelta))):
147 raise TypeError(
148 "end_or_duration MUST be a datetime, date or timedelta instance"
149 )
150 start, end_or_duration = _to_period_datetimes(start, end_or_duration)
151 by_duration = isinstance(end_or_duration, timedelta)
152 if by_duration:
153 duration = end_or_duration
154 end = normalize_pytz(start + duration)
155 else:
156 end = end_or_duration
157 duration = normalize_pytz(end - start)
158 if start > end:
159 raise ValueError("Start time is greater than end time")
160
161 self.params = Parameters(params or {"value": "PERIOD"})
162 # set the timezone identifier
163 # does not support different timezones for start and end
164 self.params.update_tzid_from(start)
165
166 self.start = start
167 self.end = end
168 self.by_duration = by_duration
169 self.duration = duration
170
171 def overlaps(self, other):
172 if self.start > other.start:
173 return other.overlaps(self)
174 return self.start <= other.start < self.end
175
176 def to_ical(self):
177 if self.by_duration:
178 return (
179 vDatetime(self.start).to_ical()
180 + b"/"
181 + vDuration(self.duration).to_ical()
182 )
183 return vDatetime(self.start).to_ical() + b"/" + vDatetime(self.end).to_ical()
184
185 @staticmethod
186 def from_ical(ical, timezone=None):
187 from icalendar.prop.dt.types import vDDDTypes
188
189 try:
190 start, end_or_duration = ical.split("/")
191 start = vDDDTypes.from_ical(start, timezone=timezone)
192 end_or_duration = vDDDTypes.from_ical(end_or_duration, timezone=timezone)
193 except Exception as e:
194 raise ValueError(f"Expected period format, got: {ical}") from e
195 return _to_period_datetimes(start, end_or_duration)
196
197 def __repr__(self):
198 p = (self.start, self.duration) if self.by_duration else (self.start, self.end)
199 return f"vPeriod({p!r})"
200
201 @property
202 def dt(self):
203 """Make this cooperate with the other vDDDTypes."""
204 return (self.start, (self.duration if self.by_duration else self.end))
205
206 @property
207 def ical_value(self) -> tuple[datetime, timedelta | datetime]:
208 """
209 Returns the period as a tuple of its start datetime
210 and either its end datetime or duration.
211 """
212 return self.dt
213
214 from icalendar.param import FBTYPE
215
216 @classmethod
217 def examples(cls) -> list[Self]:
218 """Examples of vPeriod."""
219 return [
220 vPeriod((datetime(2025, 11, 10, 16, 35), timedelta(hours=1, minutes=30))),
221 vPeriod((datetime(2025, 11, 10, 16, 35), datetime(2025, 11, 10, 18, 5))),
222 ]
223
224 from icalendar.param import VALUE
225
226 def to_jcal(self, name: str) -> list:
227 """The jCal representation of this property according to :rfc:`7265`."""
228 value = [vDatetime(self.start).to_jcal(name)[-1]]
229 if self.by_duration:
230 value.append(vDuration(self.duration).to_jcal(name)[-1])
231 else:
232 value.append(vDatetime(self.end).to_jcal(name)[-1])
233 return [name, self.params.to_jcal(exclude_utc=True), self.VALUE.lower(), value]
234
235 @classmethod
236 def parse_jcal_value(
237 cls, jcal: str | list
238 ) -> tuple[datetime, datetime] | tuple[datetime, timedelta]:
239 """Parse a jCal value.
240
241 Raises:
242 ~error.JCalParsingError: If the period is not a list with exactly two items,
243 or it can't parse a date-time or duration.
244 """
245 if isinstance(jcal, str) and "/" in jcal:
246 # only occurs in the example of RFC7265, Section B.2.2.
247 jcal = jcal.split("/")
248 if not isinstance(jcal, list) or len(jcal) != 2:
249 raise JCalParsingError(
250 "A period must be a list with exactly 2 items.", cls, value=jcal
251 )
252 with JCalParsingError.reraise_with_path_added(0):
253 start = vDatetime.parse_jcal_value(jcal[0])
254 with JCalParsingError.reraise_with_path_added(1):
255 JCalParsingError.validate_value_type(jcal[1], str, cls)
256 if jcal[1].startswith(("P", "-P", "+P")):
257 end_or_duration = vDuration.parse_jcal_value(jcal[1])
258 else:
259 try:
260 end_or_duration = vDatetime.parse_jcal_value(jcal[1])
261 except JCalParsingError as e:
262 raise JCalParsingError(
263 "Cannot parse date-time or duration.",
264 cls,
265 value=jcal[1],
266 ) from e
267 return start, end_or_duration
268
269 @classmethod
270 def from_jcal(cls, jcal_property: list) -> Self:
271 """Parse jCal from :rfc:`7265`.
272
273 Parameters:
274 jcal_property: The jCal property to parse.
275
276 Raises:
277 ~error.JCalParsingError: If the provided jCal is invalid.
278 """
279 JCalParsingError.validate_property(jcal_property, cls)
280 with JCalParsingError.reraise_with_path_added(3):
281 start, end_or_duration = cls.parse_jcal_value(jcal_property[3])
282 params = Parameters.from_jcal_property(jcal_property)
283 tzid = params.tzid
284
285 if tzid:
286 start = tzp.localize(start, tzid)
287 if is_datetime(end_or_duration):
288 end_or_duration = tzp.localize(end_or_duration, tzid)
289
290 return cls((start, end_or_duration), params=params)
291
292 def to_xcal(self, element: Element) -> None:
293 """The xCal representation of this property according to :rfc:`6321`."""
294 self.params.to_xcal(element)
295 period_element = SubElement(element, self.default_value.lower())
296 vDatetime(self.start).to_xcal_value_only(period_element, "start")
297 if self.by_duration:
298 vDuration(self.duration).to_xcal(period_element)
299 else:
300 vDatetime(self.end).to_xcal_value_only(period_element, "end")
301
302 @classmethod
303 @from_xcal_wrapper
304 def from_xcal(cls, parser: XCalParser, params: Parameters) -> Self:
305 """Parse xCal from :rfc:`6321`.
306
307 Parameters:
308 xml: The XML to parse or a parser.
309
310 Raises:
311 ~error.XCalParsingError: If the provided xCal is invalid.
312 """
313 element = parser.parse_tag(cls.default_value)
314 start_element = element.get_child_with_tag("start")
315 if start_element is None:
316 raise XCalParsingError("Expected <start>", None, element)
317 start = vDatetime.from_xcal_element(start_element)
318 tzid = params.tzid
319 if tzid:
320 start = tzp.localize(start, tzid)
321 end_element = element.get_child_with_tag("end")
322 if end_element:
323 end = vDatetime.from_xcal_element(end_element)
324 if tzid:
325 end = tzp.localize(end, tzid)
326 else:
327 duration_element = element.get_child_with_tag("duration")
328 if duration_element is None:
329 raise XCalParsingError("Expected <end> or <duration>", None, element)
330 end = vDuration.from_xcal(duration_element).td
331 if end < timedelta(0):
332 raise XCalParsingError(
333 "Expected positive duration",
334 duration_element.get_xsd_token(),
335 duration_element,
336 )
337 return cls((start, end), params=params)
338
339
340__all__ = ["vPeriod"]