Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.11/site-packages/icalendar/prop/dt/period.py: 58%

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

153 statements  

1"""PERIOD property type from :rfc:`5545`.""" 

2 

3from datetime import date, datetime, timedelta, tzinfo 

4from typing import Any, ClassVar 

5from xml.etree.ElementTree import Element, SubElement 

6 

7from icalendar.compatibility import Self 

8from icalendar.error import JCalParsingError, XCalParsingError 

9from icalendar.parser import Parameters 

10from icalendar.parser.xcal.base import XCalParser 

11from icalendar.parser.xcal.wrapper import from_xcal_wrapper 

12from icalendar.timezone import tzp 

13from icalendar.tools import is_date, is_datetime, is_pytz, normalize_pytz, to_datetime 

14 

15from .base import TimeBase 

16from .datetime import vDatetime 

17from .duration import vDuration 

18 

19 

20def _to_midnight(dt: date, tz: tzinfo | None) -> datetime: 

21 """Convert a date to the datetime at midnight. 

22 

23 Parameters: 

24 dt: The date to convert. 

25 tz: The timezone of the result, or ``None`` for a naive result. 

26 

27 Returns: 

28 The datetime at midnight. 

29 """ 

30 midnight = to_datetime(dt) 

31 if tz is None: 

32 return midnight 

33 if is_pytz(tz): 

34 return tz.localize(midnight) # type: ignore[attr-defined] 

35 return midnight.replace(tzinfo=tz) 

36 

37 

38def _to_period_datetimes( 

39 start: date | datetime, end_or_duration: date | datetime | timedelta 

40) -> tuple[datetime, datetime | timedelta]: 

41 """Convert the dates of a period to datetimes. 

42 

43 :rfc:`5545#section-3.3.9` builds a period from datetimes only, but calendars in the 

44 wild use dates. A date becomes midnight so that the period can be used 

45 and written back out. 

46 

47 A converted half takes the timezone of the other half. Without this, an 

48 aware and a naive datetime could not be subtracted to compute the 

49 duration. 

50 

51 Parameters: 

52 start: The start of the period. 

53 end_or_duration: The end of the period or its duration. 

54 

55 Returns: 

56 The start and the end or duration, with any date converted. 

57 """ 

58 if is_date(start): 

59 start = _to_midnight(start, getattr(end_or_duration, "tzinfo", None)) 

60 if is_date(end_or_duration): 

61 end_or_duration = _to_midnight(end_or_duration, getattr(start, "tzinfo", None)) 

62 return start, end_or_duration 

63 

64 

65class vPeriod(TimeBase): 

66 """A span of time, written either as a start and an end or as a start and a duration. 

67 

68 The value is a tuple of two :class:`datetime.datetime` objects, or of a 

69 datetime and a :class:`datetime.timedelta`. Whichever way it was written, 

70 :attr:`start`, :attr:`end`, and :attr:`duration` are all available, and 

71 :attr:`by_duration` says which of the two forms is written back out. 

72 

73 Conforming with :rfc:`5545#section-3.3.9`, a period is built from datetimes 

74 only, the start must come before the end, and a duration must be positive. 

75 A half written as a date does not conform. Such a half is read as midnight, 

76 in the timezone of the other half where it has one, so that a calendar that 

77 gets this wrong can still be read and written. 

78 

79 ``FREEBUSY`` holds periods directly. ``RDATE`` holds them through 

80 :class:`~icalendar.prop.dt.list.vDDDLists`, which splits a comma separated 

81 list and hands each value to :class:`~icalendar.prop.dt.types.vDDDTypes`. 

82 The halves themselves are parsed by 

83 :class:`~icalendar.prop.dt.datetime.vDatetime` and 

84 :class:`~icalendar.prop.dt.duration.vDuration`. 

85 

86 Parameters: 

87 per: The start of the period, and either its end or its duration. 

88 params: The parameters of the property. 

89 

90 Raises: 

91 TypeError: If the start is not a date or a datetime, or if the end is 

92 not a date, a datetime, or a duration. 

93 ValueError: If the start is after the end. 

94 

95 Examples: 

96 A period from 18:00:00 UTC on January 1, 1997 to 07:00:00 UTC on 

97 January 2, 1997, and one that starts at 18:00:00 UTC and lasts 5 hours 

98 and 30 minutes: 

99 

100 .. code-block:: ics 

101 

102 19970101T180000Z/19970102T070000Z 

103 19970101T180000Z/PT5H30M 

104 

105 .. code-block:: pycon 

106 

107 >>> from icalendar.prop import vPeriod 

108 >>> period = vPeriod.from_ical("19970101T180000Z/19970102T070000Z") 

109 >>> vPeriod(period).to_ical() 

110 b'19970101T180000Z/19970102T070000Z' 

111 >>> period = vPeriod.from_ical("19970101T180000Z/PT5H30M") 

112 >>> vPeriod(period).duration 

113 datetime.timedelta(seconds=19800) 

114 

115 A half written as a date is read as midnight: 

116 

117 .. code-block:: pycon 

118 

119 >>> vPeriod(vPeriod.from_ical("19970101/19970102")).to_ical() 

120 b'19970101T000000/19970102T000000' 

121 

122 .. versionchanged:: 7.3.0 

123 

124 A period written with dates is read as midnight. 

125 """ 

126 

127 default_value: ClassVar[str] = "PERIOD" 

128 params: Parameters 

129 #: Whether the value is written as a duration rather than as an end. 

130 by_duration: bool 

131 #: The start of the period. 

132 start: datetime 

133 #: The end of the period, computed from the duration where there is one. 

134 end: datetime 

135 #: The time between the start and the end. 

136 duration: timedelta 

137 

138 def __init__( 

139 self, 

140 per: tuple[date | datetime, date | datetime | timedelta], 

141 params: dict[str, Any] | None = None, 

142 ) -> None: 

143 start, end_or_duration = per 

144 if not (isinstance(start, (datetime, date))): 

145 raise TypeError("Start value MUST be a datetime or date instance") 

146 if not (isinstance(end_or_duration, (datetime, date, timedelta))): 

147 raise TypeError( 

148 "end_or_duration MUST be a datetime, date or timedelta instance" 

149 ) 

150 start, end_or_duration = _to_period_datetimes(start, end_or_duration) 

151 by_duration = isinstance(end_or_duration, timedelta) 

152 if by_duration: 

153 duration = end_or_duration 

154 end = normalize_pytz(start + duration) 

155 else: 

156 end = end_or_duration 

157 duration = normalize_pytz(end - start) 

158 if start > end: 

159 raise ValueError("Start time is greater than end time") 

160 

161 self.params = Parameters(params or {"value": "PERIOD"}) 

162 # set the timezone identifier 

163 # does not support different timezones for start and end 

164 self.params.update_tzid_from(start) 

165 

166 self.start = start 

167 self.end = end 

168 self.by_duration = by_duration 

169 self.duration = duration 

170 

171 def overlaps(self, other): 

172 if self.start > other.start: 

173 return other.overlaps(self) 

174 return self.start <= other.start < self.end 

175 

176 def to_ical(self): 

177 if self.by_duration: 

178 return ( 

179 vDatetime(self.start).to_ical() 

180 + b"/" 

181 + vDuration(self.duration).to_ical() 

182 ) 

183 return vDatetime(self.start).to_ical() + b"/" + vDatetime(self.end).to_ical() 

184 

185 @staticmethod 

186 def from_ical(ical, timezone=None): 

187 from icalendar.prop.dt.types import vDDDTypes 

188 

189 try: 

190 start, end_or_duration = ical.split("/") 

191 start = vDDDTypes.from_ical(start, timezone=timezone) 

192 end_or_duration = vDDDTypes.from_ical(end_or_duration, timezone=timezone) 

193 except Exception as e: 

194 raise ValueError(f"Expected period format, got: {ical}") from e 

195 return _to_period_datetimes(start, end_or_duration) 

196 

197 def __repr__(self): 

198 p = (self.start, self.duration) if self.by_duration else (self.start, self.end) 

199 return f"vPeriod({p!r})" 

200 

201 @property 

202 def dt(self): 

203 """Make this cooperate with the other vDDDTypes.""" 

204 return (self.start, (self.duration if self.by_duration else self.end)) 

205 

206 @property 

207 def ical_value(self) -> tuple[datetime, timedelta | datetime]: 

208 """ 

209 Returns the period as a tuple of its start datetime 

210 and either its end datetime or duration. 

211 """ 

212 return self.dt 

213 

214 from icalendar.param import FBTYPE 

215 

216 @classmethod 

217 def examples(cls) -> list[Self]: 

218 """Examples of vPeriod.""" 

219 return [ 

220 vPeriod((datetime(2025, 11, 10, 16, 35), timedelta(hours=1, minutes=30))), 

221 vPeriod((datetime(2025, 11, 10, 16, 35), datetime(2025, 11, 10, 18, 5))), 

222 ] 

223 

224 from icalendar.param import VALUE 

225 

226 def to_jcal(self, name: str) -> list: 

227 """The jCal representation of this property according to :rfc:`7265`.""" 

228 value = [vDatetime(self.start).to_jcal(name)[-1]] 

229 if self.by_duration: 

230 value.append(vDuration(self.duration).to_jcal(name)[-1]) 

231 else: 

232 value.append(vDatetime(self.end).to_jcal(name)[-1]) 

233 return [name, self.params.to_jcal(exclude_utc=True), self.VALUE.lower(), value] 

234 

235 @classmethod 

236 def parse_jcal_value( 

237 cls, jcal: str | list 

238 ) -> tuple[datetime, datetime] | tuple[datetime, timedelta]: 

239 """Parse a jCal value. 

240 

241 Raises: 

242 ~error.JCalParsingError: If the period is not a list with exactly two items, 

243 or it can't parse a date-time or duration. 

244 """ 

245 if isinstance(jcal, str) and "/" in jcal: 

246 # only occurs in the example of RFC7265, Section B.2.2. 

247 jcal = jcal.split("/") 

248 if not isinstance(jcal, list) or len(jcal) != 2: 

249 raise JCalParsingError( 

250 "A period must be a list with exactly 2 items.", cls, value=jcal 

251 ) 

252 with JCalParsingError.reraise_with_path_added(0): 

253 start = vDatetime.parse_jcal_value(jcal[0]) 

254 with JCalParsingError.reraise_with_path_added(1): 

255 JCalParsingError.validate_value_type(jcal[1], str, cls) 

256 if jcal[1].startswith(("P", "-P", "+P")): 

257 end_or_duration = vDuration.parse_jcal_value(jcal[1]) 

258 else: 

259 try: 

260 end_or_duration = vDatetime.parse_jcal_value(jcal[1]) 

261 except JCalParsingError as e: 

262 raise JCalParsingError( 

263 "Cannot parse date-time or duration.", 

264 cls, 

265 value=jcal[1], 

266 ) from e 

267 return start, end_or_duration 

268 

269 @classmethod 

270 def from_jcal(cls, jcal_property: list) -> Self: 

271 """Parse jCal from :rfc:`7265`. 

272 

273 Parameters: 

274 jcal_property: The jCal property to parse. 

275 

276 Raises: 

277 ~error.JCalParsingError: If the provided jCal is invalid. 

278 """ 

279 JCalParsingError.validate_property(jcal_property, cls) 

280 with JCalParsingError.reraise_with_path_added(3): 

281 start, end_or_duration = cls.parse_jcal_value(jcal_property[3]) 

282 params = Parameters.from_jcal_property(jcal_property) 

283 tzid = params.tzid 

284 

285 if tzid: 

286 start = tzp.localize(start, tzid) 

287 if is_datetime(end_or_duration): 

288 end_or_duration = tzp.localize(end_or_duration, tzid) 

289 

290 return cls((start, end_or_duration), params=params) 

291 

292 def to_xcal(self, element: Element) -> None: 

293 """The xCal representation of this property according to :rfc:`6321`.""" 

294 self.params.to_xcal(element) 

295 period_element = SubElement(element, self.default_value.lower()) 

296 vDatetime(self.start).to_xcal_value_only(period_element, "start") 

297 if self.by_duration: 

298 vDuration(self.duration).to_xcal(period_element) 

299 else: 

300 vDatetime(self.end).to_xcal_value_only(period_element, "end") 

301 

302 @classmethod 

303 @from_xcal_wrapper 

304 def from_xcal(cls, parser: XCalParser, params: Parameters) -> Self: 

305 """Parse xCal from :rfc:`6321`. 

306 

307 Parameters: 

308 xml: The XML to parse or a parser. 

309 

310 Raises: 

311 ~error.XCalParsingError: If the provided xCal is invalid. 

312 """ 

313 element = parser.parse_tag(cls.default_value) 

314 start_element = element.get_child_with_tag("start") 

315 if start_element is None: 

316 raise XCalParsingError("Expected <start>", None, element) 

317 start = vDatetime.from_xcal_element(start_element) 

318 tzid = params.tzid 

319 if tzid: 

320 start = tzp.localize(start, tzid) 

321 end_element = element.get_child_with_tag("end") 

322 if end_element: 

323 end = vDatetime.from_xcal_element(end_element) 

324 if tzid: 

325 end = tzp.localize(end, tzid) 

326 else: 

327 duration_element = element.get_child_with_tag("duration") 

328 if duration_element is None: 

329 raise XCalParsingError("Expected <end> or <duration>", None, element) 

330 end = vDuration.from_xcal(duration_element).td 

331 if end < timedelta(0): 

332 raise XCalParsingError( 

333 "Expected positive duration", 

334 duration_element.get_xsd_token(), 

335 duration_element, 

336 ) 

337 return cls((start, end), params=params) 

338 

339 

340__all__ = ["vPeriod"]