1from __future__ import annotations
2
3import warnings
4from datetime import datetime, time
5from typing import TYPE_CHECKING, overload
6
7from icalendar.tools import to_datetime
8
9from .windows_to_olson import WINDOWS_TO_OLSON
10
11if TYPE_CHECKING:
12 from collections.abc import Iterator
13
14 from dateutil.rrule import rrule
15
16 from icalendar import prop
17 from icalendar.cal import Timezone
18
19 from .provider import TZProvider
20
21DEFAULT_TIMEZONE_PROVIDER = "zoneinfo"
22
23
24class TZP:
25 """This is the timezone provider proxy.
26
27 If you would like to have another timezone implementation,
28 you can create a new one and pass it to this proxy.
29 All of icalendar will then use this timezone implementation.
30 """
31
32 def __init__(self, provider: str | TZProvider = DEFAULT_TIMEZONE_PROVIDER) -> None:
33 """Create a new timezone implementation proxy."""
34 self.use(provider)
35
36 def use_pytz(self) -> None:
37 """Use pytz as the timezone provider."""
38 from .pytz import PYTZ # noqa: PLC0415, RUF100
39
40 self._use(PYTZ())
41
42 def use_zoneinfo(self) -> None:
43 """Use zoneinfo as the timezone provider."""
44 from .zoneinfo import ZONEINFO # noqa: PLC0415, RUF100
45
46 self._use(ZONEINFO())
47
48 def _use(self, provider: TZProvider) -> None:
49 """Use a timezone implementation."""
50 self.__tz_cache = {}
51 self.__provider = provider
52
53 def use(self, provider: str | TZProvider) -> None:
54 """Switch to a different timezone provider."""
55 if isinstance(provider, str):
56 use_provider = getattr(self, f"use_{provider}", None)
57 if use_provider is None:
58 raise ValueError(
59 f"Unknown provider {provider}. Use 'pytz' or 'zoneinfo'."
60 )
61 use_provider()
62 else:
63 self._use(provider)
64
65 def use_default(self) -> None:
66 """Use the default timezone provider."""
67 self.use(DEFAULT_TIMEZONE_PROVIDER)
68
69 def localize_utc(self, dt: datetime.date) -> datetime.datetime:
70 """Return the datetime in UTC.
71
72 If the datetime has no timezone, set UTC as its timezone.
73 """
74 return self.__provider.localize_utc(to_datetime(dt))
75
76 @overload
77 def localize(self, dt: datetime, tz: datetime.tzinfo | str | None) -> datetime: ...
78
79 @overload
80 def localize(self, dt: time, tz: datetime.tzinfo | str | None) -> time: ...
81
82 def localize(
83 self, dt: datetime.date | time, tz: datetime.tzinfo | str | None
84 ) -> datetime | datetime.time:
85 """Localize a datetime or time to a timezone.
86
87 Returns:
88 - A localized :class:`datetime.datetime` when a
89 :class:`datetime.datetime` is given.
90 - A localized :class:`datetime.time` when a
91 :class:`datetime.time` is given.
92 """
93 if isinstance(tz, str):
94 tz = self.timezone(tz)
95 if tz is None:
96 return dt.replace(tzinfo=None)
97 if isinstance(dt, time):
98 dt_full = datetime.combine(datetime(2020, 1, 1), dt) # noqa: DTZ001
99 localized = self.__provider.localize(dt_full, tz)
100 return localized.timetz()
101 return self.__provider.localize(to_datetime(dt), tz)
102
103 def cache_timezone_component(self, timezone_component: Timezone.Timezone) -> None:
104 """Cache the timezone that is created from a timezone component
105 if it is not already known.
106
107 This can influence the result from timezone(): Once cached, the
108 custom timezone is returned from timezone().
109 """
110 _unclean_id = timezone_component["TZID"]
111 _id = self.clean_timezone_id(_unclean_id)
112 if (
113 not self.__provider.knows_timezone_id(_id)
114 and not self.__provider.knows_timezone_id(_unclean_id)
115 and _id not in self.__tz_cache
116 ):
117 self.__tz_cache[_id] = timezone_component.to_tz(self, lookup_tzid=False)
118
119 def fix_rrule_until(self, rrule: rrule, ical_rrule: prop.vRecur) -> None:
120 """Make sure the until value works."""
121 self.__provider.fix_rrule_until(rrule, ical_rrule)
122
123 def create_timezone(self, timezone_component: Timezone.Timezone) -> datetime.tzinfo:
124 """Create a timezone from a timezone component.
125
126 This component will not be cached.
127 """
128 return self.__provider.create_timezone(timezone_component)
129
130 def clean_timezone_id(self, tzid: str) -> str:
131 """Return a clean version of the timezone id.
132
133 Timezone ids can be a bit unclean, starting with a / for example.
134 Internally, we should use this to identify timezones.
135 """
136 return tzid.strip("/")
137
138 def timezone(self, tz_id: str) -> datetime.tzinfo | None:
139 """Return a timezone with an ID or ``None`` if we can't find it.
140
141 ``tz_id`` may be a plain Olson name (``Europe/Berlin``), a Windows
142 timezone name, or a "globally unique" identifier
143 (:rfc:`5545#section-3.2.19`) such as
144 ``/freeassociation.sourceforge.net/Europe/Berlin``. We try the
145 candidate IDs from ``_lookup_ids`` in order, checking the cache
146 before the provider for each one, and cache the first match under the
147 primary ID so the next lookup is fast.
148 """
149 primary = None
150 for lookup_id, is_global_guess in self._lookup_ids(tz_id):
151 if primary is None:
152 primary = lookup_id
153 tz = self.__tz_cache.get(lookup_id) or self.__provider.timezone(lookup_id)
154 if tz is not None:
155 if is_global_guess:
156 from icalendar.error import GloballyUniqueTZIDGuessed
157
158 warnings.warn(
159 f"Timezone {tz_id!r} is a globally unique TZID; "
160 f"guessing it means {lookup_id!r} by stripping the vendor "
161 "prefix. This may be wrong. See RFC 5545 section 3.2.19.",
162 GloballyUniqueTZIDGuessed,
163 stacklevel=3,
164 )
165 self.__tz_cache[primary] = tz
166 return tz
167 return None
168
169 def _lookup_ids(self, tz_id: str) -> Iterator[tuple[str, bool]]:
170 """Yield ``(id, is_global_guess)`` tuples to try, best match first.
171
172 1. The cleaned ID, without any surrounding ``/``.
173 2. The Olson name of a Windows timezone (for example,
174 ``W. Europe Standard Time`` -> ``Europe/Berlin``).
175 3. For a "globally unique" TZID (:rfc:`5545#section-3.2.19`) of the
176 form ``/<vendor>/<Olson/Name>``—emitted by clients such as
177 libical, Evolution and Mozilla Lightning—the trailing Olson
178 identifier, dropping vendor path components from the front. The
179 longest suffix is tried first, so multi-part names such as
180 ``America/Argentina/Buenos_Aires`` still match.
181 4. The original, unmodified ID.
182 """
183 cleaned = self.clean_timezone_id(tz_id)
184 yield cleaned, False
185 if cleaned in WINDOWS_TO_OLSON:
186 yield WINDOWS_TO_OLSON[cleaned], False
187 if tz_id.startswith("/"):
188 parts = cleaned.split("/")
189 for start in range(1, len(parts)):
190 yield "/".join(parts[start:]), True
191 yield tz_id, False
192
193 def uses_pytz(self) -> bool:
194 """Whether we use pytz at all."""
195 return self.__provider.uses_pytz()
196
197 def uses_zoneinfo(self) -> bool:
198 """Whether we use zoneinfo."""
199 return self.__provider.uses_zoneinfo()
200
201 @property
202 def name(self) -> str:
203 """The name of the timezone component used."""
204 return self.__provider.name
205
206 def __repr__(self) -> str:
207 return f"{self.__class__.__name__}({self.name!r})"
208
209
210__all__ = ["TZP"]