1"""DURATION property type from :rfc:`5545`."""
2
3import re
4from datetime import timedelta
5from typing import Any, ClassVar
6from xml.etree.ElementTree import Element, SubElement
7
8from icalendar.compatibility import Self
9from icalendar.error import InvalidCalendar, JCalParsingError, XCalParsingError
10from icalendar.parser import Parameters
11from icalendar.parser.xcal.base import XCalParser
12from icalendar.parser.xcal.wrapper import from_xcal_wrapper
13
14from .base import TimeBase
15
16DURATION_REGEX = re.compile(
17 r"([-+]?)P(?:(\d+)W)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?\Z"
18)
19
20XCAL_DURATION_ERROR = "Expected duration format https://datatracker.ietf.org/doc/html/rfc5545#section-3.3.6"
21
22
23class vDuration(TimeBase):
24 """Duration
25
26 Value Name:
27 DURATION
28
29 Purpose:
30 This value type is used to identify properties that contain
31 a duration of time.
32
33 Format Definition:
34 This value type is defined by the following notation:
35
36 .. code-block:: text
37
38 dur-value = (["+"] / "-") "P" (dur-date / dur-time / dur-week)
39
40 dur-date = dur-day [dur-time]
41 dur-time = "T" (dur-hour / dur-minute / dur-second)
42 dur-week = 1*DIGIT "W"
43 dur-hour = 1*DIGIT "H" [dur-minute]
44 dur-minute = 1*DIGIT "M" [dur-second]
45 dur-second = 1*DIGIT "S"
46 dur-day = 1*DIGIT "D"
47
48 Description:
49 If the property permits, multiple "duration" values are
50 specified by a COMMA-separated list of values. The format is
51 based on the [ISO.8601.2004] complete representation basic format
52 with designators for the duration of time. The format can
53 represent nominal durations (weeks and days) and accurate
54 durations (hours, minutes, and seconds). Note that unlike
55 [ISO.8601.2004], this value type doesn't support the "Y" and "M"
56 designators to specify durations in terms of years and months.
57 The duration of a week or a day depends on its position in the
58 calendar. In the case of discontinuities in the time scale, such
59 as the change from standard time to daylight time and back, the
60 computation of the exact duration requires the subtraction or
61 addition of the change of duration of the discontinuity. Leap
62 seconds MUST NOT be considered when computing an exact duration.
63 When computing an exact duration, the greatest order time
64 components MUST be added first, that is, the number of days MUST
65 be added first, followed by the number of hours, number of
66 minutes, and number of seconds.
67
68 Example:
69 A duration of 15 days, 5 hours, and 20 seconds would be:
70
71 .. code-block:: ics
72
73 P15DT5H0M20S
74
75 A duration of 7 weeks would be:
76
77 .. code-block:: ics
78
79 P7W
80
81 .. code-block:: pycon
82
83 >>> from icalendar.prop import vDuration
84 >>> duration = vDuration.from_ical('P15DT5H0M20S')
85 >>> duration
86 datetime.timedelta(days=15, seconds=18020)
87 >>> duration = vDuration.from_ical('P7W')
88 >>> duration
89 datetime.timedelta(days=49)
90 """
91
92 default_value: ClassVar[str] = "DURATION"
93 params: Parameters
94
95 def __init__(
96 self, td: timedelta | str, /, params: dict[str, Any] | None = None
97 ) -> None:
98 if isinstance(td, str):
99 td = vDuration.from_ical(td)
100 if not isinstance(td, timedelta):
101 raise TypeError("Value MUST be a timedelta instance")
102 self.td = td
103 self.params = Parameters(params)
104
105 def to_ical(self):
106 sign = ""
107 td = self.td
108 if td.days < 0:
109 sign = "-"
110 td = -td
111 timepart = ""
112 if td.seconds:
113 timepart = "T"
114 hours = td.seconds // 3600
115 minutes = td.seconds % 3600 // 60
116 seconds = td.seconds % 60
117 if hours:
118 timepart += f"{hours}H"
119 if minutes or (hours and seconds):
120 timepart += f"{minutes}M"
121 if seconds:
122 timepart += f"{seconds}S"
123 if td.days == 0 and timepart:
124 return str(sign).encode("utf-8") + b"P" + str(timepart).encode("utf-8")
125 return (
126 str(sign).encode("utf-8")
127 + b"P"
128 + str(abs(td.days)).encode("utf-8")
129 + b"D"
130 + str(timepart).encode("utf-8")
131 )
132
133 @staticmethod
134 def from_ical(ical):
135 match = DURATION_REGEX.match(ical)
136 if not match:
137 raise InvalidCalendar(f"Invalid iCalendar duration: {ical}")
138
139 sign, weeks, days, hours, minutes, seconds = match.groups()
140 try:
141 value = timedelta(
142 weeks=int(weeks or 0),
143 days=int(days or 0),
144 hours=int(hours or 0),
145 minutes=int(minutes or 0),
146 seconds=int(seconds or 0),
147 )
148 except OverflowError as e:
149 # ``timedelta`` rejects values that are too large for its C
150 # implementation. Raise the same error as other invalid durations
151 # instead of leaking ``OverflowError`` to callers.
152 raise InvalidCalendar(f"Impractical iCalendar duration: {ical}") from e
153
154 if sign == "-":
155 value = -value
156
157 return value
158
159 @property
160 def dt(self) -> timedelta:
161 """The time delta for compatibility."""
162 return self.td
163
164 @classmethod
165 def examples(cls) -> list[Self]:
166 """Examples of vDuration."""
167 return [cls(timedelta(1, 99))]
168
169 from icalendar.param import VALUE
170
171 def to_jcal(self, name: str) -> list:
172 """The jCal representation of this property according to :rfc:`7265`."""
173 return [
174 name,
175 self.params.to_jcal(),
176 self.VALUE.lower(),
177 self.to_ical().decode(),
178 ]
179
180 @classmethod
181 def parse_jcal_value(cls, jcal: str) -> timedelta:
182 """Parse a jCal string to a :class:`datetime.timedelta`.
183
184 Raises:
185 ~error.JCalParsingError: If it can't parse a duration."""
186 JCalParsingError.validate_value_type(jcal, str, cls)
187 try:
188 return cls.from_ical(jcal)
189 except (ValueError, InvalidCalendar) as e:
190 raise JCalParsingError("Cannot parse duration.", cls, value=jcal) from e
191
192 @classmethod
193 def from_jcal(cls, jcal_property: list) -> Self:
194 """Parse jCal from :rfc:`7265`.
195
196 Parameters:
197 jcal_property: The jCal property to parse.
198
199 Raises:
200 ~error.JCalParsingError: If the provided jCal is invalid.
201 """
202 JCalParsingError.validate_property(jcal_property, cls)
203 with JCalParsingError.reraise_with_path_added(3):
204 duration = cls.parse_jcal_value(jcal_property[3])
205 return cls(
206 duration,
207 Parameters.from_jcal_property(jcal_property),
208 )
209
210 def to_xcal(self, element: Element) -> None:
211 """The xCal representation of this property according to :rfc:`6321`."""
212 self.params.to_xcal(element)
213 element = SubElement(element, "duration")
214 element.text = self.to_ical().decode()
215
216 @classmethod
217 @from_xcal_wrapper
218 def from_xcal(cls, parser: XCalParser, params: Parameters) -> Self:
219 """Parse xCal from :rfc:`6321`.
220
221 Parameters:
222 xml: The XML to parse or a parser.
223
224 Raises:
225 ~error.XCalParsingError: If the provided xCal is invalid.
226 """
227 element = parser.parse_tag(cls.default_value)
228 try:
229 td = cls.from_ical(element.get_xsd_token())
230 except InvalidCalendar as e:
231 if isinstance(e.__cause__, OverflowError):
232 # The syntax is valid but we are out of range
233 raise XCalParsingError(
234 "Duration is out of range", element.get_xsd_token(), element
235 ) from e
236 raise XCalParsingError(
237 XCAL_DURATION_ERROR, element.get_xsd_token(), element
238 ) from e
239 return cls(td, params=params)
240
241
242__all__ = ["vDuration"]