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