Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.11/site-packages/icalendar/cal/todo.py: 41%

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

145 statements  

1""":rfc:`5545` VTODO component.""" 

2 

3from __future__ import annotations 

4 

5import uuid 

6from datetime import date, datetime, timedelta 

7from typing import TYPE_CHECKING, Literal, Sequence 

8 

9from icalendar.attr import ( 

10 X_MOZ_LASTACK_property, 

11 X_MOZ_SNOOZE_TIME_property, 

12 attendees_property, 

13 categories_property, 

14 class_property, 

15 color_property, 

16 contacts_property, 

17 create_single_property, 

18 description_property, 

19 exdates_property, 

20 location_property, 

21 organizer_property, 

22 priority_property, 

23 property_del_duration, 

24 property_doc_duration_template, 

25 property_get_duration, 

26 property_set_duration, 

27 rdates_property, 

28 rrules_property, 

29 sequence_property, 

30 status_property, 

31 summary_property, 

32 uid_property, 

33 url_property, 

34) 

35from icalendar.cal.component import Component 

36from icalendar.error import IncompleteComponent, InvalidCalendar 

37from icalendar.tools import is_date 

38 

39if TYPE_CHECKING: 

40 from icalendar.alarms import Alarms 

41 from icalendar.enums import CLASS, STATUS 

42 from icalendar.prop import vCalAddress 

43 

44 

45class Todo(Component): 

46 """ 

47 A "VTODO" calendar component is a grouping of component 

48 properties that represents an action item or assignment. For 

49 example, it can be used to represent an item of work assigned to 

50 an individual, such as "Prepare for the upcoming conference 

51 seminar on Internet Calendaring". 

52 

53 Examples: 

54 Create a new Todo: 

55 

56 >>> from icalendar import Todo 

57 >>> todo = Todo.new() 

58 >>> print(todo.to_ical()) 

59 BEGIN:VTODO 

60 DTSTAMP:20250517T080612Z 

61 UID:d755cef5-2311-46ed-a0e1-6733c9e15c63 

62 END:VTODO 

63 

64 """ 

65 

66 name = "VTODO" 

67 

68 required = ( 

69 "UID", 

70 "DTSTAMP", 

71 ) 

72 singletons = ( 

73 "CLASS", 

74 "COLOR", 

75 "COMPLETED", 

76 "CREATED", 

77 "DESCRIPTION", 

78 "DTSTAMP", 

79 "DTSTART", 

80 "GEO", 

81 "LAST-MODIFIED", 

82 "LOCATION", 

83 "ORGANIZER", 

84 "PERCENT-COMPLETE", 

85 "PRIORITY", 

86 "RECURRENCE-ID", 

87 "SEQUENCE", 

88 "STATUS", 

89 "SUMMARY", 

90 "UID", 

91 "URL", 

92 "DUE", 

93 "DURATION", 

94 ) 

95 exclusive = ( 

96 "DUE", 

97 "DURATION", 

98 ) 

99 multiple = ( 

100 "ATTACH", 

101 "ATTENDEE", 

102 "CATEGORIES", 

103 "COMMENT", 

104 "CONTACT", 

105 "EXDATE", 

106 "RSTATUS", 

107 "RELATED", 

108 "RESOURCES", 

109 "RDATE", 

110 "RRULE", 

111 ) 

112 DTSTART = create_single_property( 

113 "DTSTART", 

114 "dt", 

115 (datetime, date), 

116 date, 

117 'The "DTSTART" property for a "VTODO" specifies the inclusive start of the Todo.', # noqa: E501 

118 ) 

119 DUE = create_single_property( 

120 "DUE", 

121 "dt", 

122 (datetime, date), 

123 date, 

124 'The "DUE" property for a "VTODO" calendar component specifies the non-inclusive end of the Todo.', # noqa: E501 

125 ) 

126 DURATION = property( 

127 property_get_duration, 

128 property_set_duration, 

129 property_del_duration, 

130 property_doc_duration_template.format(component="VTODO"), 

131 ) 

132 

133 def _get_start_end_duration(self): 

134 """Verify the calendar validity and return the right attributes.""" 

135 start = self.DTSTART 

136 end = self.DUE 

137 duration = self.DURATION 

138 if duration is not None and end is not None: 

139 raise InvalidCalendar( 

140 "Only one of DUE and DURATION may be in a VTODO, not both." 

141 ) 

142 if ( 

143 start is not None 

144 and is_date(start) 

145 and duration is not None 

146 and duration.seconds != 0 

147 ): 

148 raise InvalidCalendar( 

149 "When DTSTART is a date, DURATION must be of days or weeks." 

150 ) 

151 if start is not None and end is not None and is_date(start) != is_date(end): 

152 raise InvalidCalendar( 

153 "DTSTART and DUE must be of the same type, either date or datetime." 

154 ) 

155 return start, end, duration 

156 

157 @property 

158 def start(self) -> date | datetime: 

159 """The start of the VTODO. 

160 

161 Invalid values raise an InvalidCalendar. 

162 If there is no start, we also raise an IncompleteComponent error. 

163 

164 You can get the start, end and duration of a Todo as follows: 

165 

166 >>> from datetime import datetime 

167 >>> from icalendar import Todo 

168 >>> todo = Todo() 

169 >>> todo.start = datetime(2021, 1, 1, 12) 

170 >>> todo.end = datetime(2021, 1, 1, 12, 30) # 30 minutes 

171 >>> todo.duration # 1800 seconds == 30 minutes 

172 datetime.timedelta(seconds=1800) 

173 >>> print(todo.to_ical()) 

174 BEGIN:VTODO 

175 DTSTART:20210101T120000 

176 DUE:20210101T123000 

177 END:VTODO 

178 """ 

179 start = self._get_start_end_duration()[0] 

180 if start is None: 

181 raise IncompleteComponent("No DTSTART given.") 

182 return start 

183 

184 @start.setter 

185 def start(self, start: date | datetime | None): 

186 """Set the start.""" 

187 self.DTSTART = start 

188 

189 @property 

190 def end(self) -> date | datetime: 

191 """The end of the todo. 

192 

193 Invalid values raise an InvalidCalendar error. 

194 If there is no end, we also raise an IncompleteComponent error. 

195 """ 

196 start, end, duration = self._get_start_end_duration() 

197 if end is None and duration is None: 

198 if start is None: 

199 raise IncompleteComponent("No DUE or DURATION+DTSTART given.") 

200 if is_date(start): 

201 return start + timedelta(days=1) 

202 return start 

203 if duration is not None: 

204 if start is not None: 

205 return start + duration 

206 raise IncompleteComponent("No DUE or DURATION+DTSTART given.") 

207 return end 

208 

209 @end.setter 

210 def end(self, end: date | datetime | None): 

211 """Set the end.""" 

212 self.DUE = end 

213 

214 @property 

215 def duration(self) -> timedelta: 

216 """The duration of the VTODO. 

217 

218 Returns the DURATION property if set, otherwise calculated from start and end. 

219 You can set the duration to automatically adjust the end time while keeping 

220 start locked. 

221 

222 Setting the duration will: 

223 1. Keep the start time locked (unchanged) 

224 2. Adjust the end time to start + duration 

225 3. Remove any existing DUE property 

226 4. Set the DURATION property 

227 """ 

228 # First check if DURATION property is explicitly set 

229 if "DURATION" in self: 

230 return self["DURATION"].dt 

231 

232 # Fall back to calculated duration from start and end 

233 return self.end - self.start 

234 

235 @duration.setter 

236 def duration(self, value: timedelta): 

237 if not isinstance(value, timedelta): 

238 raise TypeError(f"Use timedelta, not {type(value).__name__}.") 

239 

240 # Use the set_duration method with default start-locked behavior 

241 self.set_duration(value, locked="start") 

242 

243 def set_duration( 

244 self, duration: timedelta | None, locked: Literal["start", "end"] = "start" 

245 ): 

246 """Set the duration of the event relative to either start or end. 

247 

248 Args: 

249 duration: The duration to set, or None to convert to DURATION property 

250 locked: Which property to keep unchanged ('start' or 'end') 

251 """ 

252 from icalendar.attr import set_duration_with_locking 

253 

254 set_duration_with_locking(self, duration, locked, "DUE") 

255 

256 def set_start( 

257 self, start: date | datetime, locked: Literal["duration", "end"] | None = None 

258 ): 

259 """Set the start with explicit locking behavior. 

260 

261 Args: 

262 start: The start time to set 

263 locked: Which property to keep unchanged ('duration', 'end', or None 

264 for auto-detect) 

265 """ 

266 if locked is None: 

267 # Auto-detect based on existing properties 

268 if "DURATION" in self: 

269 locked = "duration" 

270 elif "DUE" in self: 

271 locked = "end" 

272 else: 

273 # Default to duration if no existing properties 

274 locked = "duration" 

275 

276 if locked == "duration": 

277 # Keep duration locked, adjust end 

278 current_duration = ( 

279 self.duration if "DURATION" in self or "DUE" in self else None 

280 ) 

281 self.DTSTART = start 

282 if current_duration is not None: 

283 self.DURATION = current_duration 

284 elif locked == "end": 

285 # Keep end locked, adjust duration 

286 current_end = self.end 

287 self.DTSTART = start 

288 self.pop("DURATION", None) 

289 self.DUE = current_end 

290 else: 

291 raise ValueError( 

292 f"locked must be 'duration', 'end', or None, not {locked!r}" 

293 ) 

294 

295 def set_end( 

296 self, end: date | datetime, locked: Literal["start", "duration"] = "start" 

297 ): 

298 """Set the end of the component, keeping either the start or the duration same. 

299 

300 Args: 

301 end: The end time to set 

302 locked: Which property to keep unchanged ('start' or 'duration') 

303 """ 

304 if locked == "start": 

305 # Keep start locked, adjust duration 

306 self.pop("DURATION", None) 

307 self.DUE = end 

308 elif locked == "duration": 

309 # Keep duration locked, adjust start 

310 current_duration = self.duration 

311 self.DTSTART = end - current_duration 

312 self.DURATION = current_duration 

313 else: 

314 raise ValueError(f"locked must be 'start' or 'duration', not {locked!r}") 

315 

316 X_MOZ_SNOOZE_TIME = X_MOZ_SNOOZE_TIME_property 

317 X_MOZ_LASTACK = X_MOZ_LASTACK_property 

318 

319 @property 

320 def alarms(self) -> Alarms: 

321 """Compute the alarm times for this component. 

322 

323 >>> from datetime import datetime 

324 >>> from icalendar import Todo 

325 >>> todo = Todo() # empty without alarms 

326 >>> todo.start = datetime(2024, 10, 26, 10, 21) 

327 >>> len(todo.alarms.times) 

328 0 

329 

330 Note that this only uses DTSTART and DUE, but ignores 

331 RDATE, EXDATE, and RRULE properties. 

332 """ 

333 from icalendar.alarms import Alarms 

334 

335 return Alarms(self) 

336 

337 color = color_property 

338 sequence = sequence_property 

339 categories = categories_property 

340 rdates = rdates_property 

341 exdates = exdates_property 

342 rrules = rrules_property 

343 uid = uid_property 

344 summary = summary_property 

345 description = description_property 

346 classification = class_property 

347 url = url_property 

348 organizer = organizer_property 

349 location = location_property 

350 priority = priority_property 

351 contacts = contacts_property 

352 status = status_property 

353 attendees = attendees_property 

354 

355 @classmethod 

356 def new( 

357 cls, 

358 /, 

359 attendees: list[vCalAddress] | None = None, 

360 categories: Sequence[str] = (), 

361 classification: CLASS | None = None, 

362 color: str | None = None, 

363 comments: list[str] | str | None = None, 

364 contacts: list[str] | str | None = None, 

365 created: date | None = None, 

366 description: str | None = None, 

367 end: date | datetime | None = None, 

368 last_modified: date | None = None, 

369 location: str | None = None, 

370 organizer: vCalAddress | str | None = None, 

371 priority: int | None = None, 

372 sequence: int | None = None, 

373 stamp: date | None = None, 

374 start: date | datetime | None = None, 

375 status: STATUS | None = None, 

376 summary: str | None = None, 

377 uid: str | uuid.UUID | None = None, 

378 url: str | None = None, 

379 ): 

380 """Create a new TODO with all required properties. 

381 

382 This creates a new Todo in accordance with :rfc:`5545`. 

383 

384 Arguments: 

385 attendees: The :attr:`attendees` of the todo. 

386 categories: The :attr:`categories` of the todo. 

387 classification: The :attr:`classification` of the todo. 

388 color: The :attr:`color` of the todo. 

389 comments: The :attr:`Component.comments` of the todo. 

390 created: The :attr:`Component.created` of the todo. 

391 description: The :attr:`description` of the todo. 

392 end: The :attr:`end` of the todo. 

393 last_modified: The :attr:`Component.last_modified` of the todo. 

394 location: The :attr:`location` of the todo. 

395 organizer: The :attr:`organizer` of the todo. 

396 sequence: The :attr:`sequence` of the todo. 

397 stamp: The :attr:`Component.DTSTAMP` of the todo. 

398 If None, this is set to the current time. 

399 start: The :attr:`start` of the todo. 

400 status: The :attr:`status` of the todo. 

401 summary: The :attr:`summary` of the todo. 

402 uid: The :attr:`uid` of the todo. 

403 If None, this is set to a new :func:`uuid.uuid4`. 

404 url: The :attr:`url` of the todo. 

405 

406 Returns: 

407 :class:`Todo` 

408 

409 Raises: 

410 InvalidCalendar: If the content is not valid according to :rfc:`5545`. 

411 

412 .. warning:: As time progresses, we will be stricter with the validation. 

413 """ 

414 todo = super().new( 

415 stamp=stamp if stamp is not None else cls._utc_now(), 

416 created=created, 

417 last_modified=last_modified, 

418 comments=comments, 

419 ) 

420 todo.summary = summary 

421 todo.description = description 

422 todo.uid = uid if uid is not None else uuid.uuid4() 

423 todo.start = start 

424 todo.end = end 

425 todo.color = color 

426 todo.categories = categories 

427 todo.sequence = sequence 

428 todo.classification = classification 

429 todo.url = url 

430 todo.organizer = organizer 

431 todo.location = location 

432 todo.priority = priority 

433 todo.contacts = contacts 

434 todo.status = status 

435 todo.attendees = attendees 

436 if cls._validate_new: 

437 cls._validate_start_and_end(start, end) 

438 return todo 

439 

440 

441__all__ = ["Todo"]