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 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 try:
166 tzname = str(component["TZNAME"])
167 except UnicodeEncodeError:
168 tzname = component["TZNAME"].encode("ascii", "replace")
169 tzname = self._make_unique_tzname(tzname, tznames)
170 except KeyError:
171 # for whatever reason this is str/unicode
172 tzname = (
173 f"{zone}_{component['DTSTART'].to_ical().decode('utf-8')}_"
174 f"{component['TZOFFSETFROM'].to_ical()}_"
175 f"{component['TZOFFSETTO'].to_ical()}"
176 )
177 tzname = self._make_unique_tzname(tzname, tznames)
178
179 dst[tzname], component_transitions = self._extract_offsets(
180 component, tzname
181 )
182 transitions.extend(component_transitions)
183
184 transitions.sort()
185 transition_times = [
186 transtime - osfrom for transtime, osfrom, _, _ in transitions
187 ]
188
189 # transition_info is a list with tuples in the format
190 # (utcoffset, dstoffset, name)
191 # dstoffset = 0, if current transition is to standard time
192 # = this_utcoffset - prev_standard_utcoffset, otherwise
193 transition_info = []
194 for num, (_transtime, osfrom, osto, name) in enumerate(transitions):
195 dst_offset = False
196 if not dst[name]:
197 dst_offset = timedelta(seconds=0)
198 else:
199 # go back in time until we find a transition to dst
200 for index in range(num - 1, -1, -1):
201 if not dst[transitions[index][3]]: # [3] is the name
202 dst_offset = osto - transitions[index][2] # [2] is osto
203 break
204 # when the first transition is to dst, we didn't find anything
205 # in the past, so we have to look into the future
206 if not dst_offset:
207 for index in range(num, len(transitions)):
208 if not dst[transitions[index][3]]: # [3] is the name
209 dst_offset = osto - transitions[index][2] # [2] is osto
210 break
211 # If we still haven't found a STANDARD transition
212 # (only DAYLIGHT exists), calculate dst_offset as the
213 # difference from TZOFFSETFROM. Handles Issue #321.
214 if dst_offset is False:
215 dst_offset = osto - osfrom
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: str | None = 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 """
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)
267 last_datetime = datetime(last_date.year, last_date.month, last_date.day)
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[tuple[timedelta | None, timedelta, str, bool], list[datetime]] = (
276 defaultdict(list)
277 )
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
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)
316 subcomponent = TimezoneStandard() if is_standard else TimezoneDaylight()
317 if offset_from is None:
318 offset_from = offset_to
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 convert=to_datetime,
401 )
402
403 TZOFFSETTO = create_single_property(
404 "TZOFFSETTO",
405 "td",
406 (timedelta,),
407 timedelta,
408 """The mandatory "TZOFFSETTO" property gives the UTC offset for the
409 time zone sub-component (Standard Time or Daylight Saving Time)
410 when this observance is in use.
411 """,
412 vUTCOffset,
413 )
414 TZOFFSETFROM = create_single_property(
415 "TZOFFSETFROM",
416 "td",
417 (timedelta,),
418 timedelta,
419 """The mandatory "TZOFFSETFROM" property gives the UTC offset that is
420 in use when the onset of this time zone observance begins.
421 "TZOFFSETFROM" is combined with "DTSTART" to define the effective
422 onset for the time zone sub-component definition. For example,
423 the following represents the time at which the observance of
424 Standard Time took effect in Fall 1967 for New York City:
425
426 DTSTART:19671029T020000
427 TZOFFSETFROM:-0400
428 """,
429 vUTCOffset,
430 )
431 rdates = rdates_property
432 exdates = exdates_property
433 rrules = rrules_property
434
435
436class TimezoneDaylight(Component):
437 """
438 The "DAYLIGHT" sub-component of "VTIMEZONE" defines the daylight
439 saving time offset from UTC for a time zone. It represents a time
440 zone's daylight saving time, typically used during summer months
441 in locations that observe Daylight Saving Time.
442 """
443
444 name = "DAYLIGHT"
445 required = TimezoneStandard.required
446 singletons = TimezoneStandard.singletons
447 multiple = TimezoneStandard.multiple
448
449 DTSTART = TimezoneStandard.DTSTART
450 TZOFFSETTO = TimezoneStandard.TZOFFSETTO
451 TZOFFSETFROM = TimezoneStandard.TZOFFSETFROM
452
453 rdates = rdates_property
454 exdates = exdates_property
455 rrules = rrules_property
456
457
458__all__ = ["Timezone", "TimezoneDaylight", "TimezoneStandard"]