Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.11/site-packages/icalendar/cal/timezone.py: 32%

Shortcuts on this page

r m x   toggle line displays

j k   next/prev highlighted chunk

0   (zero) top of page

1   (one) first highlighted chunk

205 statements  

1""":rfc:`5545` components for timezone information.""" 

2 

3from __future__ import annotations 

4 

5from collections import defaultdict 

6from datetime import date, datetime, timedelta, tzinfo 

7from typing import TYPE_CHECKING, Optional 

8 

9import dateutil.rrule 

10import dateutil.tz 

11 

12from icalendar.attr import ( 

13 create_single_property, 

14 exdates_property, 

15 rdates_property, 

16 rrules_property, 

17) 

18from icalendar.cal.component import Component 

19from icalendar.cal.examples import get_example 

20from icalendar.prop import tzid_from_tzinfo, vUTCOffset 

21from icalendar.timezone import TZP, tzp 

22from icalendar.tools import is_date, to_datetime 

23 

24if TYPE_CHECKING: 

25 from icalendar.cal.calendar import Calendar 

26 

27 

28class Timezone(Component): 

29 """ 

30 A "VTIMEZONE" calendar component is a grouping of component 

31 properties that defines a time zone. It is used to describe the 

32 way in which a time zone changes its offset from UTC over time. 

33 """ 

34 

35 subcomponents: list[TimezoneStandard | TimezoneDaylight] 

36 

37 name = "VTIMEZONE" 

38 canonical_order = ("TZID",) 

39 required = ("TZID",) # it also requires one of components DAYLIGHT and STANDARD 

40 singletons = ( 

41 "TZID", 

42 "LAST-MODIFIED", 

43 "TZURL", 

44 ) 

45 

46 DEFAULT_FIRST_DATE = date(1970, 1, 1) 

47 DEFAULT_LAST_DATE = date(2038, 1, 1) 

48 

49 @classmethod 

50 def example(cls, name: str = "pacific_fiji") -> Calendar: 

51 """Return the timezone example with the given name.""" 

52 return cls.from_ical(get_example("timezones", name)) 

53 

54 @staticmethod 

55 def _extract_offsets(component: TimezoneDaylight | TimezoneStandard, tzname: str): 

56 """extract offsets and transition times from a VTIMEZONE component 

57 :param component: a STANDARD or DAYLIGHT component 

58 :param tzname: the name of the zone 

59 """ 

60 offsetfrom = component.TZOFFSETFROM 

61 offsetto = component.TZOFFSETTO 

62 dtstart = component.DTSTART 

63 

64 # offsets need to be rounded to the next minute, we might loose up 

65 # to 30 seconds accuracy, but it can't be helped (datetime 

66 # supposedly cannot handle smaller offsets) 

67 offsetto_s = int((offsetto.seconds + 30) / 60) * 60 

68 offsetto = timedelta(days=offsetto.days, seconds=offsetto_s) 

69 offsetfrom_s = int((offsetfrom.seconds + 30) / 60) * 60 

70 offsetfrom = timedelta(days=offsetfrom.days, seconds=offsetfrom_s) 

71 

72 # expand recurrences 

73 if "RRULE" in component: 

74 # to be paranoid about correct weekdays 

75 # evaluate the rrule with the current offset 

76 tzi = dateutil.tz.tzoffset("(offsetfrom)", offsetfrom) 

77 rrstart = dtstart.replace(tzinfo=tzi) 

78 

79 rrulestr = component["RRULE"].to_ical().decode("utf-8") 

80 rrule = dateutil.rrule.rrulestr(rrulestr, dtstart=rrstart) 

81 tzp.fix_rrule_until(rrule, component["RRULE"]) 

82 

83 # constructing the timezone requires UTC transition times. 

84 # here we construct local times without tzinfo, the offset to UTC 

85 # gets subtracted in to_tz(). 

86 transtimes = [dt.replace(tzinfo=None) for dt in rrule] 

87 

88 # or rdates 

89 elif "RDATE" in component: 

90 if not isinstance(component["RDATE"], list): 

91 rdates = [component["RDATE"]] 

92 else: 

93 rdates = component["RDATE"] 

94 transtimes = [dtstart] + [leaf.dt for tree in rdates for leaf in tree.dts] 

95 else: 

96 transtimes = [dtstart] 

97 

98 transitions = [ 

99 (transtime, offsetfrom, offsetto, tzname) for transtime in set(transtimes) 

100 ] 

101 

102 if component.name == "STANDARD": 

103 is_dst = 0 

104 elif component.name == "DAYLIGHT": 

105 is_dst = 1 

106 return is_dst, transitions 

107 

108 @staticmethod 

109 def _make_unique_tzname(tzname, tznames): 

110 """ 

111 :param tzname: Candidate tzname 

112 :param tznames: Other tznames 

113 """ 

114 # TODO better way of making sure tznames are unique 

115 while tzname in tznames: 

116 tzname += "_1" 

117 tznames.add(tzname) 

118 return tzname 

119 

120 def to_tz(self, tzp: TZP = tzp, lookup_tzid: bool = True): # noqa: FBT001 

121 """convert this VTIMEZONE component to a timezone object 

122 

123 :param tzp: timezone provider to use 

124 :param lookup_tzid: whether to use the TZID property to look up existing 

125 timezone definitions with tzp. 

126 If it is False, a new timezone will be created. 

127 If it is True, the existing timezone will be used 

128 if it exists, otherwise a new timezone will be created. 

129 """ 

130 if lookup_tzid: 

131 tz = tzp.timezone(self.tz_name) 

132 if tz is not None: 

133 return tz 

134 return tzp.create_timezone(self) 

135 

136 @property 

137 def tz_name(self) -> str: 

138 """Return the name of the timezone component. 

139 

140 Please note that the names of the timezone are different from this name 

141 and may change with winter/summer time. 

142 """ 

143 try: 

144 return str(self["TZID"]) 

145 except UnicodeEncodeError: 

146 return self["TZID"].encode("ascii", "replace") 

147 

148 def get_transitions( 

149 self, 

150 ) -> tuple[list[datetime], list[tuple[timedelta, timedelta, str]]]: 

151 """Return a tuple of (transition_times, transition_info) 

152 

153 - transition_times = [datetime, ...] 

154 - transition_info = [(TZOFFSETTO, dts_offset, tzname)] 

155 

156 """ 

157 zone = self.tz_name 

158 transitions = [] 

159 dst = {} 

160 tznames = set() 

161 for component in self.walk(): 

162 if isinstance(component, Timezone): 

163 continue 

164 if is_date(component["DTSTART"].dt): 

165 component.DTSTART = to_datetime(component["DTSTART"].dt) 

166 assert isinstance(component["DTSTART"].dt, datetime), ( 

167 "VTIMEZONEs sub-components' DTSTART must be of type datetime, not date" 

168 ) 

169 try: 

170 tzname = str(component["TZNAME"]) 

171 except UnicodeEncodeError: 

172 tzname = component["TZNAME"].encode("ascii", "replace") 

173 tzname = self._make_unique_tzname(tzname, tznames) 

174 except KeyError: 

175 # for whatever reason this is str/unicode 

176 tzname = ( 

177 f"{zone}_{component['DTSTART'].to_ical().decode('utf-8')}_" 

178 f"{component['TZOFFSETFROM'].to_ical()}_" 

179 f"{component['TZOFFSETTO'].to_ical()}" 

180 ) 

181 tzname = self._make_unique_tzname(tzname, tznames) 

182 

183 dst[tzname], component_transitions = self._extract_offsets( 

184 component, tzname 

185 ) 

186 transitions.extend(component_transitions) 

187 

188 transitions.sort() 

189 transition_times = [ 

190 transtime - osfrom for transtime, osfrom, _, _ in transitions 

191 ] 

192 

193 # transition_info is a list with tuples in the format 

194 # (utcoffset, dstoffset, name) 

195 # dstoffset = 0, if current transition is to standard time 

196 # = this_utcoffset - prev_standard_utcoffset, otherwise 

197 transition_info = [] 

198 for num, (_transtime, _osfrom, osto, name) in enumerate(transitions): 

199 dst_offset = False 

200 if not dst[name]: 

201 dst_offset = timedelta(seconds=0) 

202 else: 

203 # go back in time until we find a transition to dst 

204 for index in range(num - 1, -1, -1): 

205 if not dst[transitions[index][3]]: # [3] is the name 

206 dst_offset = osto - transitions[index][2] # [2] is osto 

207 break 

208 # when the first transition is to dst, we didn't find anything 

209 # in the past, so we have to look into the future 

210 if not dst_offset: 

211 for index in range(num, len(transitions)): 

212 if not dst[transitions[index][3]]: # [3] is the name 

213 dst_offset = osto - transitions[index][2] # [2] is osto 

214 break 

215 assert dst_offset is not False 

216 transition_info.append((osto, dst_offset, name)) 

217 return transition_times, transition_info 

218 

219 # binary search 

220 _from_tzinfo_skip_search = [ 

221 timedelta(days=days) for days in (64, 32, 16, 8, 4, 2, 1) 

222 ] + [ 

223 # we know it happens in the night usually around 1am 

224 timedelta(hours=4), 

225 timedelta(hours=1), 

226 # adding some minutes and seconds for faster search 

227 timedelta(minutes=20), 

228 timedelta(minutes=5), 

229 timedelta(minutes=1), 

230 timedelta(seconds=20), 

231 timedelta(seconds=5), 

232 timedelta(seconds=1), 

233 ] 

234 

235 @classmethod 

236 def from_tzinfo( 

237 cls, 

238 timezone: tzinfo, 

239 tzid: Optional[str] = None, 

240 first_date: date = DEFAULT_FIRST_DATE, 

241 last_date: date = DEFAULT_LAST_DATE, 

242 ) -> Timezone: 

243 """Return a VTIMEZONE component from a timezone object. 

244 

245 This works with pytz and zoneinfo and any other timezone. 

246 The offsets are calculated from the tzinfo object. 

247 

248 Parameters: 

249 

250 :param tzinfo: the timezone object 

251 :param tzid: the tzid for this timezone. If None, it will be extracted from the tzinfo. 

252 :param first_date: a datetime that is earlier than anything that happens in the calendar 

253 :param last_date: a datetime that is later than anything that happens in the calendar 

254 :raises ValueError: If we have no tzid and cannot extract one. 

255 

256 .. note:: 

257 This can take some time. Please cache the results. 

258 """ # noqa: E501 

259 if tzid is None: 

260 tzid = tzid_from_tzinfo(timezone) 

261 if tzid is None: 

262 raise ValueError( 

263 f"Cannot get TZID from {timezone}. Please set the tzid parameter." 

264 ) 

265 normalize = getattr(timezone, "normalize", lambda dt: dt) # pytz compatibility 

266 first_datetime = datetime(first_date.year, first_date.month, first_date.day) # noqa: DTZ001 

267 last_datetime = datetime(last_date.year, last_date.month, last_date.day) # noqa: DTZ001 

268 if hasattr(timezone, "localize"): # pytz compatibility 

269 first_datetime = timezone.localize(first_datetime) 

270 last_datetime = timezone.localize(last_datetime) 

271 else: 

272 first_datetime = first_datetime.replace(tzinfo=timezone) 

273 last_datetime = last_datetime.replace(tzinfo=timezone) 

274 # from, to, tzname, is_standard -> start 

275 offsets: dict[ 

276 tuple[Optional[timedelta], timedelta, str, bool], list[datetime] 

277 ] = defaultdict(list) 

278 start = first_datetime 

279 offset_to = None 

280 while start < last_datetime: 

281 offset_from = offset_to 

282 end = start 

283 offset_to = end.utcoffset() 

284 for add_offset in cls._from_tzinfo_skip_search: 

285 last_end = end # we need to save this as we might be left and right of the time change # noqa: E501 

286 end = normalize(end + add_offset) 

287 try: 

288 while end.utcoffset() == offset_to: 

289 last_end = end 

290 end = normalize(end + add_offset) 

291 except OverflowError: 

292 # zoninfo does not go all the way 

293 break 

294 # retract if we overshoot 

295 end = last_end 

296 # Now, start (inclusive) -> end (exclusive) are one timezone 

297 is_standard = start.dst() == timedelta() 

298 name = start.tzname() 

299 if name is None: 

300 name = str(offset_to) 

301 key = (offset_from, offset_to, name, is_standard) 

302 # first_key = (None,) + key[1:] 

303 # if first_key in offsets: 

304 # # remove the first one and claim it changes at that day 

305 # offsets[first_key] = offsets.pop(first_key) 

306 offsets[key].append(start.replace(tzinfo=None)) 

307 start = normalize(end + cls._from_tzinfo_skip_search[-1]) 

308 tz = cls() 

309 tz.add("TZID", tzid) 

310 tz.add("COMMENT", f"This timezone only works from {first_date} to {last_date}.") 

311 for (offset_from, offset_to, tzname, is_standard), starts in offsets.items(): 

312 first_start = min(starts) 

313 starts.remove(first_start) 

314 if first_start.date() == last_date: 

315 first_start = datetime(last_date.year, last_date.month, last_date.day) # noqa: DTZ001 

316 subcomponent = TimezoneStandard() if is_standard else TimezoneDaylight() 

317 if offset_from is None: 

318 offset_from = offset_to # noqa: PLW2901 

319 subcomponent.TZOFFSETFROM = offset_from 

320 subcomponent.TZOFFSETTO = offset_to 

321 subcomponent.add("TZNAME", tzname) 

322 subcomponent.DTSTART = first_start 

323 if starts: 

324 subcomponent.add("RDATE", starts) 

325 tz.add_component(subcomponent) 

326 return tz 

327 

328 @classmethod 

329 def from_tzid( 

330 cls, 

331 tzid: str, 

332 tzp: TZP = tzp, 

333 first_date: date = DEFAULT_FIRST_DATE, 

334 last_date: date = DEFAULT_LAST_DATE, 

335 ) -> Timezone: 

336 """Create a VTIMEZONE from a tzid like ``"Europe/Berlin"``. 

337 

338 :param tzid: the id of the timezone 

339 :param tzp: the timezone provider 

340 :param first_date: a datetime that is earlier than anything 

341 that happens in the calendar 

342 :param last_date: a datetime that is later than anything 

343 that happens in the calendar 

344 :raises ValueError: If the tzid is unknown. 

345 

346 >>> from icalendar import Timezone 

347 >>> tz = Timezone.from_tzid("Europe/Berlin") 

348 >>> print(tz.to_ical()[:36]) 

349 BEGIN:VTIMEZONE 

350 TZID:Europe/Berlin 

351 

352 .. note:: 

353 This can take some time. Please cache the results. 

354 """ 

355 tz = tzp.timezone(tzid) 

356 if tz is None: 

357 raise ValueError(f"Unkown timezone {tzid}.") 

358 return cls.from_tzinfo(tz, tzid, first_date, last_date) 

359 

360 @property 

361 def standard(self) -> list[TimezoneStandard]: 

362 """The STANDARD subcomponents as a list.""" 

363 return self.walk("STANDARD") 

364 

365 @property 

366 def daylight(self) -> list[TimezoneDaylight]: 

367 """The DAYLIGHT subcomponents as a list. 

368 

369 These are for the daylight saving time. 

370 """ 

371 return self.walk("DAYLIGHT") 

372 

373 

374class TimezoneStandard(Component): 

375 """ 

376 The "STANDARD" sub-component of "VTIMEZONE" defines the standard 

377 time offset from UTC for a time zone. It represents a time zone's 

378 standard time, typically used during winter months in locations 

379 that observe Daylight Saving Time. 

380 """ 

381 

382 name = "STANDARD" 

383 required = ("DTSTART", "TZOFFSETTO", "TZOFFSETFROM") 

384 singletons = ( 

385 "DTSTART", 

386 "TZOFFSETTO", 

387 "TZOFFSETFROM", 

388 ) 

389 multiple = ("COMMENT", "RDATE", "TZNAME", "RRULE", "EXDATE") 

390 

391 DTSTART = create_single_property( 

392 "DTSTART", 

393 "dt", 

394 (datetime,), 

395 datetime, 

396 """The mandatory "DTSTART" property gives the effective onset date 

397 and local time for the time zone sub-component definition. 

398 "DTSTART" in this usage MUST be specified as a date with a local 

399 time value.""", 

400 ) 

401 TZOFFSETTO = create_single_property( 

402 "TZOFFSETTO", 

403 "td", 

404 (timedelta,), 

405 timedelta, 

406 """The mandatory "TZOFFSETTO" property gives the UTC offset for the 

407 time zone sub-component (Standard Time or Daylight Saving Time) 

408 when this observance is in use. 

409 """, 

410 vUTCOffset, 

411 ) 

412 TZOFFSETFROM = create_single_property( 

413 "TZOFFSETFROM", 

414 "td", 

415 (timedelta,), 

416 timedelta, 

417 """The mandatory "TZOFFSETFROM" property gives the UTC offset that is 

418 in use when the onset of this time zone observance begins. 

419 "TZOFFSETFROM" is combined with "DTSTART" to define the effective 

420 onset for the time zone sub-component definition. For example, 

421 the following represents the time at which the observance of 

422 Standard Time took effect in Fall 1967 for New York City: 

423 

424 DTSTART:19671029T020000 

425 TZOFFSETFROM:-0400 

426 """, 

427 vUTCOffset, 

428 ) 

429 rdates = rdates_property 

430 exdates = exdates_property 

431 rrules = rrules_property 

432 

433 

434class TimezoneDaylight(Component): 

435 """ 

436 The "DAYLIGHT" sub-component of "VTIMEZONE" defines the daylight 

437 saving time offset from UTC for a time zone. It represents a time 

438 zone's daylight saving time, typically used during summer months 

439 in locations that observe Daylight Saving Time. 

440 """ 

441 

442 name = "DAYLIGHT" 

443 required = TimezoneStandard.required 

444 singletons = TimezoneStandard.singletons 

445 multiple = TimezoneStandard.multiple 

446 

447 DTSTART = TimezoneStandard.DTSTART 

448 TZOFFSETTO = TimezoneStandard.TZOFFSETTO 

449 TZOFFSETFROM = TimezoneStandard.TZOFFSETFROM 

450 

451 rdates = rdates_property 

452 exdates = exdates_property 

453 rrules = rrules_property 

454 

455 

456__all__ = ["Timezone", "TimezoneDaylight", "TimezoneStandard"]