fix: 포트 충돌 회피 — note_bridge 8098, intent_service 8099

Jellyfin(8096), OrbStack(8097) 포트 충돌으로 변경.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hyungi Ahn
2026-03-19 13:53:55 +09:00
parent dc08d29509
commit c2257d3a86
2709 changed files with 619549 additions and 10 deletions

View File

@@ -0,0 +1,27 @@
"""This package contains all functionality for timezones."""
from .tzid import tzid_from_dt, tzid_from_tzinfo, tzids_from_tzinfo
from .tzp import TZP
tzp = TZP()
def use_pytz():
"""Use pytz as the implementation that looks up and creates timezones."""
tzp.use_pytz()
def use_zoneinfo():
"""Use zoneinfo as the implementation that looks up and creates timezones."""
tzp.use_zoneinfo()
__all__ = [
"TZP",
"tzp",
"use_pytz",
"use_zoneinfo",
"tzid_from_tzinfo",
"tzid_from_dt",
"tzids_from_tzinfo",
]

View File

@@ -0,0 +1,148 @@
"""This module helps identifying the timezone ids and where they differ.
The algorithm: We use the tzname and the utcoffset for each hour from
1970 - 2030.
We make a big map.
If they are equivalent, they are equivalent within the time that is mostly used.
You can regenerate the information from this module.
See also:
- https://stackoverflow.com/questions/79185519/which-timezones-are-equivalent
Run this module:
python -m icalendar.timezone.equivalent_timezone_ids
"""
from __future__ import annotations
from collections import defaultdict
from datetime import datetime, timedelta, tzinfo
from pathlib import Path
from pprint import pprint
from typing import Callable, NamedTuple, Optional
from pytz import AmbiguousTimeError, NonExistentTimeError
from zoneinfo import ZoneInfo, available_timezones
START = datetime(1970, 1, 1) # noqa: DTZ001
END = datetime(2020, 1, 1) # noqa: DTZ001
DISTANCE_FROM_TIMEZONE_CHANGE = timedelta(hours=12)
DTS = []
dt = START
while dt <= END:
DTS.append(dt)
dt += timedelta(
hours=25
) # This must be big enough to be fast and small enough to identify the timeszones before it is the present year
del dt
def main(
create_timezones: list[Callable[[str], tzinfo]],
name: str,
):
"""Generate a lookup table for timezone information if unknown timezones.
We cannot create one lookup for all because they seem to be all equivalent
if we mix timezone implementations.
"""
print(create_timezones, name)
unsorted_tzids = available_timezones()
unsorted_tzids.remove("localtime")
unsorted_tzids.remove("Factory")
class TZ(NamedTuple):
tz: tzinfo
id: str
tzs = [
TZ(create_timezone(tzid), tzid)
for create_timezone in create_timezones
for tzid in unsorted_tzids
]
def generate_tree(
tzs: list[TZ],
step: timedelta = timedelta(hours=1),
start: datetime = START,
end: datetime = END,
todo: Optional[set[str]] = None,
) -> tuple[datetime, dict[timedelta, set[str]]] | set[str]: # should be recursive
"""Generate a lookup tree."""
if todo is None:
todo = [tz.id for tz in tzs]
print(f"{len(todo)} left to compute")
print(len(tzs))
if len(tzs) == 0:
raise ValueError("tzs cannot be empty")
if len(tzs) == 1:
todo.remove(tzs[0].id)
return {tzs[0].id}
while start < end:
offsets: dict[timedelta, list[TZ]] = defaultdict(list)
try:
# if we are around a timezone change, we must move on
# see https://github.com/collective/icalendar/issues/776
around_tz_change = not all(
tz.tz.utcoffset(start)
== tz.tz.utcoffset(start - DISTANCE_FROM_TIMEZONE_CHANGE)
== tz.tz.utcoffset(start + DISTANCE_FROM_TIMEZONE_CHANGE)
for tz in tzs
)
except (NonExistentTimeError, AmbiguousTimeError):
around_tz_change = True
if around_tz_change:
start += DISTANCE_FROM_TIMEZONE_CHANGE
continue
for tz in tzs:
offsets[tz.tz.utcoffset(start)].append(tz)
if len(offsets) == 1:
start += step
continue
lookup = {}
for offset, tzs in offsets.items():
lookup[offset] = generate_tree(
tzs=tzs, step=step, start=start + step, end=end, todo=todo
)
return start, lookup
print(f"reached end with {len(tzs)} timezones - assuming they are equivalent.")
result = set()
for tz in tzs:
result.add(tz.id)
todo.remove(tz.id)
return result
lookup = generate_tree(tzs, step=timedelta(hours=33))
file = Path(__file__).parent / f"equivalent_timezone_ids_{name}.py"
print(f"The result is written to {file}.")
print("lookup = ", end="")
pprint(lookup)
with file.open("w") as f:
f.write(
f"'''This file is automatically generated by {Path(__file__).name}'''\n"
)
f.write("import datetime\n\n")
f.write("\nlookup = ")
pprint(lookup, stream=f)
f.write("\n\n__all__ = ['lookup']\n")
return lookup
__all__ = ["main"]
if __name__ == "__main__":
from dateutil.tz import gettz
from pytz import timezone
from zoneinfo import ZoneInfo
# add more timezone implementations if you like
main(
[ZoneInfo, timezone, gettz],
"result",
)

View File

@@ -0,0 +1,57 @@
"""The interface for timezone implementations."""
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import TYPE_CHECKING, Optional
if TYPE_CHECKING:
from datetime import datetime, tzinfo
from dateutil.rrule import rrule
from icalendar import prop
class TZProvider(ABC):
"""Interface for timezone implementations."""
@property
@abstractmethod
def name(self) -> str:
"""The name of the implementation."""
@abstractmethod
def localize_utc(self, dt: datetime) -> datetime:
"""Return the datetime in UTC."""
@abstractmethod
def localize(self, dt: datetime, tz: tzinfo) -> datetime:
"""Localize a datetime to a timezone."""
@abstractmethod
def knows_timezone_id(self, id: str) -> bool:
"""Whether the timezone is already cached by the implementation."""
@abstractmethod
def fix_rrule_until(self, rrule: rrule, ical_rrule: prop.vRecur) -> None:
"""Make sure the until value works for the rrule generated from the ical_rrule."""
@abstractmethod
def create_timezone(self, name: str, transition_times, transition_info) -> tzinfo:
"""Create a pytz timezone file given information."""
@abstractmethod
def timezone(self, name: str) -> Optional[tzinfo]:
"""Return a timezone with a name or None if we cannot find it."""
@abstractmethod
def uses_pytz(self) -> bool:
"""Whether we use pytz."""
@abstractmethod
def uses_zoneinfo(self) -> bool:
"""Whether we use zoneinfo."""
__all__ = ["TZProvider"]

View File

@@ -0,0 +1,72 @@
"""Use pytz timezones."""
from __future__ import annotations
import pytz
from .. import cal
from datetime import datetime, tzinfo
from pytz.tzinfo import DstTzInfo
from typing import Optional
from .provider import TZProvider
from icalendar import prop
from dateutil.rrule import rrule
class PYTZ(TZProvider):
"""Provide icalendar with timezones from pytz."""
name = "pytz"
def localize_utc(self, dt: datetime) -> datetime:
"""Return the datetime in UTC."""
if getattr(dt, "tzinfo", False) and dt.tzinfo is not None:
return dt.astimezone(pytz.utc)
# assume UTC for naive datetime instances
return pytz.utc.localize(dt)
def localize(self, dt: datetime, tz: tzinfo) -> datetime:
"""Localize a datetime to a timezone."""
return tz.localize(dt)
def knows_timezone_id(self, id: str) -> bool:
"""Whether the timezone is already cached by the implementation."""
return id in pytz.all_timezones
def fix_rrule_until(self, rrule: rrule, ical_rrule: prop.vRecur) -> None:
"""Make sure the until value works for the rrule generated from the ical_rrule."""
if not {"UNTIL", "COUNT"}.intersection(ical_rrule.keys()):
# pytz.timezones don't know any transition dates after 2038
# either
rrule._until = datetime(2038, 12, 31, tzinfo=pytz.UTC)
def create_timezone(self, tz: cal.Timezone) -> tzinfo:
"""Create a pytz timezone from the given information."""
transition_times, transition_info = tz.get_transitions()
name = tz.tz_name
cls = type(
name,
(DstTzInfo,),
{
"zone": name,
"_utc_transition_times": transition_times,
"_transition_info": transition_info,
},
)
return cls()
def timezone(self, name: str) -> Optional[tzinfo]:
"""Return a timezone with a name or None if we cannot find it."""
try:
return pytz.timezone(name)
except pytz.UnknownTimeZoneError:
pass
def uses_pytz(self) -> bool:
"""Whether we use pytz."""
return True
def uses_zoneinfo(self) -> bool:
"""Whether we use zoneinfo."""
return False
__all__ = ["PYTZ"]

View File

@@ -0,0 +1,112 @@
"""This module identifies timezones.
Normally, timezones have ids.
This is a way to access the ids if you have a
datetime.tzinfo object.
"""
from __future__ import annotations
from collections import defaultdict
from pathlib import Path
from typing import TYPE_CHECKING, Optional
from dateutil.tz import tz
from icalendar.timezone import equivalent_timezone_ids_result
if TYPE_CHECKING:
from datetime import datetime, tzinfo
DATEUTIL_UTC = tz.gettz("UTC")
DATEUTIL_UTC_PATH : Optional[str] = getattr(DATEUTIL_UTC, "_filename", None)
DATEUTIL_ZONEINFO_PATH = (
None if DATEUTIL_UTC_PATH is None else Path(DATEUTIL_UTC_PATH).parent
)
def tzids_from_tzinfo(tzinfo: Optional[tzinfo]) -> tuple[str]:
"""Get several timezone ids if we can identify the timezone.
>>> import zoneinfo
>>> from icalendar.timezone.tzid import tzids_from_tzinfo
>>> tzids_from_tzinfo(zoneinfo.ZoneInfo("Arctic/Longyearbyen"))
('Arctic/Longyearbyen', 'Atlantic/Jan_Mayen', 'Europe/Berlin', 'Europe/Budapest', 'Europe/Copenhagen', 'Europe/Oslo', 'Europe/Stockholm', 'Europe/Vienna')
>>> from dateutil.tz import gettz
>>> tzids_from_tzinfo(gettz("Europe/Berlin"))
('Europe/Berlin', 'Arctic/Longyearbyen', 'Atlantic/Jan_Mayen', 'Europe/Budapest', 'Europe/Copenhagen', 'Europe/Oslo', 'Europe/Stockholm', 'Europe/Vienna')
""" # The example might need to change if you recreate the lookup tree
if tzinfo is None:
return ()
if hasattr(tzinfo, "zone"):
return get_equivalent_tzids(tzinfo.zone) # pytz implementation
if hasattr(tzinfo, "key"):
return get_equivalent_tzids(tzinfo.key) # ZoneInfo implementation
if isinstance(tzinfo, tz._tzicalvtz): # noqa: SLF001
return get_equivalent_tzids(tzinfo._tzid) # noqa: SLF001
if isinstance(tzinfo, tz.tzstr):
return get_equivalent_tzids(tzinfo._s) # noqa: SLF001
if hasattr(tzinfo, "_filename"): # dateutil.tz.tzfile # noqa: SIM102
if DATEUTIL_ZONEINFO_PATH is not None:
# tzfile('/usr/share/zoneinfo/Europe/Berlin')
path = tzinfo._filename # noqa: SLF001
if path.startswith(str(DATEUTIL_ZONEINFO_PATH)):
tzid = str(Path(path).relative_to(DATEUTIL_ZONEINFO_PATH))
return get_equivalent_tzids(tzid)
return get_equivalent_tzids(path)
if isinstance(tzinfo, tz.tzutc):
return get_equivalent_tzids("UTC")
return ()
def tzid_from_tzinfo(tzinfo: Optional[tzinfo]) -> Optional[str]:
"""Retrieve the timezone id from the tzinfo object.
Some timezones are equivalent.
Thus, we might return one ID that is equivelant to others.
"""
tzids = tzids_from_tzinfo(tzinfo)
if "UTC" in tzids:
return "UTC"
if not tzids:
return None
return tzids[0]
def tzid_from_dt(dt: datetime) -> Optional[str]:
"""Retrieve the timezone id from the datetime object."""
tzid = tzid_from_tzinfo(dt.tzinfo)
if tzid is None:
return dt.tzname()
return tzid
_EQUIVALENT_IDS : dict[str, set[str]] = defaultdict(set)
def _add_equivalent_ids(value:tuple|dict|set):
"""This adds equivalent ids/
As soon as one timezone implementation used claims their equivalence,
they are considered equivalent.
Have a look at icalendar.timezone.equivalent_timezone_ids.
"""
if isinstance(value, set):
for tzid in value:
_EQUIVALENT_IDS[tzid].update(value)
elif isinstance(value, tuple):
_add_equivalent_ids(value[1])
elif isinstance(value, dict):
for value in value.values():
_add_equivalent_ids(value)
else:
raise TypeError(f"Expected tuple, dict or set, not {value.__class__.__name__}: {value!r}")
_add_equivalent_ids(equivalent_timezone_ids_result.lookup)
def get_equivalent_tzids(tzid: str) -> tuple[str]:
"""This returns the tzids which are equivalent to this one."""
ids = _EQUIVALENT_IDS.get(tzid, set())
return (tzid,) + tuple(sorted(ids - {tzid}))
__all__ = ["tzid_from_tzinfo", "tzid_from_dt", "tzids_from_tzinfo"]

View File

@@ -0,0 +1,146 @@
from __future__ import annotations
from typing import TYPE_CHECKING, Optional, Union
from icalendar.tools import to_datetime
from .windows_to_olson import WINDOWS_TO_OLSON
if TYPE_CHECKING:
import datetime
from dateutil.rrule import rrule
from icalendar import cal, prop
from .provider import TZProvider
DEFAULT_TIMEZONE_PROVIDER = "zoneinfo"
class TZP:
"""This is the timezone provider proxy.
If you would like to have another timezone implementation,
you can create a new one and pass it to this proxy.
All of icalendar will then use this timezone implementation.
"""
def __init__(self, provider: Union[str, TZProvider] = DEFAULT_TIMEZONE_PROVIDER):
"""Create a new timezone implementation proxy."""
self.use(provider)
def use_pytz(self) -> None:
"""Use pytz as the timezone provider."""
from .pytz import PYTZ
self._use(PYTZ())
def use_zoneinfo(self) -> None:
"""Use zoneinfo as the timezone provider."""
from .zoneinfo import ZONEINFO
self._use(ZONEINFO())
def _use(self, provider: TZProvider) -> None:
"""Use a timezone implementation."""
self.__tz_cache = {}
self.__provider = provider
def use(self, provider: Union[str, TZProvider]):
"""Switch to a different timezone provider."""
if isinstance(provider, str):
use_provider = getattr(self, f"use_{provider}", None)
if use_provider is None:
raise ValueError(
f"Unknown provider {provider}. Use 'pytz' or 'zoneinfo'."
)
use_provider()
else:
self._use(provider)
def use_default(self):
"""Use the default timezone provider."""
self.use(DEFAULT_TIMEZONE_PROVIDER)
def localize_utc(self, dt: datetime.date) -> datetime.datetime:
"""Return the datetime in UTC.
If the datetime has no timezone, set UTC as its timezone.
"""
return self.__provider.localize_utc(to_datetime(dt))
def localize(
self, dt: datetime.date, tz: Union[datetime.tzinfo, str, None]
) -> datetime.datetime:
"""Localize a datetime to a timezone."""
if isinstance(tz, str):
tz = self.timezone(tz)
if tz is None:
return dt.replace(tzinfo=None)
return self.__provider.localize(to_datetime(dt), tz)
def cache_timezone_component(self, timezone_component: cal.Timezone) -> None:
"""Cache the timezone that is created from a timezone component
if it is not already known.
This can influence the result from timezone(): Once cached, the
custom timezone is returned from timezone().
"""
_unclean_id = timezone_component["TZID"]
_id = self.clean_timezone_id(_unclean_id)
if (
not self.__provider.knows_timezone_id(_id)
and not self.__provider.knows_timezone_id(_unclean_id)
and _id not in self.__tz_cache
):
self.__tz_cache[_id] = timezone_component.to_tz(self, lookup_tzid=False)
def fix_rrule_until(self, rrule: rrule, ical_rrule: prop.vRecur) -> None:
"""Make sure the until value works."""
self.__provider.fix_rrule_until(rrule, ical_rrule)
def create_timezone(self, timezone_component: cal.Timezone) -> datetime.tzinfo:
"""Create a timezone from a timezone component.
This component will not be cached.
"""
return self.__provider.create_timezone(timezone_component)
def clean_timezone_id(self, tzid: str) -> str:
"""Return a clean version of the timezone id.
Timezone ids can be a bit unclean, starting with a / for example.
Internally, we should use this to identify timezones.
"""
return tzid.strip("/")
def timezone(self, tz_id: str) -> Optional[datetime.tzinfo]:
"""Return a timezone with an id or None if we cannot find it."""
_unclean_id = tz_id
tz_id = self.clean_timezone_id(tz_id)
tz = self.__provider.timezone(tz_id)
if tz is not None:
return tz
if tz_id in WINDOWS_TO_OLSON:
tz = self.__provider.timezone(WINDOWS_TO_OLSON[tz_id])
return tz or self.__provider.timezone(_unclean_id) or self.__tz_cache.get(tz_id)
def uses_pytz(self) -> bool:
"""Whether we use pytz at all."""
return self.__provider.uses_pytz()
def uses_zoneinfo(self) -> bool:
"""Whether we use zoneinfo."""
return self.__provider.uses_zoneinfo()
@property
def name(self) -> str:
"""The name of the timezone component used."""
return self.__provider.name
def __repr__(self) -> str:
return f"{self.__class__.__name__}({repr(self.name)})"
__all__ = ["TZP"]

View File

@@ -0,0 +1,119 @@
"""This module contains mappings from Windows timezone identifiers to
Olson timezone identifiers.
The data is taken from the unicode consortium [0], the proposal and rationale
for this mapping is also available at the unicode consortium [1].
[0] https://www.unicode.org/cldr/cldr-aux/charts/29/supplemental/zone_tzid.html
[1] https://cldr.unicode.org/development/development-process/design-proposals/extended-windows-olson-zid-mapping # noqa
"""
WINDOWS_TO_OLSON = {
"AUS Central Standard Time": "Australia/Darwin",
"AUS Eastern Standard Time": "Australia/Sydney",
"Afghanistan Standard Time": "Asia/Kabul",
"Alaskan Standard Time": "America/Anchorage",
"Arab Standard Time": "Asia/Riyadh",
"Arabian Standard Time": "Asia/Dubai",
"Arabic Standard Time": "Asia/Baghdad",
"Argentina Standard Time": "America/Argentina/Buenos_Aires",
"Atlantic Standard Time": "America/Halifax",
"Azerbaijan Standard Time": "Asia/Baku",
"Azores Standard Time": "Atlantic/Azores",
"Bahia Standard Time": "America/Bahia",
"Bangladesh Standard Time": "Asia/Dhaka",
"Belarus Standard Time": "Europe/Minsk",
"Canada Central Standard Time": "America/Regina",
"Cape Verde Standard Time": "Atlantic/Cape_Verde",
"Caucasus Standard Time": "Asia/Yerevan",
"Cen. Australia Standard Time": "Australia/Adelaide",
"Central America Standard Time": "America/Guatemala",
"Central Asia Standard Time": "Asia/Almaty",
"Central Brazilian Standard Time": "America/Cuiaba",
"Central Europe Standard Time": "Europe/Budapest",
"Central European Standard Time": "Europe/Warsaw",
"Central Pacific Standard Time": "Pacific/Guadalcanal",
"Central Standard Time": "America/Chicago",
"Central Standard Time (Mexico)": "America/Mexico_City",
"China Standard Time": "Asia/Shanghai",
"Dateline Standard Time": "Etc/GMT+12",
"E. Africa Standard Time": "Africa/Nairobi",
"E. Australia Standard Time": "Australia/Brisbane",
"E. Europe Standard Time": "Europe/Chisinau",
"E. South America Standard Time": "America/Sao_Paulo",
"Eastern Standard Time": "America/New_York",
"Eastern Standard Time (Mexico)": "America/Cancun",
"Egypt Standard Time": "Africa/Cairo",
"Ekaterinburg Standard Time": "Asia/Yekaterinburg",
"FLE Standard Time": "Europe/Kyiv",
"Fiji Standard Time": "Pacific/Fiji",
"GMT Standard Time": "Europe/London",
"GTB Standard Time": "Europe/Bucharest",
"Georgian Standard Time": "Asia/Tbilisi",
"Greenland Standard Time": "America/Nuuk",
"Greenwich Standard Time": "Atlantic/Reykjavik",
"Hawaiian Standard Time": "Pacific/Honolulu",
"India Standard Time": "Asia/Kolkata",
"Iran Standard Time": "Asia/Tehran",
"Israel Standard Time": "Asia/Jerusalem",
"Jordan Standard Time": "Asia/Amman",
"Kaliningrad Standard Time": "Europe/Kaliningrad",
"Korea Standard Time": "Asia/Seoul",
"Libya Standard Time": "Africa/Tripoli",
"Line Islands Standard Time": "Pacific/Kiritimati",
"Magadan Standard Time": "Asia/Magadan",
"Mauritius Standard Time": "Indian/Mauritius",
"Middle East Standard Time": "Asia/Beirut",
"Montevideo Standard Time": "America/Montevideo",
"Morocco Standard Time": "Africa/Casablanca",
"Mountain Standard Time": "America/Denver",
"Mountain Standard Time (Mexico)": "America/Chihuahua",
"Myanmar Standard Time": "Asia/Yangon",
"N. Central Asia Standard Time": "Asia/Novosibirsk",
"Namibia Standard Time": "Africa/Windhoek",
"Nepal Standard Time": "Asia/Kathmandu",
"New Zealand Standard Time": "Pacific/Auckland",
"Newfoundland Standard Time": "America/St_Johns",
"North Asia East Standard Time": "Asia/Irkutsk",
"North Asia Standard Time": "Asia/Krasnoyarsk",
"North Korea Standard Time": "Asia/Pyongyang",
"Pacific SA Standard Time": "America/Santiago",
"Pacific Standard Time": "America/Los_Angeles",
"Pakistan Standard Time": "Asia/Karachi",
"Paraguay Standard Time": "America/Asuncion",
"Romance Standard Time": "Europe/Paris",
"Russia Time Zone 10": "Asia/Srednekolymsk",
"Russia Time Zone 11": "Asia/Kamchatka",
"Russia Time Zone 3": "Europe/Samara",
"Russian Standard Time": "Europe/Moscow",
"SA Eastern Standard Time": "America/Cayenne",
"SA Pacific Standard Time": "America/Bogota",
"SA Western Standard Time": "America/La_Paz",
"SE Asia Standard Time": "Asia/Bangkok",
"Samoa Standard Time": "Pacific/Apia",
"Singapore Standard Time": "Asia/Singapore",
"South Africa Standard Time": "Africa/Johannesburg",
"Sri Lanka Standard Time": "Asia/Colombo",
"Syria Standard Time": "Asia/Damascus",
"Taipei Standard Time": "Asia/Taipei",
"Tasmania Standard Time": "Australia/Hobart",
"Tokyo Standard Time": "Asia/Tokyo",
"Tonga Standard Time": "Pacific/Tongatapu",
"Turkey Standard Time": "Europe/Istanbul",
"US Eastern Standard Time": "America/Indiana/Indianapolis",
"US Mountain Standard Time": "America/Phoenix",
"UTC": "Etc/GMT",
"UTC+12": "Etc/GMT-12",
"UTC-02": "Etc/GMT+2",
"UTC-11": "Etc/GMT+11",
"Ulaanbaatar Standard Time": "Asia/Ulaanbaatar",
"Venezuela Standard Time": "America/Caracas",
"Vladivostok Standard Time": "Asia/Vladivostok",
"W. Australia Standard Time": "Australia/Perth",
"W. Central Africa Standard Time": "Africa/Lagos",
"W. Europe Standard Time": "Europe/Berlin",
"West Asia Standard Time": "Asia/Tashkent",
"West Pacific Standard Time": "Pacific/Port_Moresby",
"Yakutsk Standard Time": "Asia/Yakutsk",
}

View File

@@ -0,0 +1,160 @@
"""Use zoneinfo timezones"""
from __future__ import annotations
from icalendar.tools import is_date, to_datetime
try:
import zoneinfo
except ImportError:
from backports import zoneinfo # type: ignore # noqa: PGH003
import copy
import copyreg
import functools
from datetime import datetime, tzinfo
from io import StringIO
from typing import TYPE_CHECKING, Optional
from dateutil.rrule import rrule, rruleset
from dateutil.tz import tzical
from dateutil.tz.tz import _tzicalvtz
from .provider import TZProvider
if TYPE_CHECKING:
from icalendar import cal, prop
from icalendar.prop import vDDDTypes
class ZONEINFO(TZProvider):
"""Provide icalendar with timezones from zoneinfo."""
name = "zoneinfo"
utc = zoneinfo.ZoneInfo("UTC")
_available_timezones = zoneinfo.available_timezones()
def localize(self, dt: datetime, tz: zoneinfo.ZoneInfo) -> datetime:
"""Localize a datetime to a timezone."""
return dt.replace(tzinfo=tz)
def localize_utc(self, dt: datetime) -> datetime:
"""Return the datetime in UTC."""
if getattr(dt, "tzinfo", False) and dt.tzinfo is not None:
return dt.astimezone(self.utc)
return self.localize(dt, self.utc)
def timezone(self, name: str) -> Optional[tzinfo]:
"""Return a timezone with a name or None if we cannot find it."""
try:
return zoneinfo.ZoneInfo(name)
except zoneinfo.ZoneInfoNotFoundError:
pass
except ValueError:
# ValueError: ZoneInfo keys may not be absolute paths, got: /Europe/CUSTOM
pass
def knows_timezone_id(self, id: str) -> bool:
"""Whether the timezone is already cached by the implementation."""
return id in self._available_timezones
def fix_rrule_until(self, rrule: rrule, ical_rrule: prop.vRecur) -> None:
"""Make sure the until value works for the rrule generated from the ical_rrule."""
if not {"UNTIL", "COUNT"}.intersection(ical_rrule.keys()):
# zoninfo does not know any transition dates after 2038
rrule._until = datetime(2038, 12, 31, tzinfo=self.utc)
def create_timezone(self, tz: cal.Timezone) -> tzinfo:
"""Create a timezone from the given information."""
try:
return self._create_timezone(tz)
except ValueError:
# We might have a custom component in there.
# see https://github.com/python/cpython/issues/120217
tz = copy.deepcopy(tz)
for sub in tz.walk():
for attr in list(sub.keys()):
if attr.lower().startswith("x-"):
sub.pop(attr)
for sub in tz.subcomponents:
start : vDDDTypes = sub.get("DTSTART")
if start and is_date(start.dt):
# ValueError: Unsupported DTSTART param in VTIMEZONE: VALUE=DATE
sub.DTSTART = to_datetime(start.dt)
return self._create_timezone(tz)
def _create_timezone(self, tz: cal.Timezone) -> tzinfo:
"""Create a timezone and maybe fail"""
file = StringIO(tz.to_ical().decode("UTF-8", "replace"))
return tzical(file).get()
def uses_pytz(self) -> bool:
"""Whether we use pytz."""
return False
def uses_zoneinfo(self) -> bool:
"""Whether we use zoneinfo."""
return True
def pickle_tzicalvtz(tzicalvtz: tz._tzicalvtz):
"""Because we use dateutil.tzical, we need to make it pickle-able."""
return _tzicalvtz, (tzicalvtz._tzid, tzicalvtz._comps)
copyreg.pickle(_tzicalvtz, pickle_tzicalvtz)
def pickle_rrule_with_cache(self: rrule):
"""Make sure we can also pickle rrules that cache.
This is mainly copied from rrule.replace.
"""
new_kwargs = {
"interval": self._interval,
"count": self._count,
"dtstart": self._dtstart,
"freq": self._freq,
"until": self._until,
"wkst": self._wkst,
"cache": False if self._cache is None else True,
}
new_kwargs.update(self._original_rule)
# from https://stackoverflow.com/a/64915638/1320237
return functools.partial(rrule, new_kwargs.pop("freq"), **new_kwargs), ()
copyreg.pickle(rrule, pickle_rrule_with_cache)
def pickle_rruleset_with_cache(rs: rruleset):
"""Pickle an rruleset."""
# self._rrule = []
# self._rdate = []
# self._exrule = []
# self._exdate = []
return unpickle_rruleset_with_cache, (
rs._rrule,
rs._rdate,
rs._exrule,
rs._exdate,
False if rs._cache is None else True,
)
def unpickle_rruleset_with_cache(rrule, rdate, exrule, exdate, cache):
"""unpickling the rruleset."""
rs = rruleset(cache)
for o in rrule:
rs.rrule(o)
for o in rdate:
rs.rdate(o)
for o in exrule:
rs.exrule(o)
for o in exdate:
rs.exdate(o)
return rs
copyreg.pickle(rruleset, pickle_rruleset_with_cache)
__all__ = ["ZONEINFO"]