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