vikunja-python 0.1.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.
@@ -0,0 +1,193 @@
1
+ """
2
+ Vikunja Pydantic Models - Base Configuration
3
+
4
+ All Vikunja API models inherit from VikunjaBaseModel for consistent configuration.
5
+ """
6
+
7
+ from pydantic import BaseModel, ConfigDict, Field, field_validator
8
+ from datetime import datetime
9
+ from typing import Optional
10
+ import re
11
+
12
+
13
+ class VikunjaBaseModel(BaseModel):
14
+ """
15
+ Base model with common configuration for all Vikunja API models.
16
+
17
+ Configuration:
18
+ extra='ignore': Ignore undocumented fields from API (prevents crashes on upstream changes)
19
+ populate_by_name=True: Accept both snake_case and camelCase field names
20
+ from_attributes=True: Support ORM mode if needed for database integration
21
+ """
22
+ model_config = ConfigDict(
23
+ extra='ignore', # Ignore undocumented API fields
24
+ populate_by_name=True, # Allow both naming conventions
25
+ from_attributes=True, # Support ORM mode
26
+ )
27
+
28
+
29
+ # ============================================================================
30
+ # Shared Nested Models (used across multiple entities)
31
+ # ============================================================================
32
+
33
+ class User(VikunjaBaseModel):
34
+ """
35
+ User object as returned by the Vikunja API.
36
+
37
+ Appears in: assignees[], created_by, owner (projects), labels.created_by
38
+ """
39
+ id: int = Field(..., description="Unique user identifier")
40
+ username: str = Field(..., description="Username")
41
+ email: Optional[str] = Field(None, description="Email address")
42
+ name: Optional[str] = Field(None, description="Display name (may be empty string)")
43
+ created: datetime = Field(..., description="Account creation timestamp (ISO 8601)")
44
+ updated: datetime = Field(..., description="Last update timestamp (ISO 8601)")
45
+
46
+ class Label(VikunjaBaseModel):
47
+ """
48
+ Label (tag) assigned to tasks.
49
+
50
+ API endpoint: /labels
51
+ Used in: Task.labels[]
52
+ """
53
+ id: int = Field(..., description="Unique label identifier")
54
+ title: str = Field(..., description="Label text/title")
55
+ description: Optional[str] = Field(None, description="Label description (markdown)")
56
+ hex_color: Optional[str] = Field(None, description="Hex color code")
57
+
58
+ @field_validator('hex_color')
59
+ @classmethod
60
+ def validate_hex_color(cls, v):
61
+ """Validate and normalize hex color format (#RRGGBB)."""
62
+ if v is None or v == "":
63
+ return None
64
+ # Add # if missing
65
+ if not v.startswith('#'):
66
+ v = f'#{v}'
67
+ if not re.match(r'^#[0-9A-Fa-f]{6}$', v):
68
+ return None # Or raise error, but base model should be lenient
69
+ return v
70
+ created_by: Optional[User] = Field(None, description="User who created this label")
71
+ created: datetime = Field(..., description="Creation timestamp (ISO 8601)")
72
+ updated: datetime = Field(..., description="Last update timestamp (ISO 8601)")
73
+
74
+
75
+ class TaskComment(VikunjaBaseModel):
76
+ """
77
+ Comment on a task.
78
+
79
+ API endpoint: /tasks/{id}/comments
80
+
81
+ UNKNOWN: Full structure not yet verified from API response
82
+ TODO: Extract from actual API call with expand=comments
83
+ """
84
+ # UNKNOWN: Field definitions pending API verification
85
+ id: Optional[int] = Field(None, description="Comment ID")
86
+ text: Optional[str] = Field(None, description="Comment text (markdown)")
87
+ # TODO: Add remaining fields after API inspection
88
+
89
+
90
+ class TaskAttachment(VikunjaBaseModel):
91
+ """
92
+ File attachment on a task.
93
+
94
+ API endpoint: /tasks/{id}/attachments
95
+
96
+ UNKNOWN: Full structure not yet verified from API response
97
+ TODO: Extract from actual API call with expand=attachments
98
+ """
99
+ # UNKNOWN: Field definitions pending API verification
100
+ id: Optional[int] = Field(None, description="Attachment ID")
101
+ filename: Optional[str] = Field(None, description="Original filename")
102
+ # TODO: Add remaining fields after API inspection
103
+
104
+
105
+ class TaskReminder(VikunjaBaseModel):
106
+ """
107
+ Reminder for a task.
108
+
109
+ API endpoint: /tasks/{id}/reminders
110
+
111
+ UNKNOWN: Full structure not yet verified from API response
112
+ TODO: Extract from actual API call with expand=reminders
113
+ """
114
+ # UNKNOWN: Field definitions pending API verification
115
+ id: Optional[int] = Field(None, description="Reminder ID")
116
+ reminder_at: Optional[datetime] = Field(None, description="When reminder triggers")
117
+ # TODO: Add remaining fields after API inspection
118
+
119
+
120
+ # ============================================================================
121
+ # Error Handling Models
122
+ # ============================================================================
123
+
124
+ class ErrorDetail(VikunjaBaseModel):
125
+ """
126
+ Structured error response for LLM consumption.
127
+
128
+ Vikunja API returns errors in this format:
129
+ {"code": <int>, "message": "<string>"}
130
+
131
+ The 'code' field is an error code (e.g., 11 = invalid token).
132
+ """
133
+ code: int = Field(..., description="Machine-readable error code")
134
+ message: str = Field(..., description="Human-readable error message")
135
+
136
+
137
+ class Message(VikunjaBaseModel):
138
+ """
139
+ Generic API message wrapper.
140
+
141
+ Used for simple success/error messages from the API.
142
+ """
143
+ code: Optional[int] = Field(None, description="Error code if applicable")
144
+ message: str = Field(..., description="Message text")
145
+
146
+
147
+ # ============================================================================
148
+ # Authentication Models
149
+ # ============================================================================
150
+
151
+ class Token(VikunjaBaseModel):
152
+ """
153
+ Authentication token response.
154
+
155
+ Returned by: POST /login, POST /auth/openid/{provider}/callback
156
+ """
157
+ # UNKNOWN: Exact fields not yet verified - placeholder based on JWT patterns
158
+ token: Optional[str] = Field(None, description="JWT token")
159
+ expires_at: Optional[datetime] = Field(None, description="Token expiration time")
160
+ # TODO: Verify exact fields from /login response
161
+
162
+
163
+ # ============================================================================
164
+ # Pagination Metadata (from API headers)
165
+ # ============================================================================
166
+
167
+ class PaginationInfo(VikunjaBaseModel):
168
+ """
169
+ Pagination metadata from response headers.
170
+
171
+ Vikunja returns pagination info in headers:
172
+ x-pagination-total-pages: <int>
173
+ x-pagination-result-count: <int>
174
+ """
175
+ total_pages: Optional[int] = Field(None, description="Total number of pages")
176
+ result_count: Optional[int] = Field(None, description="Number of items in current response")
177
+ # TODO: Add page/per_page if returned in body
178
+
179
+
180
+ # ============================================================================
181
+ # Generic Response Wrappers
182
+ # ============================================================================
183
+
184
+ class ListResponse(VikunjaBaseModel):
185
+ """
186
+ Generic wrapper for list responses with pagination.
187
+
188
+ Usage: ListResponse[Task] for task lists
189
+ """
190
+ success: bool = Field(True, description="Request succeeded")
191
+ items: list = Field(default_factory=list, description="List of items")
192
+ error: Optional[ErrorDetail] = Field(None, description="Error if request failed")
193
+ pagination: Optional[PaginationInfo] = Field(None, description="Pagination metadata")
@@ -0,0 +1,98 @@
1
+ """
2
+ Bulk Assignment Models
3
+
4
+ Models for bulk task assignment operations.
5
+ Based on Vikunja API v2.3.0 specification.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from datetime import datetime
11
+ from typing import Optional
12
+
13
+ from pydantic import BaseModel, ConfigDict, Field
14
+
15
+
16
+ class User(BaseModel):
17
+ """User representation for bulk assignments."""
18
+
19
+ model_config = ConfigDict(extra='ignore')
20
+
21
+ id: int = Field(..., description="The unique user ID")
22
+ username: str = Field(..., description="The username")
23
+ name: str = Field(..., description="The full name of the user")
24
+ email: Optional[str] = Field(None, description="The email address of the user")
25
+ created: datetime = Field(..., description="Account creation timestamp")
26
+ updated: datetime = Field(..., description="Last update timestamp")
27
+
28
+
29
+ class BulkAssignees(BaseModel):
30
+ """Request to bulk assign users to tasks.
31
+
32
+ Used when assigning multiple users to a single task or the same set of users
33
+ to multiple tasks.
34
+ """
35
+
36
+ model_config = ConfigDict(extra='ignore')
37
+
38
+ assignees: list[User] = Field(..., description="List of users to assign to the task(s)")
39
+
40
+
41
+ class BulkAssigneesCreateRequest(BaseModel):
42
+ """Request to create bulk assignments for a task."""
43
+
44
+ model_config = ConfigDict(extra='ignore')
45
+
46
+ assignees: list[int] = Field(
47
+ ...,
48
+ description="List of user IDs to assign to the task"
49
+ )
50
+
51
+
52
+ class BulkAssigneesResponse(BaseModel):
53
+ """Response from a bulk assignment operation."""
54
+
55
+ model_config = ConfigDict(extra='ignore')
56
+
57
+ assignees: list[User] = Field(..., description="List of assigned users")
58
+
59
+
60
+ class BulkTask(BaseModel):
61
+ """Bulk task operation request.
62
+
63
+ Used for updating multiple tasks with the same field values at once.
64
+ """
65
+
66
+ model_config = ConfigDict(extra='ignore')
67
+
68
+ task_ids: list[int] = Field(
69
+ ...,
70
+ description="List of task IDs to update"
71
+ )
72
+ fields: list[str] = Field(
73
+ ...,
74
+ description="List of field names to update (e.g., 'title', 'priority', 'done')"
75
+ )
76
+ values: dict = Field(
77
+ default_factory=dict,
78
+ description="Map of field names to values. Only fields in the 'fields' list will be updated."
79
+ )
80
+
81
+
82
+ class BulkTaskResponse(BaseModel):
83
+ """Response from a bulk task operation."""
84
+
85
+ model_config = ConfigDict(extra='ignore')
86
+
87
+ tasks: list[dict] = Field(
88
+ default_factory=list,
89
+ description="List of updated task objects"
90
+ )
91
+ success_count: int = Field(
92
+ 0,
93
+ description="Number of tasks successfully updated"
94
+ )
95
+ failure_count: int = Field(
96
+ 0,
97
+ description="Number of tasks that failed to update"
98
+ )
@@ -0,0 +1,134 @@
1
+ """
2
+ Filter Models for Vikunja API
3
+
4
+ Covers: models.SavedFilter and related types
5
+ API endpoints: /filters, /filters/{id}
6
+
7
+ Note: GET /filters returns 405 (Method Not Allowed) - use PUT to create filters.
8
+ Filters are typically managed through the UI or project views.
9
+ """
10
+
11
+ from pydantic import Field
12
+ from datetime import datetime
13
+ from typing import Optional, Any
14
+ from .base import VikunjaBaseModel, User
15
+
16
+
17
+ # ============================================================================
18
+ # Task Collection (filter definition)
19
+ # ============================================================================
20
+
21
+ class TaskCollection(VikunjaBaseModel):
22
+ """
23
+ Filter query definition for matching tasks.
24
+
25
+ Used in: SavedFilter.filters
26
+
27
+ Documentation: https://vikunja.io/docs/filters
28
+
29
+ Example filter syntax: "done = false && priority >= 3"
30
+ """
31
+ filter: Optional[str] = Field(None, description="Filter expression (e.g., 'done = false && priority >= 3')")
32
+ filter_include_nulls: bool = Field(False, description="Include null values in results")
33
+ order_by: Optional[list[str]] = Field(None, description="Order direction: ['asc'] or ['desc']")
34
+ s: Optional[str] = Field(None, description="Search term")
35
+ sort_by: Optional[list[str]] = Field(None, description="Fields to sort by (e.g., ['due_date', 'priority'])")
36
+
37
+
38
+ # ============================================================================
39
+ # Saved Filter Entity Model
40
+ # ============================================================================
41
+
42
+ class SavedFilter(VikunjaBaseModel):
43
+ """
44
+ Saved filter model representing a user-created filter preset.
45
+
46
+ API endpoint: PUT /filters (create), GET /filters/{id}, POST /filters/{id}, DELETE /filters/{id}
47
+
48
+ Fields from API spec (models.SavedFilter):
49
+ - id: Unique numeric ID
50
+ - title: Filter name (1-250 chars, required)
51
+ - description: Optional description
52
+ - filters: TaskCollection defining the filter logic
53
+ - is_favorite: Whether filter is favorited
54
+ - owner: User who owns this filter
55
+ - created: Creation timestamp (auto-set, read-only)
56
+ - updated: Last update timestamp (auto-set, read-only)
57
+ """
58
+
59
+ # Core Identification (2 fields)
60
+ id: Optional[int] = Field(None, description="Unique filter identifier (auto-assigned on create)")
61
+ title: str = Field(..., min_length=1, max_length=250, description="Filter name/title")
62
+
63
+ # Filter Definition (2 fields)
64
+ description: Optional[str] = Field(None, description="Filter description")
65
+ filters: Optional[TaskCollection] = Field(None, description="Filter logic definition")
66
+
67
+ # Status & Ownership (2 fields)
68
+ is_favorite: bool = Field(False, description="Marked as favorite")
69
+ owner: Optional[User] = Field(None, description="Filter owner")
70
+
71
+ # Timestamps (auto-managed by server) - 2 fields
72
+ created: Optional[datetime] = Field(None, description="Creation timestamp (read-only)")
73
+ updated: Optional[datetime] = Field(None, description="Last update timestamp (read-only)")
74
+
75
+
76
+ # ============================================================================
77
+ # Filter Request Models
78
+ # ============================================================================
79
+
80
+ class SavedFilterCreateRequest(VikunjaBaseModel):
81
+ """
82
+ Request body for creating a new saved filter.
83
+
84
+ Required fields: title, filters
85
+ Optional fields: description, is_favorite
86
+
87
+ API endpoint: PUT /filters
88
+ """
89
+ title: str = Field(..., min_length=1, max_length=250, description="Filter name (required)")
90
+ filters: TaskCollection = Field(..., description="Filter logic definition")
91
+
92
+ # Optional fields
93
+ description: Optional[str] = Field(None, description="Filter description")
94
+ is_favorite: bool = Field(False, description="Mark as favorite")
95
+
96
+ def model_dump_for_api(self) -> dict:
97
+ """Convert to dict, excluding None values."""
98
+ return self.model_dump(exclude_none=True)
99
+
100
+
101
+ class SavedFilterUpdateRequest(VikunjaBaseModel):
102
+ """
103
+ Request body for updating a saved filter.
104
+
105
+ All fields optional - only provided fields are updated.
106
+
107
+ API endpoint: POST /filters/{id}
108
+ """
109
+ title: Optional[str] = Field(None, min_length=1, max_length=250)
110
+ description: Optional[str] = None
111
+ filters: Optional[TaskCollection] = None
112
+ is_favorite: Optional[bool] = None
113
+
114
+ def model_dump_for_api(self) -> dict:
115
+ """Convert to dict, excluding None values."""
116
+ return self.model_dump(exclude_none=True)
117
+
118
+
119
+ # ============================================================================
120
+ # Filter List/Get Response Models
121
+ # ============================================================================
122
+
123
+ class SavedFilterListResponse(VikunjaBaseModel):
124
+ """Response for filter list operations."""
125
+ success: bool = Field(True, description="Request succeeded")
126
+ filters: list[SavedFilter] = Field(default_factory=list, description="List of saved filters")
127
+ error: Optional[Any] = Field(None, description="Error if request failed")
128
+
129
+
130
+ class SavedFilterGetResponse(VikunjaBaseModel):
131
+ """Response for getting a single filter."""
132
+ success: bool = Field(True, description="Request succeeded")
133
+ filter: Optional[SavedFilter] = Field(None, description="The saved filter")
134
+ error: Optional[Any] = Field(None, description="Error if request failed")
@@ -0,0 +1,230 @@
1
+ """
2
+ Label Models for Vikunja API
3
+
4
+ Covers: models.Label, LabelCreateRequest, LabelUpdateRequest, LabelListResponse
5
+ API endpoints: /labels (CRUD operations)
6
+
7
+ Labels are reusable tags that can be applied to tasks. Each label has:
8
+ - title (required): Display name shown on tasks
9
+ - hex_color: 7-character hex color code (#RRGGBB)
10
+ - description: Optional explanatory text
11
+ - created/updated: Auto-managed timestamps
12
+ - created_by: User who created the label
13
+
14
+ Usage Examples:
15
+ # Create a new label
16
+ label = LabelCreateRequest(
17
+ title="bug",
18
+ hex_color="#ff0000",
19
+ description="Bug reports and issues"
20
+ )
21
+
22
+ # Update an existing label
23
+ update = LabelUpdateRequest(
24
+ title="critical-bug", # Rename the label
25
+ hex_color="#ff0000" # Keep same color
26
+ )
27
+ """
28
+
29
+ from pydantic import Field, field_validator
30
+ from datetime import datetime
31
+ from typing import Optional
32
+ import re
33
+ from .base import VikunjaBaseModel, User
34
+
35
+
36
+ # ============================================================================
37
+ # Label Model (Full structure from API spec)
38
+ # ============================================================================
39
+
40
+ class Label(VikunjaBaseModel):
41
+ """
42
+ A reusable label/tag for categorizing tasks.
43
+
44
+ API endpoint: /labels
45
+
46
+ Labels are shared across projects and can be applied to multiple tasks.
47
+ Each label has a unique ID, title, color, and optional description.
48
+
49
+ Fields from API spec (models.Label):
50
+ - id: Unique identifier (auto-assigned)
51
+ - title: Display name (required, 1-250 chars)
52
+ - hex_color: Color in #RRGGBB format (max 7 chars)
53
+ - description: Optional explanatory text
54
+ - created: Auto-set creation timestamp
55
+ - updated: Auto-set update timestamp
56
+ - created_by: User who created the label
57
+
58
+ Example from API response:
59
+ {
60
+ "id": 1,
61
+ "title": "bug",
62
+ "hex_color": "#ff0000",
63
+ "description": "Bug reports and issues",
64
+ "created": "2024-01-15T10:30:00Z",
65
+ "updated": "2024-01-15T10:30:00Z",
66
+ "created_by": {...}
67
+ }
68
+ """
69
+
70
+ # Core Identification (2 fields)
71
+ id: Optional[int] = Field(None, description="Unique label ID (auto-assigned by server)")
72
+ title: str = Field(
73
+ ...,
74
+ min_length=1,
75
+ max_length=250,
76
+ description="Label display name shown on tasks"
77
+ )
78
+
79
+ # Visual Properties (2 fields)
80
+ hex_color: Optional[str] = Field(
81
+ None,
82
+ max_length=7,
83
+ description="Hex color code (#RRGGBB format)"
84
+ )
85
+ description: Optional[str] = Field(None, description="Optional label description")
86
+
87
+ # Metadata (3 fields - auto-managed by server)
88
+ created: Optional[datetime] = Field(None, description="Creation timestamp (read-only)")
89
+ updated: Optional[datetime] = Field(None, description="Last update timestamp (read-only)")
90
+ created_by: Optional[User] = Field(None, description="User who created this label")
91
+
92
+ @field_validator('hex_color')
93
+ @classmethod
94
+ def validate_hex_color(cls, v):
95
+ """Validate and normalize hex color format (#RRGGBB)."""
96
+ if v is None or v == "":
97
+ return None
98
+ # Add # if missing
99
+ if not v.startswith('#'):
100
+ v = f'#{v}'
101
+ if not re.match(r'^#[0-9A-Fa-f]{6}$', v):
102
+ raise ValueError(f"Invalid hex color format: {v}. Expected #RRGGBB or RRGGBB")
103
+ return v
104
+
105
+
106
+ # ============================================================================
107
+ # Label Create Request Model
108
+ # ============================================================================
109
+
110
+ class LabelCreateRequest(VikunjaBaseModel):
111
+ """
112
+ Request body for creating a new label.
113
+
114
+ Required fields: title (1-250 chars)
115
+ Optional fields: hex_color, description
116
+
117
+ API endpoint: POST /labels
118
+
119
+ Example:
120
+ req = LabelCreateRequest(
121
+ title="bug",
122
+ hex_color="#ff0000",
123
+ description="Bug reports and issues"
124
+ )
125
+
126
+ Validation:
127
+ - title: 1-250 characters, required
128
+ - hex_color: Must be valid #RRGGBB format if provided
129
+ - description: Any length if provided
130
+ """
131
+ title: str = Field(
132
+ ...,
133
+ min_length=1,
134
+ max_length=250,
135
+ description="Label display name (required)"
136
+ )
137
+ hex_color: Optional[str] = Field(None, max_length=7, description="Hex color (#RRGGBB)")
138
+ description: Optional[str] = Field(None, description="Optional label description")
139
+
140
+ @field_validator('hex_color')
141
+ @classmethod
142
+ def validate_hex_color(cls, v):
143
+ """Validate and normalize hex color format."""
144
+ if v is None or v == "":
145
+ return None
146
+ if not v.startswith('#'):
147
+ v = f'#{v}'
148
+ if not re.match(r'^#[0-9A-Fa-f]{6}$', v):
149
+ raise ValueError(f"Invalid hex color format: {v}. Expected #RRGGBB or RRGGBB")
150
+ return v
151
+
152
+ def model_dump_for_api(self) -> dict:
153
+ """Convert to dict for API submission."""
154
+ return self.model_dump(exclude_none=True)
155
+
156
+
157
+ # ============================================================================
158
+ # Label Update Request Model
159
+ # ============================================================================
160
+
161
+ class LabelUpdateRequest(VikunjaBaseModel):
162
+ """
163
+ Request body for updating an existing label.
164
+
165
+ All fields are optional - only provided fields are updated.
166
+
167
+ API endpoint: POST /labels/{id} (implied)
168
+
169
+ Example:
170
+ # Rename a label, keep color
171
+ req = LabelUpdateRequest(
172
+ title="critical-bug" # Only title changes
173
+ )
174
+
175
+ # Update color only
176
+ req = LabelUpdateRequest(
177
+ hex_color="#ff6600" # Only color changes
178
+ )
179
+ """
180
+ title: Optional[str] = Field(None, min_length=1, max_length=250, description="New label name")
181
+ hex_color: Optional[str] = Field(None, max_length=7, description="New hex color (#RRGGBB)")
182
+ description: Optional[str] = Field(None, description="New label description")
183
+
184
+ @field_validator('hex_color')
185
+ @classmethod
186
+ def validate_hex_color(cls, v):
187
+ """Validate and normalize hex color format."""
188
+ if v is None or v == "":
189
+ return None
190
+ if not v.startswith('#'):
191
+ v = f'#{v}'
192
+ if not re.match(r'^#[0-9A-Fa-f]{6}$', v):
193
+ raise ValueError(f"Invalid hex color format: {v}. Expected #RRGGBB or RRGGBB")
194
+ return v
195
+
196
+ def model_dump_for_api(self) -> dict:
197
+ """Convert to dict, excluding None values."""
198
+ return self.model_dump(exclude_none=True)
199
+
200
+
201
+ # ============================================================================
202
+ # Label Response Models
203
+ # ============================================================================
204
+
205
+ class LabelListResponse(VikunjaBaseModel):
206
+ """Response for listing labels."""
207
+ success: bool = Field(True, description="Request succeeded")
208
+ labels: list[Label] = Field(default_factory=list, description="List of labels")
209
+ error: Optional[str] = Field(None, description="Error message if failed")
210
+
211
+
212
+ class LabelGetResponse(VikunjaBaseModel):
213
+ """Response for getting a single label."""
214
+ success: bool = Field(True, description="Request succeeded")
215
+ label: Optional[Label] = Field(None, description="The requested label")
216
+ error: Optional[str] = Field(None, description="Error message if failed")
217
+
218
+
219
+ class LabelCreateResponse(VikunjaBaseModel):
220
+ """Response for creating a label."""
221
+ success: bool = Field(True, description="Request succeeded")
222
+ label: Optional[Label] = Field(None, description="The created label")
223
+ error: Optional[str] = Field(None, description="Error message if failed")
224
+
225
+
226
+ class LabelUpdateResponse(VikunjaBaseModel):
227
+ """Response for updating a label."""
228
+ success: bool = Field(True, description="Request succeeded")
229
+ label: Optional[Label] = Field(None, description="The updated label")
230
+ error: Optional[str] = Field(None, description="Error message if failed")