iflow-mcp_democratize-technology-chronos-mcp 2.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. chronos_mcp/__init__.py +5 -0
  2. chronos_mcp/__main__.py +9 -0
  3. chronos_mcp/accounts.py +410 -0
  4. chronos_mcp/bulk.py +946 -0
  5. chronos_mcp/caldav_utils.py +149 -0
  6. chronos_mcp/calendars.py +204 -0
  7. chronos_mcp/config.py +187 -0
  8. chronos_mcp/credentials.py +190 -0
  9. chronos_mcp/events.py +515 -0
  10. chronos_mcp/exceptions.py +477 -0
  11. chronos_mcp/journals.py +477 -0
  12. chronos_mcp/logging_config.py +23 -0
  13. chronos_mcp/models.py +202 -0
  14. chronos_mcp/py.typed +0 -0
  15. chronos_mcp/rrule.py +259 -0
  16. chronos_mcp/search.py +315 -0
  17. chronos_mcp/server.py +121 -0
  18. chronos_mcp/tasks.py +518 -0
  19. chronos_mcp/tools/__init__.py +29 -0
  20. chronos_mcp/tools/accounts.py +151 -0
  21. chronos_mcp/tools/base.py +59 -0
  22. chronos_mcp/tools/bulk.py +557 -0
  23. chronos_mcp/tools/calendars.py +142 -0
  24. chronos_mcp/tools/events.py +698 -0
  25. chronos_mcp/tools/journals.py +310 -0
  26. chronos_mcp/tools/tasks.py +414 -0
  27. chronos_mcp/utils.py +163 -0
  28. chronos_mcp/validation.py +636 -0
  29. iflow_mcp_democratize_technology_chronos_mcp-2.0.0.dist-info/METADATA +299 -0
  30. iflow_mcp_democratize_technology_chronos_mcp-2.0.0.dist-info/RECORD +68 -0
  31. iflow_mcp_democratize_technology_chronos_mcp-2.0.0.dist-info/WHEEL +5 -0
  32. iflow_mcp_democratize_technology_chronos_mcp-2.0.0.dist-info/entry_points.txt +2 -0
  33. iflow_mcp_democratize_technology_chronos_mcp-2.0.0.dist-info/licenses/LICENSE +21 -0
  34. iflow_mcp_democratize_technology_chronos_mcp-2.0.0.dist-info/top_level.txt +2 -0
  35. tests/__init__.py +0 -0
  36. tests/conftest.py +91 -0
  37. tests/unit/__init__.py +0 -0
  38. tests/unit/test_accounts.py +380 -0
  39. tests/unit/test_accounts_ssrf.py +134 -0
  40. tests/unit/test_base.py +135 -0
  41. tests/unit/test_bulk.py +380 -0
  42. tests/unit/test_bulk_create.py +408 -0
  43. tests/unit/test_bulk_delete.py +341 -0
  44. tests/unit/test_bulk_resource_limits.py +74 -0
  45. tests/unit/test_caldav_utils.py +300 -0
  46. tests/unit/test_calendars.py +286 -0
  47. tests/unit/test_config.py +111 -0
  48. tests/unit/test_config_validation.py +128 -0
  49. tests/unit/test_credentials_security.py +189 -0
  50. tests/unit/test_cryptography_security.py +178 -0
  51. tests/unit/test_events.py +536 -0
  52. tests/unit/test_exceptions.py +58 -0
  53. tests/unit/test_journals.py +1097 -0
  54. tests/unit/test_models.py +95 -0
  55. tests/unit/test_race_conditions.py +202 -0
  56. tests/unit/test_recurring_events.py +156 -0
  57. tests/unit/test_rrule.py +217 -0
  58. tests/unit/test_search.py +372 -0
  59. tests/unit/test_search_advanced.py +333 -0
  60. tests/unit/test_server_input_validation.py +219 -0
  61. tests/unit/test_ssrf_protection.py +505 -0
  62. tests/unit/test_tasks.py +918 -0
  63. tests/unit/test_thread_safety.py +301 -0
  64. tests/unit/test_tools_journals.py +617 -0
  65. tests/unit/test_tools_tasks.py +968 -0
  66. tests/unit/test_url_validation_security.py +234 -0
  67. tests/unit/test_utils.py +180 -0
  68. tests/unit/test_validation.py +983 -0
@@ -0,0 +1,190 @@
1
+ """
2
+ Secure credential management for Chronos MCP using system keyring.
3
+
4
+ This module provides secure storage for CalDAV passwords using the system keyring
5
+ when available, with fallback to configuration file (with warnings).
6
+ """
7
+
8
+ from typing import Any, Dict, Optional
9
+
10
+ # Try to import keyring, but handle its absence gracefully
11
+ try:
12
+ import keyring
13
+
14
+ KEYRING_AVAILABLE = True
15
+ except ImportError:
16
+ KEYRING_AVAILABLE = False
17
+ keyring = None
18
+
19
+ from .logging_config import setup_logging
20
+
21
+ logger = setup_logging()
22
+
23
+
24
+ class CredentialManager:
25
+ """
26
+ Manages secure credential storage using system keyring with fallback.
27
+
28
+ Keyring service name follows the pattern: chronos-mcp
29
+ Keyring keys follow the pattern: caldav:{alias}
30
+ """
31
+
32
+ SERVICE_NAME = "chronos-mcp"
33
+ KEY_PREFIX = "caldav:"
34
+
35
+ def __init__(self):
36
+ """Initialize the credential manager."""
37
+ self.keyring_available = KEYRING_AVAILABLE
38
+ self._keyring_backend = None
39
+
40
+ if self.keyring_available:
41
+ try:
42
+ # Test keyring availability by getting the backend
43
+ self._keyring_backend = keyring.get_keyring()
44
+ backend_name = type(self._keyring_backend).__name__
45
+
46
+ # Check if we have a null/fail backend
47
+ if "fail" in backend_name.lower() or "null" in backend_name.lower():
48
+ self.keyring_available = False
49
+ logger.warning(f"Keyring backend is non-functional: {backend_name}")
50
+ else:
51
+ logger.info(f"Using keyring backend: {backend_name}")
52
+ except Exception as e:
53
+ self.keyring_available = False
54
+ logger.warning(f"Keyring initialization failed: {e}")
55
+ else:
56
+ logger.warning(
57
+ "Keyring module not available - passwords will be stored in config file"
58
+ )
59
+
60
+ def _get_keyring_key(self, alias: str) -> str:
61
+ """Generate the keyring key for an account alias."""
62
+ return f"{self.KEY_PREFIX}{alias}"
63
+
64
+ def get_password(
65
+ self, alias: str, fallback_password: Optional[str] = None
66
+ ) -> Optional[str]:
67
+ """
68
+ Retrieve password from keyring, with fallback to provided value.
69
+
70
+ Args:
71
+ alias: Account alias
72
+ fallback_password: Password from config file (used if keyring fails)
73
+
74
+ Returns:
75
+ Password string or None if not found
76
+ """
77
+ if self.keyring_available:
78
+ try:
79
+ key = self._get_keyring_key(alias)
80
+ password = keyring.get_password(self.SERVICE_NAME, key)
81
+
82
+ if password:
83
+ logger.debug(
84
+ "Retrieved password from keyring for account: [REDACTED]"
85
+ )
86
+ return password
87
+ elif fallback_password:
88
+ logger.warning(
89
+ f"Password for '{alias}' found in config file but not in keyring. "
90
+ "Consider running the migration script to securely store passwords in keyring: "
91
+ "python -m chronos_mcp.scripts.migrate_to_keyring"
92
+ )
93
+
94
+ except Exception as e:
95
+ logger.error(f"Failed to retrieve password from keyring: {e}")
96
+
97
+ if fallback_password:
98
+ if not self.keyring_available:
99
+ logger.debug("Using password from config file for account: [REDACTED]")
100
+ return fallback_password
101
+
102
+ return None
103
+
104
+ def set_password(self, alias: str, password: str) -> bool:
105
+ """
106
+ Store password in keyring.
107
+
108
+ Args:
109
+ alias: Account alias
110
+ password: Password to store
111
+
112
+ Returns:
113
+ True if successfully stored, False otherwise
114
+ """
115
+ if not self.keyring_available:
116
+ logger.debug(
117
+ "Keyring not available, cannot store password for account: [REDACTED]"
118
+ )
119
+ return False
120
+
121
+ try:
122
+ key = self._get_keyring_key(alias)
123
+ keyring.set_password(self.SERVICE_NAME, key, password)
124
+ logger.info("Password stored in keyring for account: [REDACTED]")
125
+ return True
126
+ except Exception as e:
127
+ logger.error(f"Failed to store password in keyring: {e}")
128
+ return False
129
+
130
+ def delete_password(self, alias: str) -> bool:
131
+ """
132
+ Remove password from keyring.
133
+
134
+ Args:
135
+ alias: Account alias
136
+
137
+ Returns:
138
+ True if successfully deleted, False otherwise
139
+ """
140
+ if not self.keyring_available:
141
+ return False
142
+
143
+ try:
144
+ key = self._get_keyring_key(alias)
145
+ keyring.delete_password(self.SERVICE_NAME, key)
146
+ logger.info("Password removed from keyring for account: [REDACTED]")
147
+ return True
148
+ except keyring.errors.PasswordDeleteError:
149
+ logger.debug("No password in keyring for account: [REDACTED]")
150
+ return False
151
+ except Exception as e:
152
+ logger.error(f"Failed to delete password from keyring: {e}")
153
+ return False
154
+
155
+ def get_status(self) -> Dict[str, Any]:
156
+ """
157
+ Get credential manager status information.
158
+
159
+ Returns:
160
+ Dictionary with status information
161
+ """
162
+ status = {
163
+ "keyring_available": self.keyring_available,
164
+ "backend": None,
165
+ "backend_type": None,
166
+ "secure": False,
167
+ }
168
+
169
+ if self.keyring_available and self._keyring_backend:
170
+ backend_name = type(self._keyring_backend).__name__
171
+ status["backend"] = str(self._keyring_backend)
172
+ status["backend_type"] = backend_name
173
+
174
+ # Determine if backend is secure
175
+ secure_backends = ["Keychain", "SecretService", "KWallet", "Windows"]
176
+ status["secure"] = any(sb in backend_name for sb in secure_backends)
177
+
178
+ return status
179
+
180
+
181
+ # Singleton instance
182
+ _credential_manager = None
183
+
184
+
185
+ def get_credential_manager() -> CredentialManager:
186
+ """Get the singleton credential manager instance."""
187
+ global _credential_manager
188
+ if _credential_manager is None:
189
+ _credential_manager = CredentialManager()
190
+ return _credential_manager
chronos_mcp/events.py ADDED
@@ -0,0 +1,515 @@
1
+ """
2
+ Event operations for Chronos MCP
3
+ """
4
+
5
+ import uuid
6
+ from datetime import datetime, timedelta, timezone
7
+ from typing import Any, Dict, List, Optional
8
+
9
+ import caldav
10
+ from caldav import Event as CalDAVEvent
11
+ from icalendar import Calendar as iCalendar
12
+ from icalendar import Event as iEvent
13
+
14
+ from .caldav_utils import get_item_with_fallback
15
+ from .calendars import CalendarManager
16
+ from .exceptions import (
17
+ CalendarNotFoundError,
18
+ EventCreationError,
19
+ EventDeletionError,
20
+ EventNotFoundError,
21
+ )
22
+ from .logging_config import setup_logging
23
+ from .models import Alarm, Attendee, Event
24
+ from .utils import ical_to_datetime, validate_rrule
25
+
26
+ logger = setup_logging()
27
+
28
+
29
+ class EventManager:
30
+ """Manage calendar events"""
31
+
32
+ def __init__(self, calendar_manager: CalendarManager):
33
+ self.calendars = calendar_manager
34
+
35
+ def _get_default_account(self) -> Optional[str]:
36
+ try:
37
+ return self.calendars.accounts.config.config.default_account
38
+ except Exception:
39
+ return None
40
+
41
+ def create_event(
42
+ self,
43
+ calendar_uid: str,
44
+ summary: str,
45
+ start: datetime,
46
+ end: datetime,
47
+ description: Optional[str] = None,
48
+ location: Optional[str] = None,
49
+ all_day: bool = False,
50
+ attendees: Optional[List[Dict[str, Any]]] = None,
51
+ alarm_minutes: Optional[int] = None,
52
+ recurrence_rule: Optional[str] = None,
53
+ related_to: Optional[List[str]] = None,
54
+ account_alias: Optional[str] = None,
55
+ request_id: Optional[str] = None,
56
+ ) -> Optional[Event]:
57
+ """Create a new event - raises exceptions on failure"""
58
+ request_id = request_id or str(uuid.uuid4())
59
+
60
+ calendar = self.calendars.get_calendar(
61
+ calendar_uid, account_alias, request_id=request_id
62
+ )
63
+ if not calendar:
64
+ raise CalendarNotFoundError(
65
+ calendar_uid, account_alias, request_id=request_id
66
+ )
67
+
68
+ try:
69
+ # Fix all-day event times
70
+ if all_day:
71
+ # Ensure start is at midnight
72
+ start = start.replace(hour=0, minute=0, second=0, microsecond=0)
73
+ # End should be midnight of the next day (24 hours later)
74
+ end = start + timedelta(days=1)
75
+
76
+ # Validate RRULE if provided
77
+ if recurrence_rule:
78
+ is_valid, error_msg = validate_rrule(recurrence_rule)
79
+ if not is_valid:
80
+ raise EventCreationError(
81
+ summary, f"Invalid RRULE: {error_msg}", request_id=request_id
82
+ )
83
+
84
+ cal = iCalendar()
85
+ event = iEvent()
86
+
87
+ # Generate UID if not provided
88
+ event_uid = str(uuid.uuid4())
89
+
90
+ event.add("uid", event_uid)
91
+ event.add("summary", summary)
92
+ event.add("dtstart", start)
93
+ event.add("dtend", end)
94
+ event.add("dtstamp", datetime.now(timezone.utc))
95
+
96
+ if description:
97
+ event.add("description", description)
98
+ if location:
99
+ event.add("location", location)
100
+ if recurrence_rule:
101
+ event.add("rrule", recurrence_rule)
102
+
103
+ if attendees:
104
+ for att in attendees:
105
+ attendee_str = f"mailto:{att['email']}"
106
+ event.add(
107
+ "attendee",
108
+ attendee_str,
109
+ parameters={
110
+ "CN": att.get("name", att["email"]),
111
+ "ROLE": att.get("role", "REQ-PARTICIPANT"),
112
+ "PARTSTAT": att.get("status", "NEEDS-ACTION"),
113
+ "RSVP": "TRUE" if att.get("rsvp", True) else "FALSE",
114
+ },
115
+ )
116
+
117
+ if related_to:
118
+ for related_uid in related_to:
119
+ event.add("related-to", related_uid)
120
+
121
+ if alarm_minutes:
122
+ from icalendar import Alarm as iAlarm
123
+
124
+ alarm = iAlarm()
125
+ alarm.add("action", "DISPLAY")
126
+ alarm.add("trigger", timedelta(minutes=-alarm_minutes))
127
+ alarm.add("description", summary)
128
+ event.add_component(alarm)
129
+
130
+ cal.add_component(event)
131
+
132
+ # Save to CalDAV server
133
+ caldav_event = calendar.save_event(cal.to_ical().decode("utf-8"))
134
+
135
+ event_model = Event(
136
+ uid=event_uid,
137
+ summary=summary,
138
+ description=description,
139
+ start=start,
140
+ end=end,
141
+ all_day=all_day,
142
+ location=location,
143
+ calendar_uid=calendar_uid,
144
+ account_alias=account_alias or self._get_default_account() or "default",
145
+ recurrence_rule=recurrence_rule,
146
+ related_to=related_to or [],
147
+ )
148
+
149
+ # Add attendees to model
150
+ if attendees:
151
+ event_model.attendees = [
152
+ Attendee(
153
+ email=att["email"],
154
+ name=att.get("name", att["email"]),
155
+ role=att.get("role", "REQ-PARTICIPANT"),
156
+ status=att.get("status", "NEEDS-ACTION"),
157
+ rsvp=att.get("rsvp", True),
158
+ )
159
+ for att in attendees
160
+ ]
161
+
162
+ # Add alarm to model
163
+ if alarm_minutes:
164
+ event_model.alarms = [
165
+ Alarm(
166
+ action="DISPLAY",
167
+ trigger=f"-PT{alarm_minutes}M",
168
+ description=summary,
169
+ )
170
+ ]
171
+
172
+ return event_model
173
+
174
+ except caldav.lib.error.AuthorizationError as e:
175
+ logger.error(
176
+ f"Authorization error creating event '{summary}': {e}",
177
+ extra={"request_id": request_id},
178
+ )
179
+ raise EventCreationError(
180
+ summary, "Authorization failed", request_id=request_id
181
+ )
182
+ except Exception as e:
183
+ logger.error(
184
+ f"Error creating event '{summary}': {e}",
185
+ extra={"request_id": request_id},
186
+ )
187
+ raise EventCreationError(summary, str(e), request_id=request_id)
188
+
189
+ def get_events_range(
190
+ self,
191
+ calendar_uid: str,
192
+ start_date: datetime,
193
+ end_date: datetime,
194
+ account_alias: Optional[str] = None,
195
+ request_id: Optional[str] = None,
196
+ ) -> List[Event]:
197
+ """Get events within a date range - raises exceptions on failure"""
198
+ request_id = request_id or str(uuid.uuid4())
199
+
200
+ calendar = self.calendars.get_calendar(
201
+ calendar_uid, account_alias, request_id=request_id
202
+ )
203
+ if not calendar:
204
+ raise CalendarNotFoundError(
205
+ calendar_uid, account_alias, request_id=request_id
206
+ )
207
+
208
+ events = []
209
+ try:
210
+ # Search for events in date range
211
+ results = calendar.date_search(start=start_date, end=end_date, expand=True)
212
+
213
+ for caldav_event in results:
214
+ event_data = self._parse_caldav_event(
215
+ caldav_event, calendar_uid, account_alias
216
+ )
217
+ if event_data:
218
+ events.append(event_data)
219
+
220
+ except Exception as e:
221
+ logger.error(f"Error getting events: {e}")
222
+
223
+ return events
224
+
225
+ def _parse_caldav_event(
226
+ self, caldav_event: CalDAVEvent, calendar_uid: str, account_alias: Optional[str]
227
+ ) -> Optional[Event]:
228
+ """Parse CalDAV event to Event model"""
229
+ try:
230
+ # Parse iCalendar data
231
+ ical = iCalendar.from_ical(caldav_event.data)
232
+
233
+ for component in ical.walk():
234
+ if component.name == "VEVENT":
235
+ # Parse date/time values
236
+ dtstart = component.get("dtstart")
237
+ dtend = component.get("dtend")
238
+
239
+ start_dt = ical_to_datetime(dtstart)
240
+ end_dt = ical_to_datetime(dtend)
241
+
242
+ # Detect all-day events
243
+ # Check if the original values were DATE (not DATE-TIME) or if it's midnight to midnight
244
+ is_all_day = False
245
+ if dtstart and dtend:
246
+ # Check if values are DATE type (no time component)
247
+ if hasattr(dtstart, "dt") and not hasattr(dtstart.dt, "hour"):
248
+ is_all_day = True
249
+ # Also check for midnight-to-midnight pattern
250
+ elif (
251
+ start_dt.hour == 0
252
+ and start_dt.minute == 0
253
+ and start_dt.second == 0
254
+ and end_dt.hour == 0
255
+ and end_dt.minute == 0
256
+ and end_dt.second == 0
257
+ and (end_dt - start_dt).days >= 1
258
+ ):
259
+ is_all_day = True
260
+
261
+ # Parse basic event data
262
+ event = Event(
263
+ uid=str(component.get("uid", "")),
264
+ summary=str(component.get("summary", "No Title")),
265
+ description=(
266
+ str(component.get("description", ""))
267
+ if component.get("description")
268
+ else None
269
+ ),
270
+ start=start_dt,
271
+ end=end_dt,
272
+ all_day=is_all_day,
273
+ location=(
274
+ str(component.get("location", ""))
275
+ if component.get("location")
276
+ else None
277
+ ),
278
+ calendar_uid=calendar_uid,
279
+ account_alias=account_alias
280
+ or self._get_default_account()
281
+ or "default",
282
+ recurrence_rule=(
283
+ str(component.get("rrule", ""))
284
+ if component.get("rrule")
285
+ else None
286
+ ),
287
+ )
288
+
289
+ # Parse attendees
290
+ attendees = component.get("attendee", [])
291
+ if attendees:
292
+ if not isinstance(attendees, list):
293
+ attendees = [attendees]
294
+
295
+ for attendee in attendees:
296
+ params = (
297
+ attendee.params if hasattr(attendee, "params") else {}
298
+ )
299
+ email = str(attendee).replace("mailto:", "")
300
+ event.attendees.append(
301
+ Attendee(
302
+ email=email,
303
+ name=params.get("CN", email),
304
+ role=params.get("ROLE", "REQ-PARTICIPANT"),
305
+ status=params.get("PARTSTAT", "NEEDS-ACTION"),
306
+ rsvp=params.get("RSVP", "TRUE").upper() == "TRUE",
307
+ )
308
+ )
309
+
310
+ return event
311
+
312
+ except Exception as e:
313
+ logger.error(f"Error parsing event: {e}")
314
+
315
+ return None
316
+
317
+ def delete_event(
318
+ self,
319
+ calendar_uid: str,
320
+ event_uid: str,
321
+ account_alias: Optional[str] = None,
322
+ request_id: Optional[str] = None,
323
+ ) -> bool:
324
+ """Delete an event by UID - raises exceptions on failure"""
325
+ request_id = request_id or str(uuid.uuid4())
326
+
327
+ calendar = self.calendars.get_calendar(
328
+ calendar_uid, account_alias, request_id=request_id
329
+ )
330
+ if not calendar:
331
+ raise CalendarNotFoundError(
332
+ calendar_uid, account_alias, request_id=request_id
333
+ )
334
+
335
+ try:
336
+ # Use utility function to find event with automatic fallback
337
+ event = get_item_with_fallback(
338
+ calendar, event_uid, "event", request_id=request_id
339
+ )
340
+ event.delete()
341
+ logger.info(
342
+ f"Deleted event '{event_uid}'",
343
+ extra={"request_id": request_id},
344
+ )
345
+ return True
346
+ except ValueError:
347
+ # get_item_with_fallback raises ValueError when not found
348
+ raise EventNotFoundError(event_uid, calendar_uid, request_id=request_id)
349
+
350
+ except EventNotFoundError:
351
+ raise # Re-raise our own exception
352
+ except caldav.lib.error.AuthorizationError as e:
353
+ logger.error(
354
+ f"Authorization error deleting event '{event_uid}': {e}",
355
+ extra={"request_id": request_id},
356
+ )
357
+ raise EventDeletionError(
358
+ event_uid, "Authorization failed", request_id=request_id
359
+ )
360
+ except Exception as e:
361
+ logger.error(
362
+ f"Error deleting event '{event_uid}': {e}",
363
+ extra={"request_id": request_id},
364
+ )
365
+ raise EventDeletionError(event_uid, str(e), request_id=request_id)
366
+
367
+ def update_event(
368
+ self,
369
+ calendar_uid: str,
370
+ event_uid: str,
371
+ summary: Optional[str] = None,
372
+ description: Optional[str] = None,
373
+ start: Optional[datetime] = None,
374
+ end: Optional[datetime] = None,
375
+ location: Optional[str] = None,
376
+ all_day: Optional[bool] = None,
377
+ attendees: Optional[List[Dict[str, Any]]] = None,
378
+ alarm_minutes: Optional[int] = None,
379
+ recurrence_rule: Optional[str] = None,
380
+ account_alias: Optional[str] = None,
381
+ request_id: Optional[str] = None,
382
+ ) -> Optional[Event]:
383
+ """Update an existing event - raises exceptions on failure
384
+
385
+ Only provided fields will be updated. Other fields remain unchanged.
386
+ """
387
+ request_id = request_id or str(uuid.uuid4())
388
+
389
+ calendar = self.calendars.get_calendar(
390
+ calendar_uid, account_alias, request_id=request_id
391
+ )
392
+ if not calendar:
393
+ raise CalendarNotFoundError(
394
+ calendar_uid, account_alias, request_id=request_id
395
+ )
396
+
397
+ try:
398
+ # Get existing event using utility function with automatic fallback
399
+ try:
400
+ caldav_event = get_item_with_fallback(
401
+ calendar, event_uid, "event", request_id=request_id
402
+ )
403
+ except ValueError:
404
+ raise EventNotFoundError(event_uid, calendar_uid, request_id=request_id)
405
+ # Parse existing event data
406
+ ical = iCalendar.from_ical(caldav_event.data)
407
+ existing_event = None
408
+
409
+ for component in ical.walk():
410
+ if component.name == "VEVENT":
411
+ existing_event = component
412
+ break
413
+
414
+ if not existing_event:
415
+ raise EventCreationError(
416
+ f"Event {event_uid}",
417
+ "Could not parse existing event data",
418
+ request_id=request_id,
419
+ )
420
+
421
+ # Validate RRULE if provided
422
+ if recurrence_rule is not None:
423
+ is_valid, error_msg = validate_rrule(recurrence_rule)
424
+ if not is_valid:
425
+ raise EventCreationError(
426
+ summary or str(existing_event.get("summary", "")),
427
+ f"Invalid RRULE: {error_msg}",
428
+ request_id=request_id,
429
+ )
430
+
431
+ # Update only provided fields
432
+ if summary is not None:
433
+ existing_event["summary"] = summary
434
+
435
+ if description is not None:
436
+ if description:
437
+ existing_event["description"] = description
438
+ elif "description" in existing_event:
439
+ del existing_event["description"]
440
+
441
+ if start is not None:
442
+ existing_event["dtstart"].dt = start
443
+
444
+ if end is not None:
445
+ existing_event["dtend"].dt = end
446
+
447
+ if location is not None:
448
+ if location:
449
+ existing_event["location"] = location
450
+ elif "location" in existing_event:
451
+ del existing_event["location"]
452
+ if recurrence_rule is not None:
453
+ if recurrence_rule:
454
+ existing_event["rrule"] = recurrence_rule
455
+ elif "rrule" in existing_event:
456
+ del existing_event["rrule"]
457
+
458
+ # Update attendees if provided
459
+ if attendees is not None:
460
+ # Remove existing attendees
461
+ if "attendee" in existing_event:
462
+ del existing_event["attendee"]
463
+
464
+ # Add new attendees
465
+ for att in attendees:
466
+ attendee_str = f"mailto:{att['email']}"
467
+ existing_event.add(
468
+ "attendee",
469
+ attendee_str,
470
+ parameters={
471
+ "CN": att.get("name", att["email"]),
472
+ "ROLE": att.get("role", "REQ-PARTICIPANT"),
473
+ "PARTSTAT": att.get("status", "NEEDS-ACTION"),
474
+ "RSVP": "TRUE" if att.get("rsvp", True) else "FALSE",
475
+ },
476
+ )
477
+
478
+ # Update alarm if provided
479
+ if alarm_minutes is not None:
480
+ # Remove existing alarms
481
+ for component in list(existing_event.subcomponents):
482
+ if component.name == "VALARM":
483
+ existing_event.subcomponents.remove(component)
484
+
485
+ # Add new alarm if specified
486
+ if alarm_minutes > 0:
487
+ from icalendar import Alarm as iAlarm
488
+
489
+ alarm = iAlarm()
490
+ alarm.add("action", "DISPLAY")
491
+ alarm.add("trigger", timedelta(minutes=-alarm_minutes))
492
+ alarm.add("description", existing_event.get("summary", ""))
493
+ existing_event.add_component(alarm)
494
+
495
+ # Update last-modified timestamp
496
+ existing_event["last-modified"] = datetime.now(timezone.utc)
497
+
498
+ # Save the updated event
499
+ caldav_event.data = ical.to_ical().decode("utf-8")
500
+ caldav_event.save()
501
+ # Parse and return the updated event
502
+ return self._parse_caldav_event(caldav_event, calendar_uid, account_alias)
503
+
504
+ except EventNotFoundError:
505
+ raise
506
+ except EventCreationError:
507
+ raise
508
+ except Exception as e:
509
+ logger.error(
510
+ f"Error updating event '{event_uid}': {e}",
511
+ extra={"request_id": request_id},
512
+ )
513
+ raise EventCreationError(
514
+ event_uid, f"Failed to update event: {str(e)}", request_id=request_id
515
+ )