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,414 @@
1
+ """
2
+ Task management tools for Chronos MCP
3
+ """
4
+
5
+ import uuid
6
+ from typing import Any, Dict, List, Optional, Union
7
+
8
+ from pydantic import Field
9
+
10
+ from ..exceptions import (
11
+ CalendarNotFoundError,
12
+ ChronosError,
13
+ ErrorSanitizer,
14
+ EventCreationError,
15
+ EventNotFoundError,
16
+ ValidationError,
17
+ )
18
+ from ..logging_config import setup_logging
19
+ from ..models import TaskStatus
20
+ from ..utils import parse_datetime
21
+ from ..validation import InputValidator
22
+ from .base import create_success_response, handle_tool_errors
23
+
24
+ logger = setup_logging()
25
+
26
+ # Module-level managers dictionary for dependency injection
27
+ _managers = {}
28
+
29
+
30
+ # Task tool functions - defined as standalone functions for importability
31
+ async def create_task(
32
+ calendar_uid: str = Field(..., description="Calendar UID"),
33
+ summary: str = Field(..., description="Task title/summary"),
34
+ description: Optional[str] = Field(None, description="Task description"),
35
+ due: Optional[str] = Field(None, description="Task due date (ISO format)"),
36
+ priority: Optional[Union[int, str]] = Field(
37
+ None, description="Task priority (1-9, 1 is highest)"
38
+ ),
39
+ status: str = Field(
40
+ "NEEDS-ACTION",
41
+ description="Task status (NEEDS-ACTION, IN-PROCESS, COMPLETED, CANCELLED)",
42
+ ),
43
+ related_to: Optional[List[str]] = Field(
44
+ None, description="List of related component UIDs"
45
+ ),
46
+ account: Optional[str] = Field(None, description="Account alias"),
47
+ ) -> Dict[str, Any]:
48
+ """Create a new task"""
49
+ request_id = str(uuid.uuid4())
50
+
51
+ # Handle type conversion for parameters that might come as strings from MCP
52
+ if priority is not None:
53
+ try:
54
+ priority = int(priority)
55
+ except (ValueError, TypeError):
56
+ return {
57
+ "success": False,
58
+ "error": f"Invalid priority value: {priority}. Must be an integer between 1 and 9",
59
+ "error_code": "VALIDATION_ERROR",
60
+ "request_id": request_id,
61
+ }
62
+
63
+ try:
64
+ # Validate and sanitize text inputs
65
+ try:
66
+ summary = InputValidator.validate_text_field(
67
+ summary, "summary", required=True
68
+ )
69
+ if description:
70
+ description = InputValidator.validate_text_field(
71
+ description, "description"
72
+ )
73
+ except ValidationError as e:
74
+ return {
75
+ "success": False,
76
+ "error": str(e),
77
+ "error_code": "VALIDATION_ERROR",
78
+ "request_id": request_id,
79
+ }
80
+
81
+ # Parse due date if provided
82
+ due_dt = None
83
+ if due:
84
+ due_dt = parse_datetime(due)
85
+
86
+ # Validate priority
87
+ if priority is not None and not (1 <= priority <= 9):
88
+ return {
89
+ "success": False,
90
+ "error": "Priority must be between 1 and 9",
91
+ "error_code": "VALIDATION_ERROR",
92
+ "request_id": request_id,
93
+ }
94
+
95
+ # Parse status
96
+ try:
97
+ task_status = TaskStatus(status)
98
+ except ValueError:
99
+ return {
100
+ "success": False,
101
+ "error": f"Invalid status: {status}. Must be one of: NEEDS-ACTION, IN-PROCESS, COMPLETED, CANCELLED",
102
+ "error_code": "VALIDATION_ERROR",
103
+ "request_id": request_id,
104
+ }
105
+
106
+ task = _managers["task_manager"].create_task(
107
+ calendar_uid=calendar_uid,
108
+ summary=summary,
109
+ description=description,
110
+ due=due_dt,
111
+ priority=priority,
112
+ status=task_status,
113
+ related_to=related_to,
114
+ account_alias=account,
115
+ request_id=request_id,
116
+ )
117
+
118
+ return {
119
+ "success": True,
120
+ "task": {
121
+ "uid": task.uid,
122
+ "summary": task.summary,
123
+ "description": task.description,
124
+ "due": task.due.isoformat() if task.due else None,
125
+ "priority": task.priority,
126
+ "status": task.status.value,
127
+ "percent_complete": task.percent_complete,
128
+ "related_to": task.related_to,
129
+ },
130
+ "request_id": request_id,
131
+ }
132
+
133
+ except (CalendarNotFoundError, EventCreationError) as e:
134
+ e.request_id = request_id
135
+ logger.error(f"Task creation error: {e}")
136
+
137
+ return {
138
+ "success": False,
139
+ "error": ErrorSanitizer.get_user_friendly_message(e),
140
+ "error_code": e.error_code,
141
+ "request_id": request_id,
142
+ }
143
+
144
+ except ChronosError as e:
145
+ e.request_id = request_id
146
+ logger.error(f"Create task failed: {e}")
147
+
148
+ return {
149
+ "success": False,
150
+ "error": ErrorSanitizer.get_user_friendly_message(e),
151
+ "error_code": e.error_code,
152
+ "request_id": request_id,
153
+ }
154
+
155
+ except Exception as e:
156
+ chronos_error = ChronosError(
157
+ message=f"Failed to create task: {str(e)}",
158
+ details={
159
+ "tool": "create_task",
160
+ "summary": summary,
161
+ "calendar_uid": calendar_uid,
162
+ "original_error": str(e),
163
+ "original_type": type(e).__name__,
164
+ },
165
+ request_id=request_id,
166
+ )
167
+ logger.error(f"Unexpected error in create_task: {chronos_error}")
168
+
169
+ return {
170
+ "success": False,
171
+ "error": ErrorSanitizer.get_user_friendly_message(chronos_error),
172
+ "error_code": chronos_error.error_code,
173
+ "request_id": request_id,
174
+ }
175
+
176
+
177
+ async def list_tasks(
178
+ calendar_uid: str = Field(..., description="Calendar UID"),
179
+ status_filter: Optional[str] = Field(
180
+ None,
181
+ description="Filter by status (NEEDS-ACTION, IN-PROCESS, COMPLETED, CANCELLED)",
182
+ ),
183
+ account: Optional[str] = Field(None, description="Account alias"),
184
+ ) -> Dict[str, Any]:
185
+ """List tasks in a calendar"""
186
+ request_id = str(uuid.uuid4())
187
+
188
+ try:
189
+ # Parse status filter if provided
190
+ status_enum = None
191
+ if status_filter:
192
+ try:
193
+ status_enum = TaskStatus(status_filter)
194
+ except ValueError:
195
+ return {
196
+ "success": False,
197
+ "error": f"Invalid status filter: {status_filter}. Must be one of: NEEDS-ACTION, IN-PROCESS, COMPLETED, CANCELLED",
198
+ "error_code": "VALIDATION_ERROR",
199
+ "request_id": request_id,
200
+ }
201
+
202
+ tasks = _managers["task_manager"].list_tasks(
203
+ calendar_uid=calendar_uid,
204
+ status_filter=status_enum,
205
+ account_alias=account,
206
+ )
207
+
208
+ return {
209
+ "tasks": [
210
+ {
211
+ "uid": task.uid,
212
+ "summary": task.summary,
213
+ "description": task.description,
214
+ "due": task.due.isoformat() if task.due else None,
215
+ "priority": task.priority,
216
+ "status": task.status.value,
217
+ "percent_complete": task.percent_complete,
218
+ "related_to": task.related_to,
219
+ }
220
+ for task in tasks
221
+ ],
222
+ "total": len(tasks),
223
+ "calendar_uid": calendar_uid,
224
+ "request_id": request_id,
225
+ }
226
+
227
+ except CalendarNotFoundError as e:
228
+ e.request_id = request_id
229
+ logger.error(f"Calendar not found for task listing: {e}")
230
+
231
+ return {
232
+ "tasks": [],
233
+ "total": 0,
234
+ "error": ErrorSanitizer.get_user_friendly_message(e),
235
+ "error_code": e.error_code,
236
+ "request_id": request_id,
237
+ }
238
+
239
+ except ChronosError as e:
240
+ e.request_id = request_id
241
+ logger.error(f"List tasks failed: {e}")
242
+
243
+ return {
244
+ "tasks": [],
245
+ "total": 0,
246
+ "error": ErrorSanitizer.get_user_friendly_message(e),
247
+ "error_code": e.error_code,
248
+ "request_id": request_id,
249
+ }
250
+
251
+ except Exception as e:
252
+ chronos_error = ChronosError(
253
+ message=f"Failed to list tasks: {str(e)}",
254
+ details={
255
+ "tool": "list_tasks",
256
+ "calendar_uid": calendar_uid,
257
+ "original_error": str(e),
258
+ "original_type": type(e).__name__,
259
+ },
260
+ request_id=request_id,
261
+ )
262
+ logger.error(f"Unexpected error in list_tasks: {chronos_error}")
263
+
264
+ return {
265
+ "tasks": [],
266
+ "total": 0,
267
+ "error": ErrorSanitizer.get_user_friendly_message(chronos_error),
268
+ "error_code": chronos_error.error_code,
269
+ "request_id": request_id,
270
+ }
271
+
272
+
273
+ @handle_tool_errors
274
+ async def update_task(
275
+ calendar_uid: str = Field(..., description="Calendar UID"),
276
+ task_uid: str = Field(..., description="Task UID to update"),
277
+ summary: Optional[str] = Field(None, description="Task title/summary"),
278
+ description: Optional[str] = Field(None, description="Task description"),
279
+ due: Optional[str] = Field(None, description="Task due date (ISO format)"),
280
+ priority: Optional[Union[int, str]] = Field(
281
+ None, description="Task priority (1-9, 1 is highest)"
282
+ ),
283
+ status: Optional[str] = Field(None, description="Task status"),
284
+ percent_complete: Optional[Union[int, str]] = Field(
285
+ None, description="Completion percentage (0-100)"
286
+ ),
287
+ account: Optional[str] = Field(None, description="Account alias"),
288
+ request_id: str = None,
289
+ ) -> Dict[str, Any]:
290
+ """Update an existing task. Only provided fields will be updated."""
291
+ # Handle type conversion for parameters that might come as strings from MCP
292
+ if priority is not None:
293
+ try:
294
+ priority = int(priority)
295
+ except (ValueError, TypeError):
296
+ raise ValidationError(
297
+ f"Invalid priority value: {priority}. Must be an integer between 1 and 9"
298
+ )
299
+
300
+ if percent_complete is not None:
301
+ try:
302
+ percent_complete = int(percent_complete)
303
+ except (ValueError, TypeError):
304
+ raise ValidationError(
305
+ f"Invalid percent_complete value: {percent_complete}. Must be an integer between 0 and 100"
306
+ )
307
+
308
+ # Validate and parse inputs
309
+ if summary is not None:
310
+ summary = InputValidator.validate_text_field(summary, "summary", required=True)
311
+ if description is not None:
312
+ description = InputValidator.validate_text_field(description, "description")
313
+
314
+ # Parse due date if provided
315
+ due_dt = None
316
+ if due is not None:
317
+ due_dt = parse_datetime(due)
318
+
319
+ # Validate priority
320
+ if priority is not None and not (1 <= priority <= 9):
321
+ raise ValidationError("Priority must be between 1 and 9")
322
+
323
+ # Parse status
324
+ status_enum = None
325
+ if status is not None:
326
+ try:
327
+ status_enum = TaskStatus(status)
328
+ except ValueError:
329
+ raise ValidationError(
330
+ f"Invalid status: {status}. Must be one of: NEEDS-ACTION, IN-PROCESS, COMPLETED, CANCELLED"
331
+ )
332
+
333
+ # Validate percent_complete
334
+ if percent_complete is not None and not (0 <= percent_complete <= 100):
335
+ raise ValidationError("Percent complete must be between 0 and 100")
336
+
337
+ updated_task = _managers["task_manager"].update_task(
338
+ calendar_uid=calendar_uid,
339
+ task_uid=task_uid,
340
+ summary=summary,
341
+ description=description,
342
+ due=due_dt,
343
+ priority=priority,
344
+ status=status_enum,
345
+ percent_complete=percent_complete,
346
+ account_alias=account,
347
+ request_id=request_id,
348
+ )
349
+
350
+ return create_success_response(
351
+ message=f"Task '{task_uid}' updated successfully",
352
+ request_id=request_id,
353
+ task={
354
+ "uid": updated_task.uid,
355
+ "summary": updated_task.summary,
356
+ "description": updated_task.description,
357
+ "due": updated_task.due.isoformat() if updated_task.due else None,
358
+ "priority": updated_task.priority,
359
+ "status": updated_task.status.value,
360
+ "percent_complete": updated_task.percent_complete,
361
+ "related_to": updated_task.related_to,
362
+ },
363
+ )
364
+
365
+
366
+ @handle_tool_errors
367
+ async def delete_task(
368
+ calendar_uid: str = Field(..., description="Calendar UID"),
369
+ task_uid: str = Field(..., description="Task UID to delete"),
370
+ account: Optional[str] = Field(None, description="Account alias"),
371
+ request_id: str = None,
372
+ ) -> Dict[str, Any]:
373
+ """Delete a task"""
374
+ _managers["task_manager"].delete_task(
375
+ calendar_uid=calendar_uid,
376
+ task_uid=task_uid,
377
+ account_alias=account,
378
+ request_id=request_id,
379
+ )
380
+
381
+ return create_success_response(
382
+ message=f"Task '{task_uid}' deleted successfully",
383
+ request_id=request_id,
384
+ )
385
+
386
+
387
+ def register_task_tools(mcp, managers):
388
+ """Register task management tools with the MCP server"""
389
+
390
+ # Update module-level managers for dependency injection
391
+ _managers.update(managers)
392
+
393
+ # Register all task tools with the MCP server
394
+ mcp.tool(create_task)
395
+ mcp.tool(list_tasks)
396
+ mcp.tool(update_task)
397
+ mcp.tool(delete_task)
398
+
399
+
400
+ # Add .fn attribute to each function for backwards compatibility with tests
401
+ create_task.fn = create_task
402
+ list_tasks.fn = list_tasks
403
+ update_task.fn = update_task
404
+ delete_task.fn = delete_task
405
+
406
+
407
+ # Export all tools for backwards compatibility
408
+ __all__ = [
409
+ "create_task",
410
+ "list_tasks",
411
+ "update_task",
412
+ "delete_task",
413
+ "register_task_tools",
414
+ ]
chronos_mcp/utils.py ADDED
@@ -0,0 +1,163 @@
1
+ """
2
+ Utility functions for Chronos MCP
3
+ """
4
+
5
+ from datetime import datetime, timezone
6
+ from typing import Optional, Tuple, Union
7
+
8
+ from dateutil import parser
9
+ from icalendar import Event as iEvent
10
+
11
+ from .logging_config import setup_logging
12
+
13
+ logger = setup_logging()
14
+
15
+
16
+ def parse_datetime(dt_str: Union[str, datetime]) -> datetime:
17
+ """Parse datetime string or return datetime object"""
18
+ if isinstance(dt_str, datetime):
19
+ return dt_str
20
+
21
+ # Try parsing with dateutil
22
+ try:
23
+ dt = parser.parse(dt_str)
24
+ # Ensure timezone awareness
25
+ if dt.tzinfo is None:
26
+ dt = dt.replace(tzinfo=timezone.utc)
27
+ return dt
28
+ except Exception as e:
29
+ logger.error(f"Error parsing datetime '{dt_str}': {e}")
30
+ raise ValueError(f"Invalid datetime format: {dt_str}")
31
+
32
+
33
+ def datetime_to_ical(dt: datetime, all_day: bool = False) -> str:
34
+ """Convert datetime to iCalendar format"""
35
+ if all_day:
36
+ return dt.strftime("%Y%m%d")
37
+ else:
38
+ # Ensure UTC timezone
39
+ if dt.tzinfo is None:
40
+ dt = dt.replace(tzinfo=timezone.utc)
41
+ elif dt.tzinfo != timezone.utc:
42
+ dt = dt.astimezone(timezone.utc)
43
+ return dt.strftime("%Y%m%dT%H%M%SZ")
44
+
45
+
46
+ def ical_to_datetime(ical_dt) -> datetime:
47
+ """Convert iCalendar datetime to Python datetime"""
48
+ if hasattr(ical_dt, "dt"):
49
+ dt = ical_dt.dt
50
+ else:
51
+ dt = ical_dt
52
+
53
+ # Handle date-only (all-day events)
54
+ if not isinstance(dt, datetime):
55
+ dt = datetime.combine(dt, datetime.min.time())
56
+ dt = dt.replace(tzinfo=timezone.utc)
57
+
58
+ # Ensure timezone awareness
59
+ if dt.tzinfo is None:
60
+ dt = dt.replace(tzinfo=timezone.utc)
61
+
62
+ return dt
63
+
64
+
65
+ def create_ical_event(event_data: dict) -> iEvent:
66
+ """Create iCalendar event from data"""
67
+ event = iEvent()
68
+
69
+ # Required fields
70
+ event.add("uid", event_data.get("uid"))
71
+ event.add("summary", event_data.get("summary"))
72
+ event.add("dtstart", event_data.get("start"))
73
+ event.add("dtend", event_data.get("end"))
74
+
75
+ # Optional fields
76
+ if "description" in event_data:
77
+ event.add("description", event_data["description"])
78
+ if "location" in event_data:
79
+ event.add("location", event_data["location"])
80
+ if "status" in event_data:
81
+ event.add("status", event_data["status"])
82
+
83
+ return event
84
+
85
+
86
+ def validate_rrule(rrule: str) -> Tuple[bool, Optional[str]]:
87
+ """
88
+ Validate RRULE syntax according to RFC 5545.
89
+
90
+ Args:
91
+ rrule: The RRULE string to validate
92
+
93
+ Returns:
94
+ tuple: (is_valid, error_message)
95
+ """
96
+ if not rrule:
97
+ return True, None
98
+
99
+ try:
100
+ # Basic validation - must have FREQ
101
+ if not rrule.startswith("FREQ="):
102
+ return False, "RRULE must start with FREQ="
103
+
104
+ # Parse components
105
+ parts = rrule.split(";")
106
+ rules = {}
107
+
108
+ for part in parts:
109
+ if "=" not in part:
110
+ return False, f"Invalid RRULE component: {part}"
111
+
112
+ key, value = part.split("=", 1)
113
+ rules[key] = value
114
+
115
+ # Validate FREQ is present and valid
116
+ if "FREQ" not in rules:
117
+ return False, "FREQ is required in RRULE"
118
+
119
+ valid_freqs = ["DAILY", "WEEKLY", "MONTHLY", "YEARLY"]
120
+ if rules["FREQ"] not in valid_freqs:
121
+ return (
122
+ False,
123
+ f"Invalid FREQ value: {rules['FREQ']}. Must be one of {valid_freqs}",
124
+ )
125
+
126
+ # Validate other common components
127
+ if "INTERVAL" in rules:
128
+ try:
129
+ interval = int(rules["INTERVAL"])
130
+ if interval < 1:
131
+ return False, "INTERVAL must be a positive integer"
132
+ except ValueError:
133
+ return False, "INTERVAL must be an integer"
134
+
135
+ if "COUNT" in rules:
136
+ try:
137
+ count = int(rules["COUNT"])
138
+ if count < 1:
139
+ return False, "COUNT must be a positive integer"
140
+ except ValueError:
141
+ return False, "COUNT must be an integer"
142
+
143
+ if "UNTIL" in rules:
144
+ # Basic format check for UNTIL (should be datetime)
145
+ until = rules["UNTIL"]
146
+ if not (len(until) >= 8 and until[0:8].isdigit()):
147
+ return False, "UNTIL must be in YYYYMMDD or YYYYMMDDTHHMMSSZ format"
148
+
149
+ if "BYDAY" in rules:
150
+ # Validate day abbreviations
151
+ valid_days = ["MO", "TU", "WE", "TH", "FR", "SA", "SU"]
152
+ days = rules["BYDAY"].split(",")
153
+ for day in days:
154
+ # Remove position prefix if present (e.g., 2MO for 2nd Monday)
155
+ day_abbr = day.lstrip("-+0123456789")
156
+ if day_abbr not in valid_days:
157
+ return False, f"Invalid day abbreviation: {day}"
158
+
159
+ # If we get here, basic validation passed
160
+ return True, None
161
+
162
+ except Exception as e:
163
+ return False, f"Error parsing RRULE: {str(e)}"