"""A grouping of component properties that defines a time zone. An iCal timezone is a complete description of a timezone, separate from the built-in timezones used by python datetime objects. You can think of this like fully persisting all timezone information referenced in the calendar for dates to reference. Timezones are captured to unambiguously describe time information to aid in interoperability between different calendaring systems. """ # pylint: disable=unnecessary-lambda from __future__ import annotations import copy import traceback import datetime import enum import logging from dataclasses import dataclass from typing import Annotated, Any, Iterable, Optional, Self, Union from dateutil.rrule import rruleset from pydantic import ( BeforeValidator, Field, field_serializer, field_validator, model_validator, ) from ical.types.data_types import serialize_field from .component import ComponentModel from .iter import MergedIterable, RecurIterable from .types import ExtraProperty, Recur, Uri, UtcOffset from .tzif import timezoneinfo, tz_rule from .util import parse_date_and_datetime_list __all__ = [ "Timezone", "Observance", ] _LOGGER = logging.getLogger(__name__) # Assume that all tzif timezone rules start at an arbitrary old date. This library # typically only works with "go forward" dates, so we don't need to be completely # accurate and use the historical database of times. _TZ_START = datetime.datetime(2010, 1, 1, 0, 0, 0) _ZERO = datetime.timedelta(0) class Observance(ComponentModel): """A sub-component with properties for a set of timezone observances.""" # Has an alias of 'start' dtstart: Optional[datetime.datetime] = Field(default=None) """The first onset datetime (local time) for the observance.""" tz_offset_to: UtcOffset = Field(alias="tzoffsetto") """Gives the UTC offset for the time zone when this observance is in use.""" tz_offset_from: UtcOffset = Field(alias="tzoffsetfrom") """The timezone offset used when the onset of this time zone observance begins. The tz_offset_from and dtstart define the effective onset for the time zone sub-component. """ rrule: Optional[Recur] = None """The recurrence rule for the onset of observances defined in this sub-component.""" rdate: Annotated[ list[Union[datetime.date, datetime.datetime]], BeforeValidator(parse_date_and_datetime_list), ] = Field(default_factory=list) """A rule to determine the onset of the observances defined in this sub-component.""" tz_name: list[str] = Field(alias="tzname", default_factory=list) """A name for the observance.""" comment: list[str] = Field(default_factory=list) """Descriptive explanatory text.""" extras: list[ExtraProperty] = Field(default_factory=list) def __init__(self, **data: Any) -> None: """Initialize Timezone.""" if "start" in data: data["dtstart"] = data.pop("start") super().__init__(**data) @property def start_datetime(self) -> datetime.datetime: """Return the start of the observance.""" assert self.dtstart is not None return self.dtstart def as_ruleset(self) -> rruleset: """Represent the occurrence as a rule of repeated dates or datetimes.""" ruleset = rruleset() if self.rrule: ruleset.rrule(self.rrule.as_rrule(self.start_datetime)) for rdate in self.rdate: ruleset.rdate(rdate) # type: ignore[arg-type] return ruleset @field_validator("dtstart") @classmethod def verify_dtstart_local_time(cls, value: datetime.datetime) -> datetime.datetime: """Validate that dtstart is specified in a local time.""" if value.utcoffset() is not None: raise ValueError(f"Start time must be in local time format: {value}") return value serialize_fields = field_serializer("*")(serialize_field) # type: ignore[pydantic-field] class _ObservanceType(str, enum.Enum): """Type of a timezone observance.""" STANDARD = "STANDARD" DAYLIGHT = "DAYLIGHT" @dataclass class _ObservanceInfo: """Object holding observance information.""" observance_type: _ObservanceType observance: Observance def get( self, value: datetime.datetime | datetime.date, ) -> tuple[datetime.datetime | datetime.date, "_ObservanceInfo"]: """Adapt for an iterator over observances.""" return (value, self) class Timezone(ComponentModel): """A single free/busy entry on a calendar. A Timezone must have at least one definition of a standard or daylight sub-component. """ tz_id: str = Field(alias="tzid") """An identifier for this Timezone, unique within a calendar.""" standard: list[Observance] = Field(default_factory=list) """Describes the base offset from UTC for the time zone.""" daylight: list[Observance] = Field(default_factory=list) """Describes adjustments made to account for changes in daylight hours.""" tz_url: Optional[Uri] = Field(alias="tzurl", default=None) """Url that points to a published timezone definition.""" last_modified: Optional[datetime.datetime] = Field( alias="last-modified", default=None ) """Specifies the date and time that this time zone was last updated.""" # Unknown or unsupported properties extras: list[ExtraProperty] = Field(default_factory=list) @classmethod def from_tzif(cls, key: str, start: datetime.datetime = _TZ_START) -> Timezone: """Create a new Timezone from a tzif data source.""" info = timezoneinfo.read(key) rule = info.rule if not rule: raise ValueError("Unsupported timezoneinfo had no rule") dst_offset = rule.std.offset if rule.dst and rule.dst.offset: dst_offset = rule.dst.offset std_timezone_info = Observance( tz_name=[rule.std.name], tz_offset_to=UtcOffset(offset=rule.std.offset), tz_offset_from=UtcOffset(dst_offset), dtstart=start, ) daylight = [] if ( rule.dst and rule.dst_start and isinstance(rule.dst_start, tz_rule.RuleDate) and rule.dst_end and isinstance(rule.dst_end, tz_rule.RuleDate) ): std_timezone_info.rrule = Recur.model_validate( Recur.__parse_property_value__(rule.dst_end.rrule_str) ) std_timezone_info.dtstart = rule.dst_end.rrule_dtstart(start) daylight.append( Observance( tz_name=[rule.dst.name], tz_offset_to=UtcOffset(offset=rule.dst.offset), tz_offset_from=UtcOffset(offset=rule.std.offset), rrule=Recur.model_validate( Recur.__parse_property_value__(rule.dst_start.rrule_str) ), dtstart=rule.dst_start.rrule_dtstart(start), ) ) return Timezone(tz_id=key, standard=[std_timezone_info], daylight=daylight) def _observances( self, ) -> Iterable[tuple[datetime.datetime | datetime.date, _ObservanceInfo]]: return MergedIterable(self._std_observances() + self._dst_observances()) def _std_observances( self, ) -> list[Iterable[tuple[datetime.datetime | datetime.date, _ObservanceInfo]]]: iters: list[ Iterable[tuple[datetime.datetime | datetime.date, _ObservanceInfo]] ] = [] for observance in self.standard: iters.append( RecurIterable( _ObservanceInfo(_ObservanceType.STANDARD, observance).get, observance.as_ruleset(), ) ) return iters def _dst_observances( self, ) -> list[Iterable[tuple[datetime.datetime | datetime.date, _ObservanceInfo]]]: iters: list[ Iterable[tuple[datetime.datetime | datetime.date, _ObservanceInfo]] ] = [] for observance in self.daylight: iters.append( RecurIterable( _ObservanceInfo(_ObservanceType.DAYLIGHT, observance).get, observance.as_ruleset(), ) ) return iters def get_observance(self, value: datetime.datetime) -> _ObservanceInfo | None: """Return the specified observance for the specified date.""" if value.tzinfo is not None: raise ValueError("Start time must be in local time format") last_observance_info: _ObservanceInfo | None = None for dt_start, observance_info in self._observances(): if dt_start > value: return last_observance_info last_observance_info = observance_info return last_observance_info @model_validator(mode="after") def parse_required_timezoneinfo(self) -> Self: """Require at least one standard or daylight definition.""" standard = self.standard daylight = self.daylight if not standard and not daylight: raise ValueError("At least one standard or daylight definition is required") return self serialize_fields = field_serializer("*")(serialize_field) # type: ignore[pydantic-field] class IcsTimezoneInfo(datetime.tzinfo): """An implementation of tzinfo based on an ICS Timezone. This class is used to provide a tzinfo object for any datetime object used within a calendar. An rfc5545 calendar is an unambiguous definition of a calendar, and as a result, must encode all timezone information used in the calendar, hence this class. """ def __init__(self, timezone: Timezone) -> None: """Initialize IcsTimezoneInfo.""" self._timezone = timezone def __deepcopy__(self, memo: Any) -> IcsTimezoneInfo: """Return a deep copy of the timezone object.""" return IcsTimezoneInfo( timezone=copy.deepcopy(self._timezone, memo), ) @classmethod def from_timezone(cls, timezone: Timezone) -> IcsTimezoneInfo: """Create a new instance of an IcsTimezoneInfo.""" return cls(timezone) def utcoffset(self, dt: datetime.datetime | None) -> datetime.timedelta: """Return offset of local time from UTC, as a timedelta object.""" if not dt or not (obs := self._get_observance(dt)): return _ZERO return obs.observance.tz_offset_to.offset def tzname(self, dt: datetime.datetime | None) -> str | None: """Return the time zone name for the datetime as a sorting.""" if ( not dt or not (obs := self._get_observance(dt)) or not obs.observance.tz_name ): return None return obs.observance.tz_name[0] def dst(self, dt: datetime.datetime | None) -> datetime.timedelta | None: """Return the daylight saving time (DST) adjustment, if applicable.""" if ( not dt or not (obs := self._get_observance(dt)) or obs.observance_type != _ObservanceType.DAYLIGHT ): return _ZERO return obs.observance.tz_offset_to.offset - obs.observance.tz_offset_from.offset def _get_observance(self, value: datetime.datetime) -> _ObservanceInfo | None: return self._timezone.get_observance(value.replace(tzinfo=None)) def __str__(self) -> str: """A string representation of the timezone object.""" return self._timezone.tz_id def __repr__(self) -> str: """A string representation of the timezone object.""" return f"{self.__class__.__name__}({self._timezone.tz_id})" class TimezoneModel(ComponentModel): """A parser of a calendar that just parses timezone data. This exists so that we can parse timezone information in a first pass then propagate that information down to child objects when parsing the rest of the calendar. This is so we can do one pass on parsing events and timezone information at once, rather than deferring to later. """ timezones: list[Timezone] = Field(alias="vtimezone", default_factory=list) """Timezones associated with this calendar."""