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

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

120 statements  

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

2 

3from datetime import date, datetime, timedelta, tzinfo 

4from typing import Any, ClassVar 

5 

6from icalendar.compatibility import Self 

7from icalendar.error import JCalParsingError 

8from icalendar.parser import Parameters 

9from icalendar.timezone import tzp 

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

11 

12from .base import TimeBase 

13from .datetime import vDatetime 

14from .duration import vDuration 

15 

16 

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

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

19 

20 Parameters: 

21 dt: The date to convert. 

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

23 

24 Returns: 

25 The datetime at midnight. 

26 """ 

27 midnight = to_datetime(dt) 

28 if tz is None: 

29 return midnight 

30 if is_pytz(tz): 

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

32 return midnight.replace(tzinfo=tz) 

33 

34 

35def _to_period_datetimes( 

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

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

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

39 

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

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

42 and written back out. 

43 

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

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

46 duration. 

47 

48 Parameters: 

49 start: The start of the period. 

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

51 

52 Returns: 

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

54 """ 

55 if is_date(start): 

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

57 if is_date(end_or_duration): 

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

59 return start, end_or_duration 

60 

61 

62class vPeriod(TimeBase): 

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

64 

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

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

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

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

69 

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

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

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

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

74 gets this wrong can still be read and written. 

75 

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

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

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

79 The halves themselves are parsed by 

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

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

82 

83 Parameters: 

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

85 params: The parameters of the property. 

86 

87 Raises: 

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

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

90 ValueError: If the start is after the end. 

91 

92 Examples: 

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

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

95 and 30 minutes: 

96 

97 .. code-block:: ics 

98 

99 19970101T180000Z/19970102T070000Z 

100 19970101T180000Z/PT5H30M 

101 

102 .. code-block:: pycon 

103 

104 >>> from icalendar.prop import vPeriod 

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

106 >>> vPeriod(period).to_ical() 

107 b'19970101T180000Z/19970102T070000Z' 

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

109 >>> vPeriod(period).duration 

110 datetime.timedelta(seconds=19800) 

111 

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

113 

114 .. code-block:: pycon 

115 

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

117 b'19970101T000000/19970102T000000' 

118 

119 .. versionchanged:: 7.2.3 

120 

121 A period written with dates is read as midnight. 

122 """ 

123 

124 default_value: ClassVar[str] = "PERIOD" 

125 params: Parameters 

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

127 by_duration: bool 

128 #: The start of the period. 

129 start: datetime 

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

131 end: datetime 

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

133 duration: timedelta 

134 

135 def __init__( 

136 self, 

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

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

139 ) -> None: 

140 start, end_or_duration = per 

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

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

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

144 raise TypeError( 

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

146 ) 

147 start, end_or_duration = _to_period_datetimes(start, end_or_duration) 

148 by_duration = isinstance(end_or_duration, timedelta) 

149 if by_duration: 

150 duration = end_or_duration 

151 end = normalize_pytz(start + duration) 

152 else: 

153 end = end_or_duration 

154 duration = normalize_pytz(end - start) 

155 if start > end: 

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

157 

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

159 # set the timezone identifier 

160 # does not support different timezones for start and end 

161 self.params.update_tzid_from(start) 

162 

163 self.start = start 

164 self.end = end 

165 self.by_duration = by_duration 

166 self.duration = duration 

167 

168 def overlaps(self, other): 

169 if self.start > other.start: 

170 return other.overlaps(self) 

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

172 

173 def to_ical(self): 

174 if self.by_duration: 

175 return ( 

176 vDatetime(self.start).to_ical() 

177 + b"/" 

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

179 ) 

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

181 

182 @staticmethod 

183 def from_ical(ical, timezone=None): 

184 from icalendar.prop.dt.types import vDDDTypes 

185 

186 try: 

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

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

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

190 except Exception as e: 

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

192 return _to_period_datetimes(start, end_or_duration) 

193 

194 def __repr__(self): 

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

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

197 

198 @property 

199 def dt(self): 

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

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

202 

203 @property 

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

205 """ 

206 Returns the period as a tuple of its start datetime 

207 and either its end datetime or duration. 

208 """ 

209 return self.dt 

210 

211 from icalendar.param import FBTYPE 

212 

213 @classmethod 

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

215 """Examples of vPeriod.""" 

216 return [ 

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

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

219 ] 

220 

221 from icalendar.param import VALUE 

222 

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

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

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

226 if self.by_duration: 

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

228 else: 

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

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

231 

232 @classmethod 

233 def parse_jcal_value( 

234 cls, jcal: str | list 

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

236 """Parse a jCal value. 

237 

238 Raises: 

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

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

241 """ 

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

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

244 jcal = jcal.split("/") 

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

246 raise JCalParsingError( 

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

248 ) 

249 with JCalParsingError.reraise_with_path_added(0): 

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

251 with JCalParsingError.reraise_with_path_added(1): 

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

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

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

255 else: 

256 try: 

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

258 except JCalParsingError as e: 

259 raise JCalParsingError( 

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

261 cls, 

262 value=jcal[1], 

263 ) from e 

264 return start, end_or_duration 

265 

266 @classmethod 

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

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

269 

270 Parameters: 

271 jcal_property: The jCal property to parse. 

272 

273 Raises: 

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

275 """ 

276 JCalParsingError.validate_property(jcal_property, cls) 

277 with JCalParsingError.reraise_with_path_added(3): 

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

279 params = Parameters.from_jcal_property(jcal_property) 

280 tzid = params.tzid 

281 

282 if tzid: 

283 start = tzp.localize(start, tzid) 

284 if is_datetime(end_or_duration): 

285 end_or_duration = tzp.localize(end_or_duration, tzid) 

286 

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

288 

289 

290__all__ = ["vPeriod"]