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,
)
|