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

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

157 statements  

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

2 

3from __future__ import annotations 

4 

5from datetime import date, datetime, timedelta, tzinfo 

6from typing import TYPE_CHECKING, Any, ClassVar 

7from xml.etree.ElementTree import Element, SubElement 

8 

9from icalendar.compatibility import Self 

10from icalendar.error import JCalParsingError, XCalParsingError 

11from icalendar.parser import Parameters 

12from icalendar.parser.xcal.base import XCalParser 

13from icalendar.parser.xcal.wrapper import from_xcal_wrapper 

14from icalendar.timezone import tzp 

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

16 

17from .base import TimeBase 

18from .datetime import vDatetime 

19from .duration import vDuration 

20 

21if TYPE_CHECKING: 

22 from icalendar.compatibility import Self 

23 from icalendar.parser.xcal.base import XCalParser 

24 

25 

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

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

28 

29 Parameters: 

30 dt: The date to convert. 

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

32 

33 Returns: 

34 The datetime at midnight. 

35 """ 

36 midnight = to_datetime(dt) 

37 if tz is None: 

38 return midnight 

39 if is_pytz(tz): 

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

41 return midnight.replace(tzinfo=tz) 

42 

43 

44def _to_period_datetimes( 

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

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

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

48 

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

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

51 and written back out. 

52 

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

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

55 duration. 

56 

57 Parameters: 

58 start: The start of the period. 

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

60 

61 Returns: 

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

63 """ 

64 if is_date(start): 

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

66 if is_date(end_or_duration): 

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

68 return start, end_or_duration 

69 

70 

71class vPeriod(TimeBase): 

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

73 

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

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

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

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

78 

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

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

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

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

83 gets this wrong can still be read and written. 

84 

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

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

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

88 The halves themselves are parsed by 

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

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

91 

92 Parameters: 

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

94 params: The parameters of the property. 

95 

96 Raises: 

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

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

99 ValueError: If the start is after the end. 

100 

101 Examples: 

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

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

104 and 30 minutes: 

105 

106 .. code-block:: ics 

107 

108 19970101T180000Z/19970102T070000Z 

109 19970101T180000Z/PT5H30M 

110 

111 .. code-block:: pycon 

112 

113 >>> from icalendar.prop import vPeriod 

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

115 >>> vPeriod(period).to_ical() 

116 b'19970101T180000Z/19970102T070000Z' 

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

118 >>> vPeriod(period).duration 

119 datetime.timedelta(seconds=19800) 

120 

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

122 

123 .. code-block:: pycon 

124 

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

126 b'19970101T000000/19970102T000000' 

127 

128 .. versionchanged:: 7.3.0 

129 

130 A period written with dates is read as midnight. 

131 """ 

132 

133 default_value: ClassVar[str] = "PERIOD" 

134 params: Parameters 

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

136 by_duration: bool 

137 #: The start of the period. 

138 start: datetime 

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

140 end: datetime 

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

142 duration: timedelta 

143 

144 def __init__( 

145 self, 

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

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

148 ) -> None: 

149 start, end_or_duration = per 

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

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

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

153 raise TypeError( 

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

155 ) 

156 start, end_or_duration = _to_period_datetimes(start, end_or_duration) 

157 by_duration = isinstance(end_or_duration, timedelta) 

158 if by_duration: 

159 duration = end_or_duration 

160 end = normalize_pytz(start + duration) 

161 else: 

162 end = end_or_duration 

163 duration = normalize_pytz(end - start) 

164 if start > end: 

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

166 

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

168 # set the timezone identifier 

169 # does not support different timezones for start and end 

170 self.params.update_tzid_from(start) 

171 

172 self.start = start 

173 self.end = end 

174 self.by_duration = by_duration 

175 self.duration = duration 

176 

177 def overlaps(self, other: vPeriod) -> bool: 

178 if self.start > other.start: 

179 return other.overlaps(self) 

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

181 

182 def to_ical(self) -> bytes: 

183 if self.by_duration: 

184 return ( 

185 vDatetime(self.start).to_ical() 

186 + b"/" 

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

188 ) 

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

190 

191 @staticmethod 

192 def from_ical(ical, timezone=None): 

193 from icalendar.prop.dt.types import vDDDTypes 

194 

195 try: 

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

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

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

199 except Exception as e: 

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

201 return _to_period_datetimes(start, end_or_duration) 

202 

203 def __repr__(self) -> str: 

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

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

206 

207 @property 

208 def dt(self): 

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

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

211 

212 @property 

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

214 """ 

215 Returns the period as a tuple of its start datetime 

216 and either its end datetime or duration. 

217 """ 

218 return self.dt 

219 

220 from icalendar.param import FBTYPE 

221 

222 @classmethod 

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

224 """Examples of vPeriod.""" 

225 return [ 

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

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

228 ] 

229 

230 from icalendar.param import VALUE 

231 

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

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

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

235 if self.by_duration: 

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

237 else: 

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

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

240 

241 @classmethod 

242 def parse_jcal_value( 

243 cls, jcal: str | list 

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

245 """Parse a jCal value. 

246 

247 Raises: 

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

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

250 """ 

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

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

253 jcal = jcal.split("/") 

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

255 raise JCalParsingError( 

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

257 ) 

258 with JCalParsingError.reraise_with_path_added(0): 

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

260 with JCalParsingError.reraise_with_path_added(1): 

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

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

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

264 else: 

265 try: 

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

267 except JCalParsingError as e: 

268 raise JCalParsingError( 

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

270 cls, 

271 value=jcal[1], 

272 ) from e 

273 return start, end_or_duration 

274 

275 @classmethod 

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

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

278 

279 Parameters: 

280 jcal_property: The jCal property to parse. 

281 

282 Raises: 

283 ~icalendar.error.JCalParsingError: If the provided jCal is invalid. 

284 """ 

285 JCalParsingError.validate_property(jcal_property, cls) 

286 with JCalParsingError.reraise_with_path_added(3): 

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

288 params = Parameters.from_jcal_property(jcal_property) 

289 tzid = params.tzid 

290 

291 if tzid: 

292 start = tzp.localize(start, tzid) 

293 if is_datetime(end_or_duration): 

294 end_or_duration = tzp.localize(end_or_duration, tzid) 

295 

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

297 

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

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

300 self.params.to_xcal(element) 

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

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

303 if self.by_duration: 

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

305 else: 

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

307 

308 @classmethod 

309 @from_xcal_wrapper 

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

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

312 

313 Parameters: 

314 xml: The XML to parse or a parser. 

315 

316 Raises: 

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

318 """ 

319 element = parser.parse_tag(cls.default_value) 

320 start_element = element.get_child_with_tag("start") 

321 if start_element is None: 

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

323 start = vDatetime.from_xcal_element(start_element) 

324 tzid = params.tzid 

325 if tzid: 

326 start = tzp.localize(start, tzid) 

327 end_element = element.get_child_with_tag("end") 

328 if end_element: 

329 end = vDatetime.from_xcal_element(end_element) 

330 if tzid: 

331 end = tzp.localize(end, tzid) 

332 else: 

333 duration_element = element.get_child_with_tag("duration") 

334 if duration_element is None: 

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

336 end = vDuration.from_xcal(duration_element).td 

337 if end < timedelta(0): 

338 raise XCalParsingError( 

339 "Expected positive duration", 

340 duration_element.get_xsd_token(), 

341 duration_element, 

342 ) 

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

344 

345 

346__all__ = ["vPeriod"]