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