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

206 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 # If we still haven't found a STANDARD transition 

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

217 # difference from TZOFFSETFROM. Handles Issue #321. 

218 if dst_offset is False: 

219 dst_offset = osto - osfrom 

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

221 return transition_times, transition_info 

222 

223 # binary search 

224 _from_tzinfo_skip_search = [ 

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

226 ] + [ 

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

228 timedelta(hours=4), 

229 timedelta(hours=1), 

230 # adding some minutes and seconds for faster search 

231 timedelta(minutes=20), 

232 timedelta(minutes=5), 

233 timedelta(minutes=1), 

234 timedelta(seconds=20), 

235 timedelta(seconds=5), 

236 timedelta(seconds=1), 

237 ] 

238 

239 @classmethod 

240 def from_tzinfo( 

241 cls, 

242 timezone: tzinfo, 

243 tzid: Optional[str] = None, 

244 first_date: date = DEFAULT_FIRST_DATE, 

245 last_date: date = DEFAULT_LAST_DATE, 

246 ) -> Timezone: 

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

248 

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

250 The offsets are calculated from the tzinfo object. 

251 

252 Parameters: 

253 

254 :param tzinfo: the timezone object 

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

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

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

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

259 

260 .. note:: 

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

262 """ # noqa: E501 

263 if tzid is None: 

264 tzid = tzid_from_tzinfo(timezone) 

265 if tzid is None: 

266 raise ValueError( 

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

268 ) 

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

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

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

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

273 first_datetime = timezone.localize(first_datetime) 

274 last_datetime = timezone.localize(last_datetime) 

275 else: 

276 first_datetime = first_datetime.replace(tzinfo=timezone) 

277 last_datetime = last_datetime.replace(tzinfo=timezone) 

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

279 offsets: dict[ 

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

281 ] = defaultdict(list) 

282 start = first_datetime 

283 offset_to = None 

284 while start < last_datetime: 

285 offset_from = offset_to 

286 end = start 

287 offset_to = end.utcoffset() 

288 for add_offset in cls._from_tzinfo_skip_search: 

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

290 end = normalize(end + add_offset) 

291 try: 

292 while end.utcoffset() == offset_to: 

293 last_end = end 

294 end = normalize(end + add_offset) 

295 except OverflowError: 

296 # zoninfo does not go all the way 

297 break 

298 # retract if we overshoot 

299 end = last_end 

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

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

302 name = start.tzname() 

303 if name is None: 

304 name = str(offset_to) 

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

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

307 # if first_key in offsets: 

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

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

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

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

312 tz = cls() 

313 tz.add("TZID", tzid) 

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

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

316 first_start = min(starts) 

317 starts.remove(first_start) 

318 if first_start.date() == last_date: 

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

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

321 if offset_from is None: 

322 offset_from = offset_to # noqa: PLW2901 

323 subcomponent.TZOFFSETFROM = offset_from 

324 subcomponent.TZOFFSETTO = offset_to 

325 subcomponent.add("TZNAME", tzname) 

326 subcomponent.DTSTART = first_start 

327 if starts: 

328 subcomponent.add("RDATE", starts) 

329 tz.add_component(subcomponent) 

330 return tz 

331 

332 @classmethod 

333 def from_tzid( 

334 cls, 

335 tzid: str, 

336 tzp: TZP = tzp, 

337 first_date: date = DEFAULT_FIRST_DATE, 

338 last_date: date = DEFAULT_LAST_DATE, 

339 ) -> Timezone: 

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

341 

342 :param tzid: the id of the timezone 

343 :param tzp: the timezone provider 

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

345 that happens in the calendar 

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

347 that happens in the calendar 

348 :raises ValueError: If the tzid is unknown. 

349 

350 >>> from icalendar import Timezone 

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

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

353 BEGIN:VTIMEZONE 

354 TZID:Europe/Berlin 

355 

356 .. note:: 

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

358 """ 

359 tz = tzp.timezone(tzid) 

360 if tz is None: 

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

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

363 

364 @property 

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

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

367 return self.walk("STANDARD") 

368 

369 @property 

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

371 """The DAYLIGHT subcomponents as a list. 

372 

373 These are for the daylight saving time. 

374 """ 

375 return self.walk("DAYLIGHT") 

376 

377 

378class TimezoneStandard(Component): 

379 """ 

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

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

382 standard time, typically used during winter months in locations 

383 that observe Daylight Saving Time. 

384 """ 

385 

386 name = "STANDARD" 

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

388 singletons = ( 

389 "DTSTART", 

390 "TZOFFSETTO", 

391 "TZOFFSETFROM", 

392 ) 

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

394 

395 DTSTART = create_single_property( 

396 "DTSTART", 

397 "dt", 

398 (datetime,), 

399 datetime, 

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

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

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

403 time value.""", 

404 ) 

405 TZOFFSETTO = create_single_property( 

406 "TZOFFSETTO", 

407 "td", 

408 (timedelta,), 

409 timedelta, 

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

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

412 when this observance is in use. 

413 """, 

414 vUTCOffset, 

415 ) 

416 TZOFFSETFROM = create_single_property( 

417 "TZOFFSETFROM", 

418 "td", 

419 (timedelta,), 

420 timedelta, 

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

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

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

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

425 the following represents the time at which the observance of 

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

427 

428 DTSTART:19671029T020000 

429 TZOFFSETFROM:-0400 

430 """, 

431 vUTCOffset, 

432 ) 

433 rdates = rdates_property 

434 exdates = exdates_property 

435 rrules = rrules_property 

436 

437 

438class TimezoneDaylight(Component): 

439 """ 

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

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

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

443 in locations that observe Daylight Saving Time. 

444 """ 

445 

446 name = "DAYLIGHT" 

447 required = TimezoneStandard.required 

448 singletons = TimezoneStandard.singletons 

449 multiple = TimezoneStandard.multiple 

450 

451 DTSTART = TimezoneStandard.DTSTART 

452 TZOFFSETTO = TimezoneStandard.TZOFFSETTO 

453 TZOFFSETFROM = TimezoneStandard.TZOFFSETFROM 

454 

455 rdates = rdates_property 

456 exdates = exdates_property 

457 rrules = rrules_property 

458 

459 

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