1"""PERIOD property type from :rfc:`5545`."""
2
3from datetime import date, datetime, timedelta
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_datetime, normalize_pytz
11
12from .base import TimeBase
13from .datetime import vDatetime
14from .duration import vDuration
15
16
17class vPeriod(TimeBase):
18 """Period of Time
19
20 Value Name:
21 PERIOD
22
23 Purpose:
24 This value type is used to identify values that contain a
25 precise period of time.
26
27 Format Definition:
28 This value type is defined by the following notation:
29
30 .. code-block:: text
31
32 period = period-explicit / period-start
33
34 period-explicit = date-time "/" date-time
35 ; [ISO.8601.2004] complete representation basic format for a
36 ; period of time consisting of a start and end. The start MUST
37 ; be before the end.
38
39 period-start = date-time "/" dur-value
40 ; [ISO.8601.2004] complete representation basic format for a
41 ; period of time consisting of a start and positive duration
42 ; of time.
43
44 Description:
45 If the property permits, multiple "period" values are
46 specified by a COMMA-separated list of values. There are two
47 forms of a period of time. First, a period of time is identified
48 by its start and its end. This format is based on the
49 [ISO.8601.2004] complete representation, basic format for "DATE-
50 TIME" start of the period, followed by a SOLIDUS character
51 followed by the "DATE-TIME" of the end of the period. The start
52 of the period MUST be before the end of the period. Second, a
53 period of time can also be defined by a start and a positive
54 duration of time. The format is based on the [ISO.8601.2004]
55 complete representation, basic format for the "DATE-TIME" start of
56 the period, followed by a SOLIDUS character, followed by the
57 [ISO.8601.2004] basic format for "DURATION" of the period.
58
59 Example:
60 The period starting at 18:00:00 UTC, on January 1, 1997 and
61 ending at 07:00:00 UTC on January 2, 1997 would be:
62
63 .. code-block:: ics
64
65 19970101T180000Z/19970102T070000Z
66
67 The period start at 18:00:00 on January 1, 1997 and lasting 5 hours
68 and 30 minutes would be:
69
70 .. code-block:: ics
71
72 19970101T180000Z/PT5H30M
73
74 .. code-block:: pycon
75
76 >>> from icalendar.prop import vPeriod
77 >>> period = vPeriod.from_ical('19970101T180000Z/19970102T070000Z')
78 >>> period = vPeriod.from_ical('19970101T180000Z/PT5H30M')
79 """
80
81 default_value: ClassVar[str] = "PERIOD"
82 params: Parameters
83 by_duration: bool
84 start: datetime
85 end: datetime
86 duration: timedelta
87
88 def __init__(
89 self,
90 per: tuple[datetime, datetime | timedelta],
91 params: dict[str, Any] | None = None,
92 ):
93 start, end_or_duration = per
94 if not (isinstance(start, (datetime, date))):
95 raise TypeError("Start value MUST be a datetime or date instance")
96 if not (isinstance(end_or_duration, (datetime, date, timedelta))):
97 raise TypeError(
98 "end_or_duration MUST be a datetime, date or timedelta instance"
99 )
100 by_duration = isinstance(end_or_duration, timedelta)
101 if by_duration:
102 duration = end_or_duration
103 end = normalize_pytz(start + duration)
104 else:
105 end = end_or_duration
106 duration = normalize_pytz(end - start)
107 if start > end:
108 raise ValueError("Start time is greater than end time")
109
110 self.params = Parameters(params or {"value": "PERIOD"})
111 # set the timezone identifier
112 # does not support different timezones for start and end
113 self.params.update_tzid_from(start)
114
115 self.start = start
116 self.end = end
117 self.by_duration = by_duration
118 self.duration = duration
119
120 def overlaps(self, other):
121 if self.start > other.start:
122 return other.overlaps(self)
123 return self.start <= other.start < self.end
124
125 def to_ical(self):
126 if self.by_duration:
127 return (
128 vDatetime(self.start).to_ical()
129 + b"/"
130 + vDuration(self.duration).to_ical()
131 )
132 return vDatetime(self.start).to_ical() + b"/" + vDatetime(self.end).to_ical()
133
134 @staticmethod
135 def from_ical(ical, timezone=None):
136 from icalendar.prop.dt.types import vDDDTypes
137
138 try:
139 start, end_or_duration = ical.split("/")
140 start = vDDDTypes.from_ical(start, timezone=timezone)
141 end_or_duration = vDDDTypes.from_ical(end_or_duration, timezone=timezone)
142 except Exception as e:
143 raise ValueError(f"Expected period format, got: {ical}") from e
144 return (start, end_or_duration)
145
146 def __repr__(self):
147 p = (self.start, self.duration) if self.by_duration else (self.start, self.end)
148 return f"vPeriod({p!r})"
149
150 @property
151 def dt(self):
152 """Make this cooperate with the other vDDDTypes."""
153 return (self.start, (self.duration if self.by_duration else self.end))
154
155 @property
156 def ical_value(self) -> tuple[datetime, timedelta | datetime]:
157 """
158 Returns the period as a tuple of its start datetime
159 and either its end datetime or duration.
160 """
161 return self.dt
162
163 from icalendar.param import FBTYPE
164
165 @classmethod
166 def examples(cls) -> list[Self]:
167 """Examples of vPeriod."""
168 return [
169 vPeriod((datetime(2025, 11, 10, 16, 35), timedelta(hours=1, minutes=30))),
170 vPeriod((datetime(2025, 11, 10, 16, 35), datetime(2025, 11, 10, 18, 5))),
171 ]
172
173 from icalendar.param import VALUE
174
175 def to_jcal(self, name: str) -> list:
176 """The jCal representation of this property according to :rfc:`7265`."""
177 value = [vDatetime(self.start).to_jcal(name)[-1]]
178 if self.by_duration:
179 value.append(vDuration(self.duration).to_jcal(name)[-1])
180 else:
181 value.append(vDatetime(self.end).to_jcal(name)[-1])
182 return [name, self.params.to_jcal(exclude_utc=True), self.VALUE.lower(), value]
183
184 @classmethod
185 def parse_jcal_value(
186 cls, jcal: str | list
187 ) -> tuple[datetime, datetime] | tuple[datetime, timedelta]:
188 """Parse a jCal value.
189
190 Raises:
191 ~error.JCalParsingError: If the period is not a list with exactly two items,
192 or it can't parse a date-time or duration.
193 """
194 if isinstance(jcal, str) and "/" in jcal:
195 # only occurs in the example of RFC7265, Section B.2.2.
196 jcal = jcal.split("/")
197 if not isinstance(jcal, list) or len(jcal) != 2:
198 raise JCalParsingError(
199 "A period must be a list with exactly 2 items.", cls, value=jcal
200 )
201 with JCalParsingError.reraise_with_path_added(0):
202 start = vDatetime.parse_jcal_value(jcal[0])
203 with JCalParsingError.reraise_with_path_added(1):
204 JCalParsingError.validate_value_type(jcal[1], str, cls)
205 if jcal[1].startswith(("P", "-P", "+P")):
206 end_or_duration = vDuration.parse_jcal_value(jcal[1])
207 else:
208 try:
209 end_or_duration = vDatetime.parse_jcal_value(jcal[1])
210 except JCalParsingError as e:
211 raise JCalParsingError(
212 "Cannot parse date-time or duration.",
213 cls,
214 value=jcal[1],
215 ) from e
216 return start, end_or_duration
217
218 @classmethod
219 def from_jcal(cls, jcal_property: list) -> Self:
220 """Parse jCal from :rfc:`7265`.
221
222 Parameters:
223 jcal_property: The jCal property to parse.
224
225 Raises:
226 ~error.JCalParsingError: If the provided jCal is invalid.
227 """
228 JCalParsingError.validate_property(jcal_property, cls)
229 with JCalParsingError.reraise_with_path_added(3):
230 start, end_or_duration = cls.parse_jcal_value(jcal_property[3])
231 params = Parameters.from_jcal_property(jcal_property)
232 tzid = params.tzid
233
234 if tzid:
235 start = tzp.localize(start, tzid)
236 if is_datetime(end_or_duration):
237 end_or_duration = tzp.localize(end_or_duration, tzid)
238
239 return cls((start, end_or_duration), params=params)
240
241
242__all__ = ["vPeriod"]