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