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

201 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 

7 

8import dateutil.rrule 

9import dateutil.tz 

10 

11from icalendar.attr import ( 

12 create_single_property, 

13 exdates_property, 

14 rdates_property, 

15 rrules_property, 

16) 

17from icalendar.cal.component import Component 

18from icalendar.cal.examples import get_example 

19from icalendar.prop import vUTCOffset 

20from icalendar.timezone import TZP, tzp 

21from icalendar.timezone.tzid import tzid_from_tzinfo 

22from icalendar.tools import to_datetime 

23 

24 

25class Timezone(Component): 

26 """ 

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

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

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

30 

31 Examples: 

32 Get the example Timezone. 

33 

34 .. code-block:: pycon 

35 

36 >>> from icalendar import Timezone 

37 >>> timezone = Timezone.example() 

38 >>> print(timezone["TZID"]) 

39 custom_Pacific/Fiji 

40 >>> len(timezone.subcomponents) 

41 5 

42 

43 """ 

44 

45 subcomponents: list[TimezoneStandard | TimezoneDaylight] 

46 

47 name = "VTIMEZONE" 

48 canonical_order = ("TZID",) 

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

50 singletons = ( 

51 "TZID", 

52 "LAST-MODIFIED", 

53 "TZURL", 

54 ) 

55 

56 DEFAULT_FIRST_DATE = date(1970, 1, 1) 

57 DEFAULT_LAST_DATE = date(2038, 1, 1) 

58 

59 @classmethod 

60 def example(cls, name: str = "pacific_fiji") -> Timezone: 

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

62 

63 Example: 

64 

65 .. code-block:: pycon 

66 

67 >>> from icalendar import Timezone 

68 >>> timezone = Timezone.example() 

69 >>> print(timezone["TZID"]) 

70 custom_Pacific/Fiji 

71 """ 

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

73 

74 @staticmethod 

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

76 """Extract offsets and transition times from a VTIMEZONE component. 

77 

78 Args: 

79 component: A STANDARD or DAYLIGHT timezone component. 

80 tzname: The name of the timezone. 

81 

82 Returns: 

83 A tuple containing whether the component represents daylight saving 

84 time and its transition information. 

85 """ 

86 offsetfrom = component.TZOFFSETFROM 

87 offsetto = component.TZOFFSETTO 

88 dtstart = component.DTSTART 

89 

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

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

92 # supposedly cannot handle smaller offsets) 

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

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

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

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

97 

98 # expand recurrences 

99 if "RRULE" in component: 

100 # to be paranoid about correct weekdays 

101 # evaluate the rrule with the current offset 

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

103 rrstart = dtstart.replace(tzinfo=tzi) 

104 

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

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

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

108 

109 # constructing the timezone requires UTC transition times. 

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

111 # gets subtracted in to_tz(). 

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

113 

114 # or rdates 

115 elif "RDATE" in component: 

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

117 rdates = [component["RDATE"]] 

118 else: 

119 rdates = component["RDATE"] 

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

121 else: 

122 transtimes = [dtstart] 

123 

124 transitions = [ 

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

126 ] 

127 

128 if component.name == "STANDARD": 

129 is_dst = 0 

130 elif component.name == "DAYLIGHT": 

131 is_dst = 1 

132 return is_dst, transitions 

133 

134 @staticmethod 

135 def _make_unique_tzname(tzname, tznames): 

136 """ 

137 :param tzname: Candidate tzname 

138 :param tznames: Other tznames 

139 """ 

140 # TODO better way of making sure tznames are unique 

141 while tzname in tznames: 

142 tzname += "_1" 

143 tznames.add(tzname) 

144 return tzname 

145 

146 def to_tz(self, tzp: TZP = tzp, lookup_tzid: bool = True): 

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

148 

149 :param tzp: timezone provider to use 

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

151 timezone definitions with tzp. 

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

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

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

155 """ 

156 if lookup_tzid: 

157 tz = tzp.timezone(self.tz_name) 

158 if tz is not None: 

159 return tz 

160 return tzp.create_timezone(self) 

161 

162 @property 

163 def tz_name(self) -> str: 

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

165 

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

167 and may change with winter/summer time. 

168 """ 

169 try: 

170 return str(self["TZID"]) 

171 except UnicodeEncodeError: 

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

173 

174 def get_transitions( 

175 self, 

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

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

178 

179 - transition_times = [datetime, ...] 

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

181 

182 """ 

183 zone = self.tz_name 

184 transitions = [] 

185 dst = {} 

186 tznames = set() 

187 for component in self.walk(): 

188 if isinstance(component, Timezone): 

189 continue 

190 try: 

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

192 except UnicodeEncodeError: 

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

194 tzname = self._make_unique_tzname(tzname, tznames) 

195 except KeyError: 

196 # for whatever reason this is str/unicode 

197 tzname = ( 

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

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

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

201 ) 

202 tzname = self._make_unique_tzname(tzname, tznames) 

203 

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

205 component, tzname 

206 ) 

207 transitions.extend(component_transitions) 

208 

209 transitions.sort() 

210 transition_times = [ 

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

212 ] 

213 

214 # transition_info is a list with tuples in the format 

215 # (utcoffset, dstoffset, name) 

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

217 # = this_utcoffset - prev_standard_utcoffset, otherwise 

218 transition_info = [] 

219 for num, (_transtime, osfrom, osto, name) in enumerate(transitions): 

220 dst_offset = False 

221 if not dst[name]: 

222 dst_offset = timedelta(seconds=0) 

223 else: 

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

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

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

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

228 break 

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

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

231 if not dst_offset: 

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

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

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

235 break 

236 # If we still haven't found a STANDARD transition 

237 # (only DAYLIGHT exists), calculate dst_offset as the 

238 # difference from TZOFFSETFROM. Handles Issue #321. 

239 if dst_offset is False: 

240 dst_offset = osto - osfrom 

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

242 return transition_times, transition_info 

243 

244 # binary search 

245 _from_tzinfo_skip_search = [ 

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

247 ] + [ 

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

249 timedelta(hours=4), 

250 timedelta(hours=1), 

251 # adding some minutes and seconds for faster search 

252 timedelta(minutes=20), 

253 timedelta(minutes=5), 

254 timedelta(minutes=1), 

255 timedelta(seconds=20), 

256 timedelta(seconds=5), 

257 timedelta(seconds=1), 

258 ] 

259 

260 @classmethod 

261 def from_tzinfo( 

262 cls, 

263 timezone: tzinfo, 

264 tzid: str | None = None, 

265 first_date: date = DEFAULT_FIRST_DATE, 

266 last_date: date = DEFAULT_LAST_DATE, 

267 ) -> Timezone: 

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

269 

270 Calculate offsets from a pytz, zoneinfo, or other timezone object. 

271 

272 Parameters: 

273 timezone: Required. The timezone object used to calculate transitions. 

274 first_date: The starting date for the transition search. 

275 last_date: The ending date for the transition search. 

276 tzid: The timezone identifier. If None, derive it from 

277 ``timezone``. 

278 

279 Returns: 

280 :class:`Timezone`: The ``VTIMEZONE`` component for ``timezone``. 

281 

282 Raises: 

283 ValueError: If ``tzid`` is None and cannot be derived from 

284 ``timezone``. 

285 

286 .. note:: 

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

288 """ 

289 if tzid is None: 

290 tzid = tzid_from_tzinfo(timezone) 

291 if tzid is None: 

292 raise ValueError( 

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

294 ) 

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

296 first_datetime = datetime(first_date.year, first_date.month, first_date.day) 

297 last_datetime = datetime(last_date.year, last_date.month, last_date.day) 

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

299 first_datetime = timezone.localize(first_datetime) 

300 last_datetime = timezone.localize(last_datetime) 

301 else: 

302 first_datetime = first_datetime.replace(tzinfo=timezone) 

303 last_datetime = last_datetime.replace(tzinfo=timezone) 

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

305 offsets: dict[tuple[timedelta | None, timedelta, str, bool], list[datetime]] = ( 

306 defaultdict(list) 

307 ) 

308 start = first_datetime 

309 offset_to = None 

310 while start < last_datetime: 

311 offset_from = offset_to 

312 end = start 

313 offset_to = end.utcoffset() 

314 for add_offset in cls._from_tzinfo_skip_search: 

315 last_end = end # we need to save this as we might be left and right of the time change 

316 end = normalize(end + add_offset) 

317 try: 

318 while end.utcoffset() == offset_to: 

319 last_end = end 

320 end = normalize(end + add_offset) 

321 except OverflowError: 

322 # zoninfo does not go all the way 

323 break 

324 # retract if we overshoot 

325 end = last_end 

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

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

328 name = start.tzname() 

329 if name is None: 

330 name = str(offset_to) 

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

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

333 # if first_key in offsets: 

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

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

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

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

338 tz = cls() 

339 tz.add("TZID", tzid) 

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

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

342 first_start = min(starts) 

343 starts.remove(first_start) 

344 if first_start.date() == last_date: 

345 first_start = datetime(last_date.year, last_date.month, last_date.day) 

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

347 if offset_from is None: 

348 offset_from = offset_to 

349 subcomponent.TZOFFSETFROM = offset_from 

350 subcomponent.TZOFFSETTO = offset_to 

351 subcomponent.add("TZNAME", tzname) 

352 subcomponent.DTSTART = first_start 

353 if starts: 

354 subcomponent.add("RDATE", starts) 

355 tz.add_component(subcomponent) 

356 return tz 

357 

358 @classmethod 

359 def from_tzid( 

360 cls, 

361 tzid: str, 

362 tzp: TZP = tzp, 

363 first_date: date = DEFAULT_FIRST_DATE, 

364 last_date: date = DEFAULT_LAST_DATE, 

365 ) -> Timezone: 

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

367 

368 :param tzid: the id of the timezone 

369 :param tzp: the timezone provider 

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

371 that happens in the calendar 

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

373 that happens in the calendar 

374 :raises ValueError: If the tzid is unknown. 

375 

376 >>> from icalendar import Timezone 

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

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

379 BEGIN:VTIMEZONE 

380 TZID:Europe/Berlin 

381 

382 .. note:: 

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

384 """ 

385 tz = tzp.timezone(tzid) 

386 if tz is None: 

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

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

389 

390 @property 

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

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

393 return self.walk("STANDARD") 

394 

395 @property 

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

397 """The DAYLIGHT subcomponents as a list. 

398 

399 These are for the daylight saving time. 

400 """ 

401 return self.walk("DAYLIGHT") 

402 

403 

404class TimezoneStandard(Component): 

405 """ 

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

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

408 standard time, typically used during winter months in locations 

409 that observe Daylight Saving Time. 

410 """ 

411 

412 name = "STANDARD" 

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

414 singletons = ( 

415 "DTSTART", 

416 "TZOFFSETTO", 

417 "TZOFFSETFROM", 

418 ) 

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

420 

421 DTSTART = create_single_property( 

422 "DTSTART", 

423 "dt", 

424 (datetime,), 

425 datetime, 

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

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

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

429 time value.""", 

430 convert=to_datetime, 

431 ) 

432 

433 TZOFFSETTO = create_single_property( 

434 "TZOFFSETTO", 

435 "td", 

436 (timedelta,), 

437 timedelta, 

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

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

440 when this observance is in use. 

441 """, 

442 vUTCOffset, 

443 ) 

444 TZOFFSETFROM = create_single_property( 

445 "TZOFFSETFROM", 

446 "td", 

447 (timedelta,), 

448 timedelta, 

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

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

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

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

453 the following represents the time at which the observance of 

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

455 

456 DTSTART:19671029T020000 

457 TZOFFSETFROM:-0400 

458 """, 

459 vUTCOffset, 

460 ) 

461 rdates = rdates_property 

462 exdates = exdates_property 

463 rrules = rrules_property 

464 

465 

466class TimezoneDaylight(Component): 

467 """ 

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

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

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

471 in locations that observe Daylight Saving Time. 

472 """ 

473 

474 name = "DAYLIGHT" 

475 required = TimezoneStandard.required 

476 singletons = TimezoneStandard.singletons 

477 multiple = TimezoneStandard.multiple 

478 

479 DTSTART = TimezoneStandard.DTSTART 

480 TZOFFSETTO = TimezoneStandard.TZOFFSETTO 

481 TZOFFSETFROM = TimezoneStandard.TZOFFSETFROM 

482 

483 rdates = rdates_property 

484 exdates = exdates_property 

485 rrules = rrules_property 

486 

487 

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