""":rfc:`5545` components for timezone information."""
from __future__ import annotations
from collections import defaultdict
from datetime import date, datetime, timedelta, tzinfo
from typing import TYPE_CHECKING, Optional
import dateutil.rrule
import dateutil.tz
from icalendar.attr import (
create_single_property,
exdates_property,
rdates_property,
rrules_property,
)
from icalendar.cal.component import Component
from icalendar.cal.examples import get_example
from icalendar.prop import tzid_from_tzinfo, vUTCOffset
from icalendar.timezone import TZP, tzp
from icalendar.tools import is_date, to_datetime
if TYPE_CHECKING:
from icalendar.cal.calendar import Calendar
[docs]
class Timezone(Component):
"""
A "VTIMEZONE" calendar component is a grouping of component
properties that defines a time zone. It is used to describe the
way in which a time zone changes its offset from UTC over time.
"""
subcomponents: list[TimezoneStandard | TimezoneDaylight]
name = "VTIMEZONE"
canonical_order = ("TZID",)
required = ("TZID",) # it also requires one of components DAYLIGHT and STANDARD
singletons = (
"TZID",
"LAST-MODIFIED",
"TZURL",
)
DEFAULT_FIRST_DATE = date(1970, 1, 1)
DEFAULT_LAST_DATE = date(2038, 1, 1)
[docs]
@classmethod
def example(cls, name: str = "pacific_fiji") -> Calendar:
"""Return the timezone example with the given name."""
return cls.from_ical(get_example("timezones", name))
@staticmethod
def _extract_offsets(component: TimezoneDaylight | TimezoneStandard, tzname: str):
"""extract offsets and transition times from a VTIMEZONE component
:param component: a STANDARD or DAYLIGHT component
:param tzname: the name of the zone
"""
offsetfrom = component.TZOFFSETFROM
offsetto = component.TZOFFSETTO
dtstart = component.DTSTART
# offsets need to be rounded to the next minute, we might loose up
# to 30 seconds accuracy, but it can't be helped (datetime
# supposedly cannot handle smaller offsets)
offsetto_s = int((offsetto.seconds + 30) / 60) * 60
offsetto = timedelta(days=offsetto.days, seconds=offsetto_s)
offsetfrom_s = int((offsetfrom.seconds + 30) / 60) * 60
offsetfrom = timedelta(days=offsetfrom.days, seconds=offsetfrom_s)
# expand recurrences
if "RRULE" in component:
# to be paranoid about correct weekdays
# evaluate the rrule with the current offset
tzi = dateutil.tz.tzoffset("(offsetfrom)", offsetfrom)
rrstart = dtstart.replace(tzinfo=tzi)
rrulestr = component["RRULE"].to_ical().decode("utf-8")
rrule = dateutil.rrule.rrulestr(rrulestr, dtstart=rrstart)
tzp.fix_rrule_until(rrule, component["RRULE"])
# constructing the timezone requires UTC transition times.
# here we construct local times without tzinfo, the offset to UTC
# gets subtracted in to_tz().
transtimes = [dt.replace(tzinfo=None) for dt in rrule]
# or rdates
elif "RDATE" in component:
if not isinstance(component["RDATE"], list):
rdates = [component["RDATE"]]
else:
rdates = component["RDATE"]
transtimes = [dtstart] + [leaf.dt for tree in rdates for leaf in tree.dts]
else:
transtimes = [dtstart]
transitions = [
(transtime, offsetfrom, offsetto, tzname) for transtime in set(transtimes)
]
if component.name == "STANDARD":
is_dst = 0
elif component.name == "DAYLIGHT":
is_dst = 1
return is_dst, transitions
@staticmethod
def _make_unique_tzname(tzname, tznames):
"""
:param tzname: Candidate tzname
:param tznames: Other tznames
"""
# TODO better way of making sure tznames are unique
while tzname in tznames:
tzname += "_1"
tznames.add(tzname)
return tzname
[docs]
def to_tz(self, tzp: TZP = tzp, lookup_tzid: bool = True): # noqa: FBT001
"""convert this VTIMEZONE component to a timezone object
:param tzp: timezone provider to use
:param lookup_tzid: whether to use the TZID property to look up existing
timezone definitions with tzp.
If it is False, a new timezone will be created.
If it is True, the existing timezone will be used
if it exists, otherwise a new timezone will be created.
"""
if lookup_tzid:
tz = tzp.timezone(self.tz_name)
if tz is not None:
return tz
return tzp.create_timezone(self)
@property
def tz_name(self) -> str:
"""Return the name of the timezone component.
Please note that the names of the timezone are different from this name
and may change with winter/summer time.
"""
try:
return str(self["TZID"])
except UnicodeEncodeError:
return self["TZID"].encode("ascii", "replace")
[docs]
def get_transitions(
self,
) -> tuple[list[datetime], list[tuple[timedelta, timedelta, str]]]:
"""Return a tuple of (transition_times, transition_info)
- transition_times = [datetime, ...]
- transition_info = [(TZOFFSETTO, dts_offset, tzname)]
"""
zone = self.tz_name
transitions = []
dst = {}
tznames = set()
for component in self.walk():
if isinstance(component, Timezone):
continue
if is_date(component["DTSTART"].dt):
component.DTSTART = to_datetime(component["DTSTART"].dt)
assert isinstance(component["DTSTART"].dt, datetime), (
"VTIMEZONEs sub-components' DTSTART must be of type datetime, not date"
)
try:
tzname = str(component["TZNAME"])
except UnicodeEncodeError:
tzname = component["TZNAME"].encode("ascii", "replace")
tzname = self._make_unique_tzname(tzname, tznames)
except KeyError:
# for whatever reason this is str/unicode
tzname = (
f"{zone}_{component['DTSTART'].to_ical().decode('utf-8')}_"
f"{component['TZOFFSETFROM'].to_ical()}_"
f"{component['TZOFFSETTO'].to_ical()}"
)
tzname = self._make_unique_tzname(tzname, tznames)
dst[tzname], component_transitions = self._extract_offsets(
component, tzname
)
transitions.extend(component_transitions)
transitions.sort()
transition_times = [
transtime - osfrom for transtime, osfrom, _, _ in transitions
]
# transition_info is a list with tuples in the format
# (utcoffset, dstoffset, name)
# dstoffset = 0, if current transition is to standard time
# = this_utcoffset - prev_standard_utcoffset, otherwise
transition_info = []
for num, (_transtime, _osfrom, osto, name) in enumerate(transitions):
dst_offset = False
if not dst[name]:
dst_offset = timedelta(seconds=0)
else:
# go back in time until we find a transition to dst
for index in range(num - 1, -1, -1):
if not dst[transitions[index][3]]: # [3] is the name
dst_offset = osto - transitions[index][2] # [2] is osto
break
# when the first transition is to dst, we didn't find anything
# in the past, so we have to look into the future
if not dst_offset:
for index in range(num, len(transitions)):
if not dst[transitions[index][3]]: # [3] is the name
dst_offset = osto - transitions[index][2] # [2] is osto
break
assert dst_offset is not False
transition_info.append((osto, dst_offset, name))
return transition_times, transition_info
# binary search
_from_tzinfo_skip_search = [
timedelta(days=days) for days in (64, 32, 16, 8, 4, 2, 1)
] + [
# we know it happens in the night usually around 1am
timedelta(hours=4),
timedelta(hours=1),
# adding some minutes and seconds for faster search
timedelta(minutes=20),
timedelta(minutes=5),
timedelta(minutes=1),
timedelta(seconds=20),
timedelta(seconds=5),
timedelta(seconds=1),
]
[docs]
@classmethod
def from_tzinfo(
cls,
timezone: tzinfo,
tzid: Optional[str] = None,
first_date: date = DEFAULT_FIRST_DATE,
last_date: date = DEFAULT_LAST_DATE,
) -> Timezone:
"""Return a VTIMEZONE component from a timezone object.
This works with pytz and zoneinfo and any other timezone.
The offsets are calculated from the tzinfo object.
Parameters:
:param tzinfo: the timezone object
:param tzid: the tzid for this timezone. If None, it will be extracted from the tzinfo.
:param first_date: a datetime that is earlier than anything that happens in the calendar
:param last_date: a datetime that is later than anything that happens in the calendar
:raises ValueError: If we have no tzid and cannot extract one.
.. note::
This can take some time. Please cache the results.
""" # noqa: E501
if tzid is None:
tzid = tzid_from_tzinfo(timezone)
if tzid is None:
raise ValueError(
f"Cannot get TZID from {timezone}. Please set the tzid parameter."
)
normalize = getattr(timezone, "normalize", lambda dt: dt) # pytz compatibility
first_datetime = datetime(first_date.year, first_date.month, first_date.day) # noqa: DTZ001
last_datetime = datetime(last_date.year, last_date.month, last_date.day) # noqa: DTZ001
if hasattr(timezone, "localize"): # pytz compatibility
first_datetime = timezone.localize(first_datetime)
last_datetime = timezone.localize(last_datetime)
else:
first_datetime = first_datetime.replace(tzinfo=timezone)
last_datetime = last_datetime.replace(tzinfo=timezone)
# from, to, tzname, is_standard -> start
offsets: dict[
tuple[Optional[timedelta], timedelta, str, bool], list[datetime]
] = defaultdict(list)
start = first_datetime
offset_to = None
while start < last_datetime:
offset_from = offset_to
end = start
offset_to = end.utcoffset()
for add_offset in cls._from_tzinfo_skip_search:
last_end = end # we need to save this as we might be left and right of the time change # noqa: E501
end = normalize(end + add_offset)
try:
while end.utcoffset() == offset_to:
last_end = end
end = normalize(end + add_offset)
except OverflowError:
# zoninfo does not go all the way
break
# retract if we overshoot
end = last_end
# Now, start (inclusive) -> end (exclusive) are one timezone
is_standard = start.dst() == timedelta()
name = start.tzname()
if name is None:
name = str(offset_to)
key = (offset_from, offset_to, name, is_standard)
# first_key = (None,) + key[1:]
# if first_key in offsets:
# # remove the first one and claim it changes at that day
# offsets[first_key] = offsets.pop(first_key)
offsets[key].append(start.replace(tzinfo=None))
start = normalize(end + cls._from_tzinfo_skip_search[-1])
tz = cls()
tz.add("TZID", tzid)
tz.add("COMMENT", f"This timezone only works from {first_date} to {last_date}.")
for (offset_from, offset_to, tzname, is_standard), starts in offsets.items():
first_start = min(starts)
starts.remove(first_start)
if first_start.date() == last_date:
first_start = datetime(last_date.year, last_date.month, last_date.day) # noqa: DTZ001
subcomponent = TimezoneStandard() if is_standard else TimezoneDaylight()
if offset_from is None:
offset_from = offset_to # noqa: PLW2901
subcomponent.TZOFFSETFROM = offset_from
subcomponent.TZOFFSETTO = offset_to
subcomponent.add("TZNAME", tzname)
subcomponent.DTSTART = first_start
if starts:
subcomponent.add("RDATE", starts)
tz.add_component(subcomponent)
return tz
[docs]
@classmethod
def from_tzid(
cls,
tzid: str,
tzp: TZP = tzp,
first_date: date = DEFAULT_FIRST_DATE,
last_date: date = DEFAULT_LAST_DATE,
) -> Timezone:
"""Create a VTIMEZONE from a tzid like ``"Europe/Berlin"``.
:param tzid: the id of the timezone
:param tzp: the timezone provider
:param first_date: a datetime that is earlier than anything
that happens in the calendar
:param last_date: a datetime that is later than anything
that happens in the calendar
:raises ValueError: If the tzid is unknown.
>>> from icalendar import Timezone
>>> tz = Timezone.from_tzid("Europe/Berlin")
>>> print(tz.to_ical()[:36])
BEGIN:VTIMEZONE
TZID:Europe/Berlin
.. note::
This can take some time. Please cache the results.
"""
tz = tzp.timezone(tzid)
if tz is None:
raise ValueError(f"Unkown timezone {tzid}.")
return cls.from_tzinfo(tz, tzid, first_date, last_date)
@property
def standard(self) -> list[TimezoneStandard]:
"""The STANDARD subcomponents as a list."""
return self.walk("STANDARD")
@property
def daylight(self) -> list[TimezoneDaylight]:
"""The DAYLIGHT subcomponents as a list.
These are for the daylight saving time.
"""
return self.walk("DAYLIGHT")
[docs]
class TimezoneStandard(Component):
"""
The "STANDARD" sub-component of "VTIMEZONE" defines the standard
time offset from UTC for a time zone. It represents a time zone's
standard time, typically used during winter months in locations
that observe Daylight Saving Time.
"""
name = "STANDARD"
required = ("DTSTART", "TZOFFSETTO", "TZOFFSETFROM")
singletons = (
"DTSTART",
"TZOFFSETTO",
"TZOFFSETFROM",
)
multiple = ("COMMENT", "RDATE", "TZNAME", "RRULE", "EXDATE")
DTSTART = create_single_property(
"DTSTART",
"dt",
(datetime,),
datetime,
"""The mandatory "DTSTART" property gives the effective onset date
and local time for the time zone sub-component definition.
"DTSTART" in this usage MUST be specified as a date with a local
time value.""",
)
TZOFFSETTO = create_single_property(
"TZOFFSETTO",
"td",
(timedelta,),
timedelta,
"""The mandatory "TZOFFSETTO" property gives the UTC offset for the
time zone sub-component (Standard Time or Daylight Saving Time)
when this observance is in use.
""",
vUTCOffset,
)
TZOFFSETFROM = create_single_property(
"TZOFFSETFROM",
"td",
(timedelta,),
timedelta,
"""The mandatory "TZOFFSETFROM" property gives the UTC offset that is
in use when the onset of this time zone observance begins.
"TZOFFSETFROM" is combined with "DTSTART" to define the effective
onset for the time zone sub-component definition. For example,
the following represents the time at which the observance of
Standard Time took effect in Fall 1967 for New York City:
DTSTART:19671029T020000
TZOFFSETFROM:-0400
""",
vUTCOffset,
)
rdates = rdates_property
exdates = exdates_property
rrules = rrules_property
[docs]
class TimezoneDaylight(Component):
"""
The "DAYLIGHT" sub-component of "VTIMEZONE" defines the daylight
saving time offset from UTC for a time zone. It represents a time
zone's daylight saving time, typically used during summer months
in locations that observe Daylight Saving Time.
"""
name = "DAYLIGHT"
required = TimezoneStandard.required
singletons = TimezoneStandard.singletons
multiple = TimezoneStandard.multiple
DTSTART = TimezoneStandard.DTSTART
TZOFFSETTO = TimezoneStandard.TZOFFSETTO
TZOFFSETFROM = TimezoneStandard.TZOFFSETFROM
rdates = rdates_property
exdates = exdates_property
rrules = rrules_property
__all__ = ["Timezone", "TimezoneDaylight", "TimezoneStandard"]