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:: text
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:: text
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 from icalendar.param import FBTYPE
156
157 @classmethod
158 def examples(cls) -> list[Self]:
159 """Examples of vPeriod."""
160 return [
161 vPeriod((datetime(2025, 11, 10, 16, 35), timedelta(hours=1, minutes=30))),
162 vPeriod((datetime(2025, 11, 10, 16, 35), datetime(2025, 11, 10, 18, 5))),
163 ]
164
165 from icalendar.param import VALUE
166
167 def to_jcal(self, name: str) -> list:
168 """The jCal representation of this property according to :rfc:`7265`."""
169 value = [vDatetime(self.start).to_jcal(name)[-1]]
170 if self.by_duration:
171 value.append(vDuration(self.duration).to_jcal(name)[-1])
172 else:
173 value.append(vDatetime(self.end).to_jcal(name)[-1])
174 return [name, self.params.to_jcal(exclude_utc=True), self.VALUE.lower(), value]
175
176 @classmethod
177 def parse_jcal_value(
178 cls, jcal: str | list
179 ) -> tuple[datetime, datetime] | tuple[datetime, timedelta]:
180 """Parse a jCal value.
181
182 Raises:
183 ~error.JCalParsingError: If the period is not a list with exactly two items,
184 or it can't parse a date-time or duration.
185 """
186 if isinstance(jcal, str) and "/" in jcal:
187 # only occurs in the example of RFC7265, Section B.2.2.
188 jcal = jcal.split("/")
189 if not isinstance(jcal, list) or len(jcal) != 2:
190 raise JCalParsingError(
191 "A period must be a list with exactly 2 items.", cls, value=jcal
192 )
193 with JCalParsingError.reraise_with_path_added(0):
194 start = vDatetime.parse_jcal_value(jcal[0])
195 with JCalParsingError.reraise_with_path_added(1):
196 JCalParsingError.validate_value_type(jcal[1], str, cls)
197 if jcal[1].startswith(("P", "-P", "+P")):
198 end_or_duration = vDuration.parse_jcal_value(jcal[1])
199 else:
200 try:
201 end_or_duration = vDatetime.parse_jcal_value(jcal[1])
202 except JCalParsingError as e:
203 raise JCalParsingError(
204 "Cannot parse date-time or duration.",
205 cls,
206 value=jcal[1],
207 ) from e
208 return start, end_or_duration
209
210 @classmethod
211 def from_jcal(cls, jcal_property: list) -> Self:
212 """Parse jCal from :rfc:`7265`.
213
214 Parameters:
215 jcal_property: The jCal property to parse.
216
217 Raises:
218 ~error.JCalParsingError: If the provided jCal is invalid.
219 """
220 JCalParsingError.validate_property(jcal_property, cls)
221 with JCalParsingError.reraise_with_path_added(3):
222 start, end_or_duration = cls.parse_jcal_value(jcal_property[3])
223 params = Parameters.from_jcal_property(jcal_property)
224 tzid = params.tzid
225
226 if tzid:
227 start = tzp.localize(start, tzid)
228 if is_datetime(end_or_duration):
229 end_or_duration = tzp.localize(end_or_duration, tzid)
230
231 return cls((start, end_or_duration), params=params)
232
233
234__all__ = ["vPeriod"]