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