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