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

207 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 is_date, 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 if is_date(component["DTSTART"].dt): 

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

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

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

169 ) 

170 try: 

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

172 except UnicodeEncodeError: 

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

174 tzname = self._make_unique_tzname(tzname, tznames) 

175 except KeyError: 

176 # for whatever reason this is str/unicode 

177 tzname = ( 

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

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

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

181 ) 

182 tzname = self._make_unique_tzname(tzname, tznames) 

183 

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

185 component, tzname 

186 ) 

187 transitions.extend(component_transitions) 

188 

189 transitions.sort() 

190 transition_times = [ 

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

192 ] 

193 

194 # transition_info is a list with tuples in the format 

195 # (utcoffset, dstoffset, name) 

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

197 # = this_utcoffset - prev_standard_utcoffset, otherwise 

198 transition_info = [] 

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

200 dst_offset = False 

201 if not dst[name]: 

202 dst_offset = timedelta(seconds=0) 

203 else: 

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

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

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

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

208 break 

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

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

211 if not dst_offset: 

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

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

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

215 break 

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

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

218 # difference from TZOFFSETFROM. Handles Issue #321. 

219 if dst_offset is False: 

220 dst_offset = osto - osfrom 

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

222 return transition_times, transition_info 

223 

224 # binary search 

225 _from_tzinfo_skip_search = [ 

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

227 ] + [ 

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

229 timedelta(hours=4), 

230 timedelta(hours=1), 

231 # adding some minutes and seconds for faster search 

232 timedelta(minutes=20), 

233 timedelta(minutes=5), 

234 timedelta(minutes=1), 

235 timedelta(seconds=20), 

236 timedelta(seconds=5), 

237 timedelta(seconds=1), 

238 ] 

239 

240 @classmethod 

241 def from_tzinfo( 

242 cls, 

243 timezone: tzinfo, 

244 tzid: str | None = None, 

245 first_date: date = DEFAULT_FIRST_DATE, 

246 last_date: date = DEFAULT_LAST_DATE, 

247 ) -> Timezone: 

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

249 

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

251 The offsets are calculated from the tzinfo object. 

252 

253 Parameters: 

254 

255 :param tzinfo: the timezone object 

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

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

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

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

260 

261 .. note:: 

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

263 """ 

264 if tzid is None: 

265 tzid = tzid_from_tzinfo(timezone) 

266 if tzid is None: 

267 raise ValueError( 

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

269 ) 

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

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

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

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

274 first_datetime = timezone.localize(first_datetime) 

275 last_datetime = timezone.localize(last_datetime) 

276 else: 

277 first_datetime = first_datetime.replace(tzinfo=timezone) 

278 last_datetime = last_datetime.replace(tzinfo=timezone) 

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

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

281 defaultdict(list) 

282 ) 

283 start = first_datetime 

284 offset_to = None 

285 while start < last_datetime: 

286 offset_from = offset_to 

287 end = start 

288 offset_to = end.utcoffset() 

289 for add_offset in cls._from_tzinfo_skip_search: 

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

291 end = normalize(end + add_offset) 

292 try: 

293 while end.utcoffset() == offset_to: 

294 last_end = end 

295 end = normalize(end + add_offset) 

296 except OverflowError: 

297 # zoninfo does not go all the way 

298 break 

299 # retract if we overshoot 

300 end = last_end 

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

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

303 name = start.tzname() 

304 if name is None: 

305 name = str(offset_to) 

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

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

308 # if first_key in offsets: 

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

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

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

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

313 tz = cls() 

314 tz.add("TZID", tzid) 

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

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

317 first_start = min(starts) 

318 starts.remove(first_start) 

319 if first_start.date() == last_date: 

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

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

322 if offset_from is None: 

323 offset_from = offset_to 

324 subcomponent.TZOFFSETFROM = offset_from 

325 subcomponent.TZOFFSETTO = offset_to 

326 subcomponent.add("TZNAME", tzname) 

327 subcomponent.DTSTART = first_start 

328 if starts: 

329 subcomponent.add("RDATE", starts) 

330 tz.add_component(subcomponent) 

331 return tz 

332 

333 @classmethod 

334 def from_tzid( 

335 cls, 

336 tzid: str, 

337 tzp: TZP = tzp, 

338 first_date: date = DEFAULT_FIRST_DATE, 

339 last_date: date = DEFAULT_LAST_DATE, 

340 ) -> Timezone: 

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

342 

343 :param tzid: the id of the timezone 

344 :param tzp: the timezone provider 

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

346 that happens in the calendar 

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

348 that happens in the calendar 

349 :raises ValueError: If the tzid is unknown. 

350 

351 >>> from icalendar import Timezone 

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

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

354 BEGIN:VTIMEZONE 

355 TZID:Europe/Berlin 

356 

357 .. note:: 

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

359 """ 

360 tz = tzp.timezone(tzid) 

361 if tz is None: 

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

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

364 

365 @property 

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

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

368 return self.walk("STANDARD") 

369 

370 @property 

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

372 """The DAYLIGHT subcomponents as a list. 

373 

374 These are for the daylight saving time. 

375 """ 

376 return self.walk("DAYLIGHT") 

377 

378 

379class TimezoneStandard(Component): 

380 """ 

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

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

383 standard time, typically used during winter months in locations 

384 that observe Daylight Saving Time. 

385 """ 

386 

387 name = "STANDARD" 

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

389 singletons = ( 

390 "DTSTART", 

391 "TZOFFSETTO", 

392 "TZOFFSETFROM", 

393 ) 

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

395 

396 DTSTART = create_single_property( 

397 "DTSTART", 

398 "dt", 

399 (datetime,), 

400 datetime, 

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

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

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

404 time value.""", 

405 ) 

406 TZOFFSETTO = create_single_property( 

407 "TZOFFSETTO", 

408 "td", 

409 (timedelta,), 

410 timedelta, 

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

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

413 when this observance is in use. 

414 """, 

415 vUTCOffset, 

416 ) 

417 TZOFFSETFROM = create_single_property( 

418 "TZOFFSETFROM", 

419 "td", 

420 (timedelta,), 

421 timedelta, 

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

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

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

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

426 the following represents the time at which the observance of 

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

428 

429 DTSTART:19671029T020000 

430 TZOFFSETFROM:-0400 

431 """, 

432 vUTCOffset, 

433 ) 

434 rdates = rdates_property 

435 exdates = exdates_property 

436 rrules = rrules_property 

437 

438 

439class TimezoneDaylight(Component): 

440 """ 

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

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

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

444 in locations that observe Daylight Saving Time. 

445 """ 

446 

447 name = "DAYLIGHT" 

448 required = TimezoneStandard.required 

449 singletons = TimezoneStandard.singletons 

450 multiple = TimezoneStandard.multiple 

451 

452 DTSTART = TimezoneStandard.DTSTART 

453 TZOFFSETTO = TimezoneStandard.TZOFFSETTO 

454 TZOFFSETFROM = TimezoneStandard.TZOFFSETFROM 

455 

456 rdates = rdates_property 

457 exdates = exdates_property 

458 rrules = rrules_property 

459 

460 

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