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