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):
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):
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(
78 self, dt: datetime.datetime, tz: datetime.tzinfo | str | None
79 ) -> datetime.datetime: ...
80
81 @overload
82 def localize(
83 self, dt: datetime.time, tz: datetime.tzinfo | str | None
84 ) -> datetime.time: ...
85
86 def localize(
87 self, dt: datetime.date | datetime.time, tz: datetime.tzinfo | str | None
88 ) -> datetime.datetime | datetime.time:
89 """Localize a datetime or time to a timezone.
90
91 Returns:
92 - A localized :class:`datetime.datetime` when a
93 :class:`datetime.datetime` is given.
94 - A localized :class:`datetime.time` when a
95 :class:`datetime.time` is given.
96 """
97 if isinstance(tz, str):
98 tz = self.timezone(tz)
99 if tz is None:
100 return dt.replace(tzinfo=None)
101 if isinstance(dt, time):
102 dt_full = datetime.combine(datetime(2020, 1, 1), dt) # noqa: DTZ001
103 localized = self.__provider.localize(dt_full, tz)
104 return localized.timetz()
105 return self.__provider.localize(to_datetime(dt), tz)
106
107 def cache_timezone_component(self, timezone_component: Timezone.Timezone) -> None:
108 """Cache the timezone that is created from a timezone component
109 if it is not already known.
110
111 This can influence the result from timezone(): Once cached, the
112 custom timezone is returned from timezone().
113 """
114 _unclean_id = timezone_component["TZID"]
115 _id = self.clean_timezone_id(_unclean_id)
116 if (
117 not self.__provider.knows_timezone_id(_id)
118 and not self.__provider.knows_timezone_id(_unclean_id)
119 and _id not in self.__tz_cache
120 ):
121 self.__tz_cache[_id] = timezone_component.to_tz(self, lookup_tzid=False)
122
123 def fix_rrule_until(self, rrule: rrule, ical_rrule: prop.vRecur) -> None:
124 """Make sure the until value works."""
125 self.__provider.fix_rrule_until(rrule, ical_rrule)
126
127 def create_timezone(self, timezone_component: Timezone.Timezone) -> datetime.tzinfo:
128 """Create a timezone from a timezone component.
129
130 This component will not be cached.
131 """
132 return self.__provider.create_timezone(timezone_component)
133
134 def clean_timezone_id(self, tzid: str) -> str:
135 """Return a clean version of the timezone id.
136
137 Timezone ids can be a bit unclean, starting with a / for example.
138 Internally, we should use this to identify timezones.
139 """
140 return tzid.strip("/")
141
142 def timezone(self, tz_id: str) -> datetime.tzinfo | None:
143 """Return a timezone with an ID or ``None`` if we can't find it.
144
145 ``tz_id`` may be a plain Olson name (``Europe/Berlin``), a Windows
146 timezone name, or a "globally unique" identifier
147 (:rfc:`5545#section-3.2.19`) such as
148 ``/freeassociation.sourceforge.net/Europe/Berlin``. We try the
149 candidate IDs from :meth:`_lookup_ids` in order, checking the cache
150 before the provider for each one, and cache the first match under the
151 primary ID so the next lookup is fast.
152 """
153 primary = None
154 for lookup_id, is_global_guess in self._lookup_ids(tz_id):
155 if primary is None:
156 primary = lookup_id
157 tz = self.__tz_cache.get(lookup_id) or self.__provider.timezone(lookup_id)
158 if tz is not None:
159 if is_global_guess:
160 from icalendar.error import GloballyUniqueTZIDGuessed
161
162 warnings.warn(
163 f"Timezone {tz_id!r} is a globally unique TZID; "
164 f"guessing it means {lookup_id!r} by stripping the vendor "
165 "prefix. This may be wrong. See RFC 5545 section 3.2.19.",
166 GloballyUniqueTZIDGuessed,
167 stacklevel=3,
168 )
169 self.__tz_cache[primary] = tz
170 return tz
171 return None
172
173 def _lookup_ids(self, tz_id: str) -> Iterator[tuple[str, bool]]:
174 """Yield ``(id, is_global_guess)`` tuples to try, best match first.
175
176 1. The cleaned ID, without any surrounding ``/``.
177 2. The Olson name of a Windows timezone (for example,
178 ``W. Europe Standard Time`` -> ``Europe/Berlin``).
179 3. For a "globally unique" TZID (:rfc:`5545#section-3.2.19`) of the
180 form ``/<vendor>/<Olson/Name>``—emitted by clients such as
181 libical, Evolution and Mozilla Lightning—the trailing Olson
182 identifier, dropping vendor path components from the front. The
183 longest suffix is tried first, so multi-part names such as
184 ``America/Argentina/Buenos_Aires`` still match.
185 4. The original, unmodified ID.
186 """
187 cleaned = self.clean_timezone_id(tz_id)
188 yield cleaned, False
189 if cleaned in WINDOWS_TO_OLSON:
190 yield WINDOWS_TO_OLSON[cleaned], False
191 if tz_id.startswith("/"):
192 parts = cleaned.split("/")
193 for start in range(1, len(parts)):
194 yield "/".join(parts[start:]), True
195 yield tz_id, False
196
197 def uses_pytz(self) -> bool:
198 """Whether we use pytz at all."""
199 return self.__provider.uses_pytz()
200
201 def uses_zoneinfo(self) -> bool:
202 """Whether we use zoneinfo."""
203 return self.__provider.uses_zoneinfo()
204
205 @property
206 def name(self) -> str:
207 """The name of the timezone component used."""
208 return self.__provider.name
209
210 def __repr__(self) -> str:
211 return f"{self.__class__.__name__}({self.name!r})"
212
213
214__all__ = ["TZP"]