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

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

204 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 

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 vUTCOffset 

21from icalendar.timezone import TZP, tzp 

22from icalendar.timezone.tzid import tzid_from_tzinfo 

23from icalendar.tools import to_datetime 

24 

25if TYPE_CHECKING: 

26 from icalendar.cal.calendar import Calendar 

27 

28 

29class Timezone(Component): 

30 """ 

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

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

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

34 """ 

35 

36 subcomponents: list[TimezoneStandard | TimezoneDaylight] 

37 

38 name = "VTIMEZONE" 

39 canonical_order = ("TZID",) 

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

41 singletons = ( 

42 "TZID", 

43 "LAST-MODIFIED", 

44 "TZURL", 

45 ) 

46 

47 DEFAULT_FIRST_DATE = date(1970, 1, 1) 

48 DEFAULT_LAST_DATE = date(2038, 1, 1) 

49 

50 @classmethod 

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

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

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

54 

55 @staticmethod 

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

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

58 :param component: a STANDARD or DAYLIGHT component 

59 :param tzname: the name of the zone 

60 """ 

61 offsetfrom = component.TZOFFSETFROM 

62 offsetto = component.TZOFFSETTO 

63 dtstart = component.DTSTART 

64 

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

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

67 # supposedly cannot handle smaller offsets) 

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

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

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

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

72 

73 # expand recurrences 

74 if "RRULE" in component: 

75 # to be paranoid about correct weekdays 

76 # evaluate the rrule with the current offset 

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

78 rrstart = dtstart.replace(tzinfo=tzi) 

79 

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

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

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

83 

84 # constructing the timezone requires UTC transition times. 

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

86 # gets subtracted in to_tz(). 

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

88 

89 # or rdates 

90 elif "RDATE" in component: 

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

92 rdates = [component["RDATE"]] 

93 else: 

94 rdates = component["RDATE"] 

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

96 else: 

97 transtimes = [dtstart] 

98 

99 transitions = [ 

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

101 ] 

102 

103 if component.name == "STANDARD": 

104 is_dst = 0 

105 elif component.name == "DAYLIGHT": 

106 is_dst = 1 

107 return is_dst, transitions 

108 

109 @staticmethod 

110 def _make_unique_tzname(tzname, tznames): 

111 """ 

112 :param tzname: Candidate tzname 

113 :param tznames: Other tznames 

114 """ 

115 # TODO better way of making sure tznames are unique 

116 while tzname in tznames: 

117 tzname += "_1" 

118 tznames.add(tzname) 

119 return tzname 

120 

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

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

123 

124 :param tzp: timezone provider to use 

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

126 timezone definitions with tzp. 

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

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

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

130 """ 

131 if lookup_tzid: 

132 tz = tzp.timezone(self.tz_name) 

133 if tz is not None: 

134 return tz 

135 return tzp.create_timezone(self) 

136 

137 @property 

138 def tz_name(self) -> str: 

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

140 

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

142 and may change with winter/summer time. 

143 """ 

144 try: 

145 return str(self["TZID"]) 

146 except UnicodeEncodeError: 

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

148 

149 def get_transitions( 

150 self, 

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

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

153 

154 - transition_times = [datetime, ...] 

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

156 

157 """ 

158 zone = self.tz_name 

159 transitions = [] 

160 dst = {} 

161 tznames = set() 

162 for component in self.walk(): 

163 if isinstance(component, Timezone): 

164 continue 

165 try: 

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

167 except UnicodeEncodeError: 

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

169 tzname = self._make_unique_tzname(tzname, tznames) 

170 except KeyError: 

171 # for whatever reason this is str/unicode 

172 tzname = ( 

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

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

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

176 ) 

177 tzname = self._make_unique_tzname(tzname, tznames) 

178 

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

180 component, tzname 

181 ) 

182 transitions.extend(component_transitions) 

183 

184 transitions.sort() 

185 transition_times = [ 

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

187 ] 

188 

189 # transition_info is a list with tuples in the format 

190 # (utcoffset, dstoffset, name) 

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

192 # = this_utcoffset - prev_standard_utcoffset, otherwise 

193 transition_info = [] 

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

195 dst_offset = False 

196 if not dst[name]: 

197 dst_offset = timedelta(seconds=0) 

198 else: 

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

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

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

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

203 break 

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

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

206 if not dst_offset: 

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

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

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

210 break 

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

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

213 # difference from TZOFFSETFROM. Handles Issue #321. 

214 if dst_offset is False: 

215 dst_offset = osto - osfrom 

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: str | None = 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 """ 

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) 

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

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[tuple[timedelta | None, timedelta, str, bool], list[datetime]] = ( 

276 defaultdict(list) 

277 ) 

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 

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) 

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

317 if offset_from is None: 

318 offset_from = offset_to 

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 convert=to_datetime, 

401 ) 

402 

403 TZOFFSETTO = create_single_property( 

404 "TZOFFSETTO", 

405 "td", 

406 (timedelta,), 

407 timedelta, 

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

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

410 when this observance is in use. 

411 """, 

412 vUTCOffset, 

413 ) 

414 TZOFFSETFROM = create_single_property( 

415 "TZOFFSETFROM", 

416 "td", 

417 (timedelta,), 

418 timedelta, 

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

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

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

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

423 the following represents the time at which the observance of 

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

425 

426 DTSTART:19671029T020000 

427 TZOFFSETFROM:-0400 

428 """, 

429 vUTCOffset, 

430 ) 

431 rdates = rdates_property 

432 exdates = exdates_property 

433 rrules = rrules_property 

434 

435 

436class TimezoneDaylight(Component): 

437 """ 

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

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

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

441 in locations that observe Daylight Saving Time. 

442 """ 

443 

444 name = "DAYLIGHT" 

445 required = TimezoneStandard.required 

446 singletons = TimezoneStandard.singletons 

447 multiple = TimezoneStandard.multiple 

448 

449 DTSTART = TimezoneStandard.DTSTART 

450 TZOFFSETTO = TimezoneStandard.TZOFFSETTO 

451 TZOFFSETFROM = TimezoneStandard.TZOFFSETFROM 

452 

453 rdates = rdates_property 

454 exdates = exdates_property 

455 rrules = rrules_property 

456 

457 

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