1""":rfc:`5545` components for timezone information."""
2
3from __future__ import annotations
4
5from collections import defaultdict
6from datetime import date, datetime, timedelta, tzinfo
7from typing import TYPE_CHECKING, Optional
8
9import dateutil.rrule
10import dateutil.tz
11
12from icalendar.attr import (
13 create_single_property,
14 exdates_property,
15 rdates_property,
16 rrules_property,
17)
18from icalendar.cal.component import Component
19from icalendar.cal.examples import get_example
20from icalendar.prop import tzid_from_tzinfo, vUTCOffset
21from icalendar.timezone import TZP, tzp
22from icalendar.tools import is_date, to_datetime
23
24if TYPE_CHECKING:
25 from icalendar.cal.calendar import Calendar
26
27
28class Timezone(Component):
29 """
30 A "VTIMEZONE" calendar component is a grouping of component
31 properties that defines a time zone. It is used to describe the
32 way in which a time zone changes its offset from UTC over time.
33 """
34
35 subcomponents: list[TimezoneStandard | TimezoneDaylight]
36
37 name = "VTIMEZONE"
38 canonical_order = ("TZID",)
39 required = ("TZID",) # it also requires one of components DAYLIGHT and STANDARD
40 singletons = (
41 "TZID",
42 "LAST-MODIFIED",
43 "TZURL",
44 )
45
46 DEFAULT_FIRST_DATE = date(1970, 1, 1)
47 DEFAULT_LAST_DATE = date(2038, 1, 1)
48
49 @classmethod
50 def example(cls, name: str = "pacific_fiji") -> Calendar:
51 """Return the timezone example with the given name."""
52 return cls.from_ical(get_example("timezones", name))
53
54 @staticmethod
55 def _extract_offsets(component: TimezoneDaylight | TimezoneStandard, tzname: str):
56 """extract offsets and transition times from a VTIMEZONE component
57 :param component: a STANDARD or DAYLIGHT component
58 :param tzname: the name of the zone
59 """
60 offsetfrom = component.TZOFFSETFROM
61 offsetto = component.TZOFFSETTO
62 dtstart = component.DTSTART
63
64 # offsets need to be rounded to the next minute, we might loose up
65 # to 30 seconds accuracy, but it can't be helped (datetime
66 # supposedly cannot handle smaller offsets)
67 offsetto_s = int((offsetto.seconds + 30) / 60) * 60
68 offsetto = timedelta(days=offsetto.days, seconds=offsetto_s)
69 offsetfrom_s = int((offsetfrom.seconds + 30) / 60) * 60
70 offsetfrom = timedelta(days=offsetfrom.days, seconds=offsetfrom_s)
71
72 # expand recurrences
73 if "RRULE" in component:
74 # to be paranoid about correct weekdays
75 # evaluate the rrule with the current offset
76 tzi = dateutil.tz.tzoffset("(offsetfrom)", offsetfrom)
77 rrstart = dtstart.replace(tzinfo=tzi)
78
79 rrulestr = component["RRULE"].to_ical().decode("utf-8")
80 rrule = dateutil.rrule.rrulestr(rrulestr, dtstart=rrstart)
81 tzp.fix_rrule_until(rrule, component["RRULE"])
82
83 # constructing the timezone requires UTC transition times.
84 # here we construct local times without tzinfo, the offset to UTC
85 # gets subtracted in to_tz().
86 transtimes = [dt.replace(tzinfo=None) for dt in rrule]
87
88 # or rdates
89 elif "RDATE" in component:
90 if not isinstance(component["RDATE"], list):
91 rdates = [component["RDATE"]]
92 else:
93 rdates = component["RDATE"]
94 transtimes = [dtstart] + [leaf.dt for tree in rdates for leaf in tree.dts]
95 else:
96 transtimes = [dtstart]
97
98 transitions = [
99 (transtime, offsetfrom, offsetto, tzname) for transtime in set(transtimes)
100 ]
101
102 if component.name == "STANDARD":
103 is_dst = 0
104 elif component.name == "DAYLIGHT":
105 is_dst = 1
106 return is_dst, transitions
107
108 @staticmethod
109 def _make_unique_tzname(tzname, tznames):
110 """
111 :param tzname: Candidate tzname
112 :param tznames: Other tznames
113 """
114 # TODO better way of making sure tznames are unique
115 while tzname in tznames:
116 tzname += "_1"
117 tznames.add(tzname)
118 return tzname
119
120 def to_tz(self, tzp: TZP = tzp, lookup_tzid: bool = True): # noqa: FBT001
121 """convert this VTIMEZONE component to a timezone object
122
123 :param tzp: timezone provider to use
124 :param lookup_tzid: whether to use the TZID property to look up existing
125 timezone definitions with tzp.
126 If it is False, a new timezone will be created.
127 If it is True, the existing timezone will be used
128 if it exists, otherwise a new timezone will be created.
129 """
130 if lookup_tzid:
131 tz = tzp.timezone(self.tz_name)
132 if tz is not None:
133 return tz
134 return tzp.create_timezone(self)
135
136 @property
137 def tz_name(self) -> str:
138 """Return the name of the timezone component.
139
140 Please note that the names of the timezone are different from this name
141 and may change with winter/summer time.
142 """
143 try:
144 return str(self["TZID"])
145 except UnicodeEncodeError:
146 return self["TZID"].encode("ascii", "replace")
147
148 def get_transitions(
149 self,
150 ) -> tuple[list[datetime], list[tuple[timedelta, timedelta, str]]]:
151 """Return a tuple of (transition_times, transition_info)
152
153 - transition_times = [datetime, ...]
154 - transition_info = [(TZOFFSETTO, dts_offset, tzname)]
155
156 """
157 zone = self.tz_name
158 transitions = []
159 dst = {}
160 tznames = set()
161 for component in self.walk():
162 if isinstance(component, Timezone):
163 continue
164 if is_date(component["DTSTART"].dt):
165 component.DTSTART = to_datetime(component["DTSTART"].dt)
166 assert isinstance(component["DTSTART"].dt, datetime), (
167 "VTIMEZONEs sub-components' DTSTART must be of type datetime, not date"
168 )
169 try:
170 tzname = str(component["TZNAME"])
171 except UnicodeEncodeError:
172 tzname = component["TZNAME"].encode("ascii", "replace")
173 tzname = self._make_unique_tzname(tzname, tznames)
174 except KeyError:
175 # for whatever reason this is str/unicode
176 tzname = (
177 f"{zone}_{component['DTSTART'].to_ical().decode('utf-8')}_"
178 f"{component['TZOFFSETFROM'].to_ical()}_"
179 f"{component['TZOFFSETTO'].to_ical()}"
180 )
181 tzname = self._make_unique_tzname(tzname, tznames)
182
183 dst[tzname], component_transitions = self._extract_offsets(
184 component, tzname
185 )
186 transitions.extend(component_transitions)
187
188 transitions.sort()
189 transition_times = [
190 transtime - osfrom for transtime, osfrom, _, _ in transitions
191 ]
192
193 # transition_info is a list with tuples in the format
194 # (utcoffset, dstoffset, name)
195 # dstoffset = 0, if current transition is to standard time
196 # = this_utcoffset - prev_standard_utcoffset, otherwise
197 transition_info = []
198 for num, (_transtime, _osfrom, osto, name) in enumerate(transitions):
199 dst_offset = False
200 if not dst[name]:
201 dst_offset = timedelta(seconds=0)
202 else:
203 # go back in time until we find a transition to dst
204 for index in range(num - 1, -1, -1):
205 if not dst[transitions[index][3]]: # [3] is the name
206 dst_offset = osto - transitions[index][2] # [2] is osto
207 break
208 # when the first transition is to dst, we didn't find anything
209 # in the past, so we have to look into the future
210 if not dst_offset:
211 for index in range(num, len(transitions)):
212 if not dst[transitions[index][3]]: # [3] is the name
213 dst_offset = osto - transitions[index][2] # [2] is osto
214 break
215 assert dst_offset is not False
216 transition_info.append((osto, dst_offset, name))
217 return transition_times, transition_info
218
219 # binary search
220 _from_tzinfo_skip_search = [
221 timedelta(days=days) for days in (64, 32, 16, 8, 4, 2, 1)
222 ] + [
223 # we know it happens in the night usually around 1am
224 timedelta(hours=4),
225 timedelta(hours=1),
226 # adding some minutes and seconds for faster search
227 timedelta(minutes=20),
228 timedelta(minutes=5),
229 timedelta(minutes=1),
230 timedelta(seconds=20),
231 timedelta(seconds=5),
232 timedelta(seconds=1),
233 ]
234
235 @classmethod
236 def from_tzinfo(
237 cls,
238 timezone: tzinfo,
239 tzid: Optional[str] = None,
240 first_date: date = DEFAULT_FIRST_DATE,
241 last_date: date = DEFAULT_LAST_DATE,
242 ) -> Timezone:
243 """Return a VTIMEZONE component from a timezone object.
244
245 This works with pytz and zoneinfo and any other timezone.
246 The offsets are calculated from the tzinfo object.
247
248 Parameters:
249
250 :param tzinfo: the timezone object
251 :param tzid: the tzid for this timezone. If None, it will be extracted from the tzinfo.
252 :param first_date: a datetime that is earlier than anything that happens in the calendar
253 :param last_date: a datetime that is later than anything that happens in the calendar
254 :raises ValueError: If we have no tzid and cannot extract one.
255
256 .. note::
257 This can take some time. Please cache the results.
258 """ # noqa: E501
259 if tzid is None:
260 tzid = tzid_from_tzinfo(timezone)
261 if tzid is None:
262 raise ValueError(
263 f"Cannot get TZID from {timezone}. Please set the tzid parameter."
264 )
265 normalize = getattr(timezone, "normalize", lambda dt: dt) # pytz compatibility
266 first_datetime = datetime(first_date.year, first_date.month, first_date.day) # noqa: DTZ001
267 last_datetime = datetime(last_date.year, last_date.month, last_date.day) # noqa: DTZ001
268 if hasattr(timezone, "localize"): # pytz compatibility
269 first_datetime = timezone.localize(first_datetime)
270 last_datetime = timezone.localize(last_datetime)
271 else:
272 first_datetime = first_datetime.replace(tzinfo=timezone)
273 last_datetime = last_datetime.replace(tzinfo=timezone)
274 # from, to, tzname, is_standard -> start
275 offsets: dict[
276 tuple[Optional[timedelta], timedelta, str, bool], list[datetime]
277 ] = defaultdict(list)
278 start = first_datetime
279 offset_to = None
280 while start < last_datetime:
281 offset_from = offset_to
282 end = start
283 offset_to = end.utcoffset()
284 for add_offset in cls._from_tzinfo_skip_search:
285 last_end = end # we need to save this as we might be left and right of the time change # noqa: E501
286 end = normalize(end + add_offset)
287 try:
288 while end.utcoffset() == offset_to:
289 last_end = end
290 end = normalize(end + add_offset)
291 except OverflowError:
292 # zoninfo does not go all the way
293 break
294 # retract if we overshoot
295 end = last_end
296 # Now, start (inclusive) -> end (exclusive) are one timezone
297 is_standard = start.dst() == timedelta()
298 name = start.tzname()
299 if name is None:
300 name = str(offset_to)
301 key = (offset_from, offset_to, name, is_standard)
302 # first_key = (None,) + key[1:]
303 # if first_key in offsets:
304 # # remove the first one and claim it changes at that day
305 # offsets[first_key] = offsets.pop(first_key)
306 offsets[key].append(start.replace(tzinfo=None))
307 start = normalize(end + cls._from_tzinfo_skip_search[-1])
308 tz = cls()
309 tz.add("TZID", tzid)
310 tz.add("COMMENT", f"This timezone only works from {first_date} to {last_date}.")
311 for (offset_from, offset_to, tzname, is_standard), starts in offsets.items():
312 first_start = min(starts)
313 starts.remove(first_start)
314 if first_start.date() == last_date:
315 first_start = datetime(last_date.year, last_date.month, last_date.day) # noqa: DTZ001
316 subcomponent = TimezoneStandard() if is_standard else TimezoneDaylight()
317 if offset_from is None:
318 offset_from = offset_to # noqa: PLW2901
319 subcomponent.TZOFFSETFROM = offset_from
320 subcomponent.TZOFFSETTO = offset_to
321 subcomponent.add("TZNAME", tzname)
322 subcomponent.DTSTART = first_start
323 if starts:
324 subcomponent.add("RDATE", starts)
325 tz.add_component(subcomponent)
326 return tz
327
328 @classmethod
329 def from_tzid(
330 cls,
331 tzid: str,
332 tzp: TZP = tzp,
333 first_date: date = DEFAULT_FIRST_DATE,
334 last_date: date = DEFAULT_LAST_DATE,
335 ) -> Timezone:
336 """Create a VTIMEZONE from a tzid like ``"Europe/Berlin"``.
337
338 :param tzid: the id of the timezone
339 :param tzp: the timezone provider
340 :param first_date: a datetime that is earlier than anything
341 that happens in the calendar
342 :param last_date: a datetime that is later than anything
343 that happens in the calendar
344 :raises ValueError: If the tzid is unknown.
345
346 >>> from icalendar import Timezone
347 >>> tz = Timezone.from_tzid("Europe/Berlin")
348 >>> print(tz.to_ical()[:36])
349 BEGIN:VTIMEZONE
350 TZID:Europe/Berlin
351
352 .. note::
353 This can take some time. Please cache the results.
354 """
355 tz = tzp.timezone(tzid)
356 if tz is None:
357 raise ValueError(f"Unkown timezone {tzid}.")
358 return cls.from_tzinfo(tz, tzid, first_date, last_date)
359
360 @property
361 def standard(self) -> list[TimezoneStandard]:
362 """The STANDARD subcomponents as a list."""
363 return self.walk("STANDARD")
364
365 @property
366 def daylight(self) -> list[TimezoneDaylight]:
367 """The DAYLIGHT subcomponents as a list.
368
369 These are for the daylight saving time.
370 """
371 return self.walk("DAYLIGHT")
372
373
374class TimezoneStandard(Component):
375 """
376 The "STANDARD" sub-component of "VTIMEZONE" defines the standard
377 time offset from UTC for a time zone. It represents a time zone's
378 standard time, typically used during winter months in locations
379 that observe Daylight Saving Time.
380 """
381
382 name = "STANDARD"
383 required = ("DTSTART", "TZOFFSETTO", "TZOFFSETFROM")
384 singletons = (
385 "DTSTART",
386 "TZOFFSETTO",
387 "TZOFFSETFROM",
388 )
389 multiple = ("COMMENT", "RDATE", "TZNAME", "RRULE", "EXDATE")
390
391 DTSTART = create_single_property(
392 "DTSTART",
393 "dt",
394 (datetime,),
395 datetime,
396 """The mandatory "DTSTART" property gives the effective onset date
397 and local time for the time zone sub-component definition.
398 "DTSTART" in this usage MUST be specified as a date with a local
399 time value.""",
400 )
401 TZOFFSETTO = create_single_property(
402 "TZOFFSETTO",
403 "td",
404 (timedelta,),
405 timedelta,
406 """The mandatory "TZOFFSETTO" property gives the UTC offset for the
407 time zone sub-component (Standard Time or Daylight Saving Time)
408 when this observance is in use.
409 """,
410 vUTCOffset,
411 )
412 TZOFFSETFROM = create_single_property(
413 "TZOFFSETFROM",
414 "td",
415 (timedelta,),
416 timedelta,
417 """The mandatory "TZOFFSETFROM" property gives the UTC offset that is
418 in use when the onset of this time zone observance begins.
419 "TZOFFSETFROM" is combined with "DTSTART" to define the effective
420 onset for the time zone sub-component definition. For example,
421 the following represents the time at which the observance of
422 Standard Time took effect in Fall 1967 for New York City:
423
424 DTSTART:19671029T020000
425 TZOFFSETFROM:-0400
426 """,
427 vUTCOffset,
428 )
429 rdates = rdates_property
430 exdates = exdates_property
431 rrules = rrules_property
432
433
434class TimezoneDaylight(Component):
435 """
436 The "DAYLIGHT" sub-component of "VTIMEZONE" defines the daylight
437 saving time offset from UTC for a time zone. It represents a time
438 zone's daylight saving time, typically used during summer months
439 in locations that observe Daylight Saving Time.
440 """
441
442 name = "DAYLIGHT"
443 required = TimezoneStandard.required
444 singletons = TimezoneStandard.singletons
445 multiple = TimezoneStandard.multiple
446
447 DTSTART = TimezoneStandard.DTSTART
448 TZOFFSETTO = TimezoneStandard.TZOFFSETTO
449 TZOFFSETFROM = TimezoneStandard.TZOFFSETFROM
450
451 rdates = rdates_property
452 exdates = exdates_property
453 rrules = rrules_property
454
455
456__all__ = ["Timezone", "TimezoneDaylight", "TimezoneStandard"]