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 # If we still haven't found a STANDARD transition
216 # (only DAYLIGHT exists), calculate dst_offset as the
217 # difference from TZOFFSETFROM. Handles Issue #321.
218 if dst_offset is False:
219 dst_offset = osto - osfrom
220 transition_info.append((osto, dst_offset, name))
221 return transition_times, transition_info
222
223 # binary search
224 _from_tzinfo_skip_search = [
225 timedelta(days=days) for days in (64, 32, 16, 8, 4, 2, 1)
226 ] + [
227 # we know it happens in the night usually around 1am
228 timedelta(hours=4),
229 timedelta(hours=1),
230 # adding some minutes and seconds for faster search
231 timedelta(minutes=20),
232 timedelta(minutes=5),
233 timedelta(minutes=1),
234 timedelta(seconds=20),
235 timedelta(seconds=5),
236 timedelta(seconds=1),
237 ]
238
239 @classmethod
240 def from_tzinfo(
241 cls,
242 timezone: tzinfo,
243 tzid: Optional[str] = None,
244 first_date: date = DEFAULT_FIRST_DATE,
245 last_date: date = DEFAULT_LAST_DATE,
246 ) -> Timezone:
247 """Return a VTIMEZONE component from a timezone object.
248
249 This works with pytz and zoneinfo and any other timezone.
250 The offsets are calculated from the tzinfo object.
251
252 Parameters:
253
254 :param tzinfo: the timezone object
255 :param tzid: the tzid for this timezone. If None, it will be extracted from the tzinfo.
256 :param first_date: a datetime that is earlier than anything that happens in the calendar
257 :param last_date: a datetime that is later than anything that happens in the calendar
258 :raises ValueError: If we have no tzid and cannot extract one.
259
260 .. note::
261 This can take some time. Please cache the results.
262 """ # noqa: E501
263 if tzid is None:
264 tzid = tzid_from_tzinfo(timezone)
265 if tzid is None:
266 raise ValueError(
267 f"Cannot get TZID from {timezone}. Please set the tzid parameter."
268 )
269 normalize = getattr(timezone, "normalize", lambda dt: dt) # pytz compatibility
270 first_datetime = datetime(first_date.year, first_date.month, first_date.day) # noqa: DTZ001
271 last_datetime = datetime(last_date.year, last_date.month, last_date.day) # noqa: DTZ001
272 if hasattr(timezone, "localize"): # pytz compatibility
273 first_datetime = timezone.localize(first_datetime)
274 last_datetime = timezone.localize(last_datetime)
275 else:
276 first_datetime = first_datetime.replace(tzinfo=timezone)
277 last_datetime = last_datetime.replace(tzinfo=timezone)
278 # from, to, tzname, is_standard -> start
279 offsets: dict[
280 tuple[Optional[timedelta], timedelta, str, bool], list[datetime]
281 ] = defaultdict(list)
282 start = first_datetime
283 offset_to = None
284 while start < last_datetime:
285 offset_from = offset_to
286 end = start
287 offset_to = end.utcoffset()
288 for add_offset in cls._from_tzinfo_skip_search:
289 last_end = end # we need to save this as we might be left and right of the time change # noqa: E501
290 end = normalize(end + add_offset)
291 try:
292 while end.utcoffset() == offset_to:
293 last_end = end
294 end = normalize(end + add_offset)
295 except OverflowError:
296 # zoninfo does not go all the way
297 break
298 # retract if we overshoot
299 end = last_end
300 # Now, start (inclusive) -> end (exclusive) are one timezone
301 is_standard = start.dst() == timedelta()
302 name = start.tzname()
303 if name is None:
304 name = str(offset_to)
305 key = (offset_from, offset_to, name, is_standard)
306 # first_key = (None,) + key[1:]
307 # if first_key in offsets:
308 # # remove the first one and claim it changes at that day
309 # offsets[first_key] = offsets.pop(first_key)
310 offsets[key].append(start.replace(tzinfo=None))
311 start = normalize(end + cls._from_tzinfo_skip_search[-1])
312 tz = cls()
313 tz.add("TZID", tzid)
314 tz.add("COMMENT", f"This timezone only works from {first_date} to {last_date}.")
315 for (offset_from, offset_to, tzname, is_standard), starts in offsets.items():
316 first_start = min(starts)
317 starts.remove(first_start)
318 if first_start.date() == last_date:
319 first_start = datetime(last_date.year, last_date.month, last_date.day) # noqa: DTZ001
320 subcomponent = TimezoneStandard() if is_standard else TimezoneDaylight()
321 if offset_from is None:
322 offset_from = offset_to # noqa: PLW2901
323 subcomponent.TZOFFSETFROM = offset_from
324 subcomponent.TZOFFSETTO = offset_to
325 subcomponent.add("TZNAME", tzname)
326 subcomponent.DTSTART = first_start
327 if starts:
328 subcomponent.add("RDATE", starts)
329 tz.add_component(subcomponent)
330 return tz
331
332 @classmethod
333 def from_tzid(
334 cls,
335 tzid: str,
336 tzp: TZP = tzp,
337 first_date: date = DEFAULT_FIRST_DATE,
338 last_date: date = DEFAULT_LAST_DATE,
339 ) -> Timezone:
340 """Create a VTIMEZONE from a tzid like ``"Europe/Berlin"``.
341
342 :param tzid: the id of the timezone
343 :param tzp: the timezone provider
344 :param first_date: a datetime that is earlier than anything
345 that happens in the calendar
346 :param last_date: a datetime that is later than anything
347 that happens in the calendar
348 :raises ValueError: If the tzid is unknown.
349
350 >>> from icalendar import Timezone
351 >>> tz = Timezone.from_tzid("Europe/Berlin")
352 >>> print(tz.to_ical()[:36])
353 BEGIN:VTIMEZONE
354 TZID:Europe/Berlin
355
356 .. note::
357 This can take some time. Please cache the results.
358 """
359 tz = tzp.timezone(tzid)
360 if tz is None:
361 raise ValueError(f"Unkown timezone {tzid}.")
362 return cls.from_tzinfo(tz, tzid, first_date, last_date)
363
364 @property
365 def standard(self) -> list[TimezoneStandard]:
366 """The STANDARD subcomponents as a list."""
367 return self.walk("STANDARD")
368
369 @property
370 def daylight(self) -> list[TimezoneDaylight]:
371 """The DAYLIGHT subcomponents as a list.
372
373 These are for the daylight saving time.
374 """
375 return self.walk("DAYLIGHT")
376
377
378class TimezoneStandard(Component):
379 """
380 The "STANDARD" sub-component of "VTIMEZONE" defines the standard
381 time offset from UTC for a time zone. It represents a time zone's
382 standard time, typically used during winter months in locations
383 that observe Daylight Saving Time.
384 """
385
386 name = "STANDARD"
387 required = ("DTSTART", "TZOFFSETTO", "TZOFFSETFROM")
388 singletons = (
389 "DTSTART",
390 "TZOFFSETTO",
391 "TZOFFSETFROM",
392 )
393 multiple = ("COMMENT", "RDATE", "TZNAME", "RRULE", "EXDATE")
394
395 DTSTART = create_single_property(
396 "DTSTART",
397 "dt",
398 (datetime,),
399 datetime,
400 """The mandatory "DTSTART" property gives the effective onset date
401 and local time for the time zone sub-component definition.
402 "DTSTART" in this usage MUST be specified as a date with a local
403 time value.""",
404 )
405 TZOFFSETTO = create_single_property(
406 "TZOFFSETTO",
407 "td",
408 (timedelta,),
409 timedelta,
410 """The mandatory "TZOFFSETTO" property gives the UTC offset for the
411 time zone sub-component (Standard Time or Daylight Saving Time)
412 when this observance is in use.
413 """,
414 vUTCOffset,
415 )
416 TZOFFSETFROM = create_single_property(
417 "TZOFFSETFROM",
418 "td",
419 (timedelta,),
420 timedelta,
421 """The mandatory "TZOFFSETFROM" property gives the UTC offset that is
422 in use when the onset of this time zone observance begins.
423 "TZOFFSETFROM" is combined with "DTSTART" to define the effective
424 onset for the time zone sub-component definition. For example,
425 the following represents the time at which the observance of
426 Standard Time took effect in Fall 1967 for New York City:
427
428 DTSTART:19671029T020000
429 TZOFFSETFROM:-0400
430 """,
431 vUTCOffset,
432 )
433 rdates = rdates_property
434 exdates = exdates_property
435 rrules = rrules_property
436
437
438class TimezoneDaylight(Component):
439 """
440 The "DAYLIGHT" sub-component of "VTIMEZONE" defines the daylight
441 saving time offset from UTC for a time zone. It represents a time
442 zone's daylight saving time, typically used during summer months
443 in locations that observe Daylight Saving Time.
444 """
445
446 name = "DAYLIGHT"
447 required = TimezoneStandard.required
448 singletons = TimezoneStandard.singletons
449 multiple = TimezoneStandard.multiple
450
451 DTSTART = TimezoneStandard.DTSTART
452 TZOFFSETTO = TimezoneStandard.TZOFFSETTO
453 TZOFFSETFROM = TimezoneStandard.TZOFFSETFROM
454
455 rdates = rdates_property
456 exdates = exdates_property
457 rrules = rrules_property
458
459
460__all__ = ["Timezone", "TimezoneDaylight", "TimezoneStandard"]