File: tz_rule.py

package info (click to toggle)
python-ical 12.1.0-1
  • links: PTS, VCS
  • area: main
  • in suites: forky, sid
  • size: 1,776 kB
  • sloc: python: 15,157; sh: 9; makefile: 5
file content (229 lines) | stat: -rw-r--r-- 8,004 bytes parent folder | download
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
"""Library for parsing TZ rules.

TZ supports these two formats

No DST: std offset
  - std: Name of the timezone
  - offset: Time added to local time to get UTC
  Example: EST+5

DST: std offset dst [offset],start[/time],end[/time]
  - dst: Name of the Daylight savings time timezone
  - offset: Defaults to 1 hour ahead of STD offset if not specified
  - start & end: Time period when DST is in effect. The start/end have
    the following formats:
      Jn: A julian day between 1 and 365 (Feb 29th never counted)
      n: A julian day between 0 and 364 (Feb 29th is counted in leap years)
      Mm.w.d:
          m: Month between 1 and 12
          d: Between 0 (Sunday) and 6 (Saturday)
          w: Between 1 and 5. Week 1 is first week d occurs
      The time field is in hh:mm:ss. The hour can be 167 to -167.
"""

from __future__ import annotations


from dataclasses import dataclass
import datetime
import logging
import re
from typing import Any, Optional, Self, Union

from dateutil import rrule
from pydantic import BaseModel, field_validator, model_validator


_LOGGER = logging.getLogger(__name__)

_ZERO = datetime.timedelta(seconds=0)
_DEFAULT_TIME_DELTA = datetime.timedelta(hours=2)


def _parse_time(values: dict[str, Any]) -> datetime.timedelta | None:
    """Convert an offset from [+/-]hh[:mm[:ss]] to a valid timedelta pydantic format.

    The parse tree dict expects fields of hour, minutes, seconds (see tz_time rule in parser).
    """
    if (hour := values["hour"]) is None:
        return None
    sign = 1
    if hour.startswith("+"):
        hour = hour[1:]
    elif hour.startswith("-"):
        sign = -1
        hour = hour[1:]
    minutes = values.get("minutes") or "0"
    seconds = values.get("seconds") or "0"
    return datetime.timedelta(
        seconds=sign * (int(hour) * 60 * 60 + int(minutes) * 60 + int(seconds))
    )


@dataclass
class RuleDay:
    """A date referenced in a timezone rule for a julian day."""

    day_of_year: int
    """A day of the year between 1 and 365, leap days never supported."""

    time: datetime.timedelta
    """Offset of time in current local time when the rule goes into effect, default of 02:00:00."""


@dataclass
class RuleDate:
    """A date referenced in a timezone rule."""

    month: int
    """A month between 1 and 12."""

    day_of_week: int
    """A day of the week between 0 (Sunday) and 6 (Saturday)."""

    week_of_month: int
    """A week number of the month (1 to 5) based on the first occurrence of day_of_week."""

    time: datetime.timedelta
    """Offset of time in current local time when the rule goes into effect, default of 02:00:00."""

    def as_rrule(self, dtstart: datetime.datetime | None = None) -> rrule.rrule:
        """Return a recurrence rule for this timezone occurrence (no start date)."""
        dst_start_weekday = self._rrule_byday(self._rrule_week_of_month)
        if dtstart:
            dtstart = dtstart.replace(hour=0, minute=0, second=0) + self.time
        return rrule.rrule(
            freq=rrule.YEARLY,
            bymonth=self.month,
            byweekday=dst_start_weekday,
            dtstart=dtstart,
        )

    @property
    def rrule_str(self) -> str:
        """Return a recurrence rule string for this timezone occurrence."""
        return ";".join(
            [
                "FREQ=YEARLY",
                f"BYMONTH={self.month}",
                f"BYDAY={self._rrule_week_of_month}{self._rrule_byday}",
            ]
        )

    def rrule_dtstart(self, start: datetime.datetime) -> datetime.datetime:
        """Return an rrule dtstart starting at the specified date with the time applied."""
        dt_start = start.replace(hour=0, minute=0, second=0) + self.time
        return next(iter(self.as_rrule(dt_start)))

    @property
    def _rrule_byday(self) -> rrule.weekday:
        """Return the dateutil weekday for this rule based on day_of_week."""
        return rrule.weekdays[(self.day_of_week - 1) % 7]

    @property
    def _rrule_week_of_month(self) -> int:
        """Return the byday modifier for the week of the month."""
        if self.week_of_month == 5:
            return -1
        return self.week_of_month


@dataclass
class RuleOccurrence:
    """A TimeZone rule occurrence."""

    name: str
    """The name of the timezone occurrence e.g. EST."""

    offset: datetime.timedelta
    """UTC offset for this timezone occurrence (not time added to local time)."""

    def __post_init__(self) -> None:
        """Convert the offset from time added to local time to get UTC to a UTC offset."""
        self.offset = _ZERO - self.offset


@dataclass
class Rule:
    """A rule for evaluating future timezone transitions."""

    std: RuleOccurrence
    """An occurrence of a timezone transition for standard time."""

    dst: Optional[RuleOccurrence] = None
    """An occurrence of a timezone transition for standard time."""

    dst_start: Union[RuleDate, RuleDay, None] = None
    """Describes when dst goes into effect."""

    dst_end: Union[RuleDate, RuleDay, None] = None
    """Describes when dst ends (std starts)."""

    def __post_init__(self) -> None:
        """Infer the default DST offset if not specified."""
        if self.dst and not self.dst.offset:
            # If the dst offset is omitted, it defaults to one hour ahead of standard time.
            self.dst.offset = self.std.offset + datetime.timedelta(hours=1)


# Regexp for parsing the TZ string
_OFFSET_RE_PATTERN: re.Pattern[str] = re.compile(
    r"(?P<name>(\<[+\-]?\d+\>|[a-zA-Z]+))"  # name
    r"((?P<hour>[+-]?\d+)(?::(?P<minutes>\d{1,2})(?::(?P<seconds>\d{1,2}))?)?)?"  # offset
)
_START_END_RE_PATTERN = re.compile(
    # days in either julian (J prefix) or month.week.day (M prefix) format
    r",(J(?P<day_of_year>\d+)|M(?P<month>\d{1,2})\.(?P<week_of_month>\d)\.(?P<day_of_week>\d))"
    # time
    r"(\/(?P<hour>[+-]?\d+)(?::(?P<minutes>\d{1,2})(?::(?P<seconds>\d{1,2}))?)?)?"
)


def _rule_occurrence_from_match(match: re.Match[str]) -> RuleOccurrence:
    """Create a rule occurrence from a regex match."""
    return RuleOccurrence(
        name=match.group("name"), offset=_parse_time(match.groupdict()) or _ZERO
    )


def _rule_date_from_match(match: re.Match[str]) -> Union[RuleDay, RuleDate]:
    """Create a rule date from a regex match."""
    if match["day_of_year"] is not None:
        return RuleDay(
            day_of_year=int(match.group("day_of_year")),
            time=_parse_time(match.groupdict()) or _DEFAULT_TIME_DELTA,
        )
    return RuleDate(
        month=int(match.group("month")),
        week_of_month=int(match.group("week_of_month")),
        day_of_week=int(match.group("day_of_week")),
        time=_parse_time(match.groupdict()) or _DEFAULT_TIME_DELTA,
    )


def parse_tz_rule(tz_str: str) -> Rule:
    """Parse the TZ string into a Rule object."""
    buffer = tz_str
    if (std_match := _OFFSET_RE_PATTERN.match(buffer)) is None:
        raise ValueError(f"Unable to parse TZ string: {tz_str}")
    buffer = buffer[std_match.end() :]
    if (dst_match := _OFFSET_RE_PATTERN.match(buffer)) is not None:
        buffer = buffer[dst_match.end() :]
    if (std_start := _START_END_RE_PATTERN.match(buffer)) is not None:
        buffer = buffer[std_start.end() :]
    if (std_end := _START_END_RE_PATTERN.match(buffer)) is not None:
        buffer = buffer[std_end.end() :]
    if (std_start is None) != (std_end is None):
        raise ValueError(
            f"Unable to parse TZ string, should have both or neither start and end dates: {tz_str}"
        )
    if buffer:
        raise ValueError(
            f"Unable to parse TZ string, unexpected trailing data: {tz_str}"
        )
    return Rule(
        std=_rule_occurrence_from_match(std_match),
        dst=_rule_occurrence_from_match(dst_match) if dst_match else None,
        dst_start=_rule_date_from_match(std_start) if std_start else None,
        dst_end=_rule_date_from_match(std_end) if std_end else None,
    )