1"""TIME property type from :rfc:`5545`."""
2
3import re
4from datetime import datetime, time, timezone, tzinfo
5from typing import Any, ClassVar
6from xml.etree.ElementTree import Element, SubElement
7
8from icalendar.compatibility import Self
9from icalendar.error import JCalParsingError, XCalParsingError
10from icalendar.parser import Parameters
11from icalendar.parser.xcal.base import XCalParser
12from icalendar.parser.xcal.match import XCalRegexMatcher
13from icalendar.parser.xcal.wrapper import from_xcal_wrapper
14from icalendar.timezone import tzp
15from icalendar.timezone.tzid import is_utc
16
17from .base import TimeBase
18
19TIME_JCAL_REGEX = re.compile(
20 r"^(?P<hour>[0-9]{2}):(?P<minute>[0-9]{2}):(?P<second>[0-9]{2})(?P<utc>Z)?\Z"
21)
22
23XCAL_TIME_REGEX = XCalRegexMatcher(
24 TIME_JCAL_REGEX,
25 "Expected time format HH:MM:SS or HH:MM:SSZ",
26)
27
28
29class vTime(TimeBase):
30 """Time
31
32 Value Name:
33 TIME
34
35 Purpose:
36 This value type is used to identify values that contain a
37 time of day.
38
39 Format Definition:
40 This value type is defined by the following notation:
41
42 .. code-block:: text
43
44 time = time-hour time-minute time-second [time-utc]
45
46 time-hour = 2DIGIT ;00-23
47 time-minute = 2DIGIT ;00-59
48 time-second = 2DIGIT ;00-60
49 ;The "60" value is used to account for positive "leap" seconds.
50
51 time-utc = "Z"
52
53 Description:
54 If the property permits, multiple "time" values are
55 specified by a COMMA-separated list of values. No additional
56 content value encoding (i.e., BACKSLASH character encoding, see
57 vText) is defined for this value type.
58
59 The "TIME" value type is used to identify values that contain a
60 time of day. The format is based on the [ISO.8601.2004] complete
61 representation, basic format for a time of day. The text format
62 consists of a two-digit, 24-hour of the day (i.e., values 00-23),
63 two-digit minute in the hour (i.e., values 00-59), and two-digit
64 seconds in the minute (i.e., values 00-60). The seconds value of
65 60 MUST only be used to account for positive "leap" seconds.
66 Fractions of a second are not supported by this format.
67
68 In parallel to the "DATE-TIME" definition above, the "TIME" value
69 type expresses time values in three forms:
70
71 The form of time with UTC offset MUST NOT be used. For example,
72 the following is not valid for a time value:
73
74 .. code-block:: ics
75
76 230000-0800 ;Invalid time format
77
78 **FORM #1 LOCAL TIME**
79
80 The local time form is simply a time value that does not contain
81 the UTC designator nor does it reference a time zone. For
82 example, 11:00 PM:
83
84 .. code-block:: ics
85
86 230000
87
88 Time values of this type are said to be "floating" and are not
89 bound to any time zone in particular. They are used to represent
90 the same hour, minute, and second value regardless of which time
91 zone is currently being observed. For example, an event can be
92 defined that indicates that an individual will be busy from 11:00
93 AM to 1:00 PM every day, no matter which time zone the person is
94 in. In these cases, a local time can be specified. The recipient
95 of an iCalendar object with a property value consisting of a local
96 time, without any relative time zone information, SHOULD interpret
97 the value as being fixed to whatever time zone the "ATTENDEE" is
98 in at any given moment. This means that two "Attendees", may
99 participate in the same event at different UTC times; floating
100 time SHOULD only be used where that is reasonable behavior.
101
102 In most cases, a fixed time is desired. To properly communicate a
103 fixed time in a property value, either UTC time or local time with
104 time zone reference MUST be specified.
105
106 The use of local time in a TIME value without the "TZID" property
107 parameter is to be interpreted as floating time, regardless of the
108 existence of "VTIMEZONE" calendar components in the iCalendar
109 object.
110
111 **FORM #2: UTC TIME**
112
113 UTC time, or absolute time, is identified by a LATIN CAPITAL
114 LETTER Z suffix character, the UTC designator, appended to the
115 time value. For example, the following represents 07:00 AM UTC:
116
117 .. code-block:: ics
118
119 070000Z
120
121 The "TZID" property parameter MUST NOT be applied to TIME
122 properties whose time values are specified in UTC.
123
124 **FORM #3: LOCAL TIME AND TIME ZONE REFERENCE**
125
126 The local time with reference to time zone information form is
127 identified by the use the "TZID" property parameter to reference
128 the appropriate time zone definition.
129
130 Example:
131 The following represents 8:30 AM in New York in winter,
132 five hours behind UTC, in each of the three formats:
133
134 .. code-block:: ics
135
136 083000
137 133000Z
138 TZID=America/New_York:083000
139 """
140
141 default_value: ClassVar[str] = "TIME"
142 params: Parameters
143
144 def __init__(self, *args: Any, params: dict[str, Any] | None = None) -> None:
145 if len(args) == 1:
146 if not isinstance(args[0], (time, datetime)):
147 raise ValueError(f"Expected a datetime.time, got: {args[0]}")
148 self.dt = args[0]
149 else:
150 self.dt = time(*args)
151 self.params = Parameters(params or {})
152 self.params.update_tzid_from(self.dt)
153
154 def to_ical(self):
155 value = self.dt.strftime("%H%M%S")
156 if self.is_utc():
157 value += "Z"
158 return value
159
160 def is_utc(self) -> bool:
161 """Whether this time is UTC."""
162 return self.params.is_utc() or is_utc(self.dt)
163
164 @staticmethod
165 def from_ical(ical: str, timezone: str | None | tzinfo = None) -> time:
166 """Convert an ical string into a time.
167
168 This method supports parsing the three forms of time values defined in :rfc:`5545#section-3.3.12`:
169 - Local time (floating)
170 - UTC time
171 - Local time with time zone reference
172
173 Returns:
174 A :class:`datetime.time` object representing the parsed time, with timezone information if applicable.
175
176 Raises:
177 ValueError: if the provided string cannot be parsed as a time.
178 """
179 tzinfo = None
180 if isinstance(timezone, str):
181 tzinfo = tzp.timezone(timezone)
182 elif timezone is not None:
183 tzinfo = timezone
184
185 if isinstance(ical, bytes):
186 ical = ical.decode()
187 # Extract the value part if parameters are present per
188 # https://datatracker.ietf.org/doc/html/rfc5545.html#section-3.3.5
189 # Form #3: TZID=America/New_York:083000
190 ical = ical.rpartition(":")[2]
191 if utc := ical.endswith("Z"):
192 ical = ical[:-1]
193 # time = time-hour time-minute time-second [time-utc], six digits,
194 # per https://datatracker.ietf.org/doc/html/rfc5545#section-3.3.12
195 if len(ical) != 6 or not ical.isascii() or not ical.isdigit():
196 raise ValueError(f"Expected time, got: {ical}")
197 try:
198 timetuple = (int(ical[:2]), int(ical[2:4]), int(ical[4:6]))
199 if tzinfo:
200 return tzp.localize(time(*timetuple), tzinfo)
201 if utc:
202 return tzp.localize_utc(time(*timetuple))
203 return time(*timetuple)
204 except Exception as e:
205 raise ValueError(f"Expected time, got: {ical}") from e
206
207 @classmethod
208 def examples(cls) -> list[Self]:
209 """Examples of vTime."""
210 return [cls(time(12, 30))]
211
212 from icalendar.param import VALUE
213
214 def to_jcal(self, name: str) -> list:
215 """The jCal representation of this property according to :rfc:`7265`."""
216 value = self.dt.strftime("%H:%M:%S")
217 if self.is_utc():
218 value += "Z"
219 return [name, self.params.to_jcal(exclude_utc=True), self.VALUE.lower(), value]
220
221 @classmethod
222 def parse_jcal_value(cls, jcal: str) -> time:
223 """Parse a jCal string to a :class:`datetime.time`.
224
225 Raises:
226 ~error.JCalParsingError: If it can't parse a time.
227 """
228 JCalParsingError.validate_value_type(jcal, str, cls)
229 match = TIME_JCAL_REGEX.match(jcal)
230 if match is None:
231 raise JCalParsingError("Cannot parse time.", cls, value=jcal)
232 hour = int(match.group("hour"))
233 minute = int(match.group("minute"))
234 second = int(match.group("second"))
235 utc = bool(match.group("utc"))
236 return time(hour, minute, second, tzinfo=timezone.utc if utc else None)
237
238 @classmethod
239 def from_jcal(cls, jcal_property: list) -> Self:
240 """Parse jCal from :rfc:`7265`.
241
242 Parameters:
243 jcal_property: The jCal property to parse.
244
245 Raises:
246 ~error.JCalParsingError: If the provided jCal is invalid.
247 """
248 JCalParsingError.validate_property(jcal_property, cls)
249 with JCalParsingError.reraise_with_path_added(3):
250 value = cls.parse_jcal_value(jcal_property[3])
251 return cls(
252 value,
253 params=Parameters.from_jcal_property(jcal_property),
254 )
255
256 def to_xcal(self, element: Element) -> None:
257 """The xCal representation of this property according to :rfc:`6321`."""
258 self.params.to_xcal(element)
259 element = SubElement(element, self.default_value.lower())
260 text = self.dt.strftime("%H:%M:%S")
261 if is_utc(self.dt):
262 text += "Z"
263 element.text = text
264
265 @classmethod
266 @from_xcal_wrapper
267 def from_xcal(cls, parser: XCalParser, params: Parameters) -> Self:
268 """Parse xCal from :rfc:`6321`.
269
270 Parameters:
271 xml: The XML to parse or a parser.
272
273 Raises:
274 ~error.XCalParsingError: If the provided xCal is invalid.
275 """
276 element = parser.parse_tag(cls.default_value)
277 hour, minute, second, utc = XCAL_TIME_REGEX.groups(element)
278 if second == "60":
279 # This is wrong but we have no way to represent this
280 second = 59
281 try:
282 dt = time(int(hour), int(minute), int(second))
283 except ValueError as e:
284 raise XCalParsingError(
285 "Time is out of range", element.get_xsd_token(), element
286 ) from e
287 tzid = params.tzid
288 if utc:
289 if tzid:
290 raise XCalParsingError(f"Cannot mix {tzid} with UTC", None, element)
291 dt = tzp.localize_utc(dt)
292 elif tzid:
293 dt = tzp.localize(dt, tzid)
294 return cls(dt, params=params)
295
296
297__all__ = ["vTime"]