notionary 0.2.15__py3-none-any.whl → 0.2.17__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 (38) hide show
  1. notionary/__init__.py +9 -5
  2. notionary/base_notion_client.py +19 -8
  3. notionary/blocks/__init__.py +2 -0
  4. notionary/blocks/document_element.py +194 -0
  5. notionary/blocks/registry/block_registry.py +27 -3
  6. notionary/database/__init__.py +4 -0
  7. notionary/database/database.py +481 -0
  8. notionary/database/{filter_builder.py → database_filter_builder.py} +27 -29
  9. notionary/database/{notion_database_provider.py → database_provider.py} +6 -10
  10. notionary/database/notion_database.py +73 -32
  11. notionary/file_upload/__init__.py +7 -0
  12. notionary/file_upload/client.py +254 -0
  13. notionary/file_upload/models.py +60 -0
  14. notionary/file_upload/notion_file_upload.py +387 -0
  15. notionary/page/notion_page.py +5 -6
  16. notionary/telemetry/__init__.py +19 -0
  17. notionary/telemetry/service.py +136 -0
  18. notionary/telemetry/views.py +73 -0
  19. notionary/user/__init__.py +11 -0
  20. notionary/user/base_notion_user.py +52 -0
  21. notionary/user/client.py +129 -0
  22. notionary/user/models.py +83 -0
  23. notionary/user/notion_bot_user.py +227 -0
  24. notionary/user/notion_user.py +256 -0
  25. notionary/user/notion_user_manager.py +173 -0
  26. notionary/user/notion_user_provider.py +1 -0
  27. notionary/util/__init__.py +5 -5
  28. notionary/util/{factory_decorator.py → factory_only.py} +9 -5
  29. notionary/util/fuzzy.py +74 -0
  30. notionary/util/logging_mixin.py +12 -12
  31. notionary/workspace.py +38 -2
  32. {notionary-0.2.15.dist-info → notionary-0.2.17.dist-info}/METADATA +3 -1
  33. {notionary-0.2.15.dist-info → notionary-0.2.17.dist-info}/RECORD +37 -20
  34. notionary/util/fuzzy_matcher.py +0 -82
  35. /notionary/database/{database_exceptions.py → exceptions.py} +0 -0
  36. /notionary/util/{singleton_decorator.py → singleton.py} +0 -0
  37. {notionary-0.2.15.dist-info → notionary-0.2.17.dist-info}/LICENSE +0 -0
  38. {notionary-0.2.15.dist-info → notionary-0.2.17.dist-info}/WHEEL +0 -0
@@ -2,10 +2,10 @@ from __future__ import annotations
2
2
 
3
3
  from typing import Dict, Optional, TYPE_CHECKING
4
4
  from notionary.database.client import NotionDatabaseClient
5
- from notionary.database.database_exceptions import DatabaseNotFoundException
5
+ from notionary.database.exceptions import DatabaseNotFoundException
6
6
  from notionary.models.notion_database_response import NotionDatabaseResponse
7
- from notionary.util import LoggingMixin, FuzzyMatcher, format_uuid
8
- from notionary.util.singleton_metaclass import SingletonMetaClass
7
+ from notionary.util import LoggingMixin, format_uuid, SingletonMetaClass
8
+ from notionary.util.fuzzy import find_best_match
9
9
 
10
10
  if TYPE_CHECKING:
11
11
  from notionary import NotionDatabase
@@ -57,18 +57,14 @@ class NotionDatabaseProvider(LoggingMixin, metaclass=SingletonMetaClass):
57
57
 
58
58
  id_cache_key = self._create_id_cache_key(database.id)
59
59
  if not force_refresh and id_cache_key in self._database_cache:
60
- self.logger.debug(
61
- f"Found existing cached database by ID: {database.id}"
62
- )
60
+ self.logger.debug(f"Found existing cached database by ID: {database.id}")
63
61
  existing_database = self._database_cache[id_cache_key]
64
62
 
65
63
  self._database_cache[name_cache_key] = existing_database
66
64
  return existing_database
67
65
 
68
66
  self._cache_database(database, token, database_name)
69
- self.logger.debug(
70
- f"Cached database: {database.title} (ID: {database.id})"
71
- )
67
+ self.logger.debug(f"Cached database: {database.title} (ID: {database.id})")
72
68
 
73
69
  return database
74
70
 
@@ -129,7 +125,7 @@ class NotionDatabaseProvider(LoggingMixin, metaclass=SingletonMetaClass):
129
125
  self.logger.warning("No databases found for name: %s", database_name)
130
126
  raise DatabaseNotFoundException(database_name)
131
127
 
132
- best_match = FuzzyMatcher.find_best_match(
128
+ best_match = find_best_match(
133
129
  query=database_name,
134
130
  items=search_result.results,
135
131
  text_extractor=lambda db: self._extract_title(db),
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
+ import asyncio
2
3
  import random
3
- from typing import Any, AsyncGenerator, Dict, List, Optional
4
+ from typing import Any, AsyncGenerator, Dict, Optional
4
5
 
5
6
  from notionary.database.client import NotionDatabaseClient
6
7
  from notionary.models.notion_database_response import (
@@ -8,11 +9,16 @@ from notionary.models.notion_database_response import (
8
9
  NotionPageResponse,
9
10
  NotionQueryDatabaseResponse,
10
11
  )
11
- from notionary.page.notion_page import NotionPage
12
+ from notionary import NotionPage
12
13
 
13
- from notionary.database.notion_database_provider import NotionDatabaseProvider
14
+ from notionary.database.database_provider import NotionDatabaseProvider
14
15
 
15
- from notionary.database.filter_builder import FilterBuilder
16
+ from notionary.database.database_filter_builder import DatabaseFilterBuilder
17
+ from notionary.telemetry import (
18
+ ProductTelemetry,
19
+ DatabaseFactoryUsedEvent,
20
+ QueryOperationEvent,
21
+ )
16
22
  from notionary.util import factory_only, LoggingMixin
17
23
 
18
24
 
@@ -23,6 +29,8 @@ class NotionDatabase(LoggingMixin):
23
29
  for further page operations.
24
30
  """
25
31
 
32
+ telemetry = ProductTelemetry()
33
+
26
34
  @factory_only("from_database_id", "from_database_name")
27
35
  def __init__(
28
36
  self,
@@ -52,6 +60,10 @@ class NotionDatabase(LoggingMixin):
52
60
  Create a NotionDatabase from a database ID using NotionDatabaseProvider.
53
61
  """
54
62
  provider = cls.get_database_provider()
63
+ cls.telemetry.capture(
64
+ DatabaseFactoryUsedEvent(factory_method="from_database_id")
65
+ )
66
+
55
67
  return await provider.get_database_by_id(id, token)
56
68
 
57
69
  @classmethod
@@ -65,6 +77,9 @@ class NotionDatabase(LoggingMixin):
65
77
  Create a NotionDatabase by finding a database with fuzzy matching on the title using NotionDatabaseProvider.
66
78
  """
67
79
  provider = cls.get_database_provider()
80
+ cls.telemetry.capture(
81
+ DatabaseFactoryUsedEvent(factory_method="from_database_name")
82
+ )
68
83
  return await provider.get_database_by_name(database_name, token, min_similarity)
69
84
 
70
85
  @property
@@ -129,9 +144,7 @@ class NotionDatabase(LoggingMixin):
129
144
 
130
145
  self._title = result.title[0].plain_text
131
146
  self.logger.info(f"Successfully updated database title to: {new_title}")
132
- self.database_provider.invalidate_database_cache(
133
- database_id=self.id
134
- )
147
+ self.database_provider.invalidate_database_cache(database_id=self.id)
135
148
  return True
136
149
 
137
150
  except Exception as e:
@@ -149,9 +162,7 @@ class NotionDatabase(LoggingMixin):
149
162
 
150
163
  self._emoji_icon = result.icon.emoji if result.icon else None
151
164
  self.logger.info(f"Successfully updated database emoji to: {new_emoji}")
152
- self.database_provider.invalidate_database_cache(
153
- database_id=self.id
154
- )
165
+ self.database_provider.invalidate_database_cache(database_id=self.id)
155
166
  return True
156
167
 
157
168
  except Exception as e:
@@ -168,9 +179,7 @@ class NotionDatabase(LoggingMixin):
168
179
  )
169
180
 
170
181
  if result.cover and result.cover.external:
171
- self.database_provider.invalidate_database_cache(
172
- database_id=self.id
173
- )
182
+ self.database_provider.invalidate_database_cache(database_id=self.id)
174
183
  return result.cover.external.url
175
184
  return None
176
185
 
@@ -197,9 +206,7 @@ class NotionDatabase(LoggingMixin):
197
206
  )
198
207
 
199
208
  if result.icon and result.icon.external:
200
- self.database_provider.invalidate_database_cache(
201
- database_id=self.id
202
- )
209
+ self.database_provider.invalidate_database_cache(database_id=self.id)
203
210
  return result.icon.external.url
204
211
  return None
205
212
 
@@ -207,7 +214,7 @@ class NotionDatabase(LoggingMixin):
207
214
  self.logger.error(f"Error updating database external icon: {str(e)}")
208
215
  return None
209
216
 
210
- async def get_options_by_property_name(self, property_name: str) -> List[str]:
217
+ async def get_options_by_property_name(self, property_name: str) -> list[str]:
211
218
  """
212
219
  Retrieve all option names for a select, multi_select, status, or relation property.
213
220
 
@@ -238,7 +245,7 @@ class NotionDatabase(LoggingMixin):
238
245
  property_schema = self.properties.get(property_name)
239
246
  return property_schema.get("type") if property_schema else None
240
247
 
241
- async def query_database_by_title(self, page_title: str) -> List[NotionPage]:
248
+ async def query_database_by_title(self, page_title: str) -> list[NotionPage]:
242
249
  """
243
250
  Query the database for pages with a specific title.
244
251
  """
@@ -248,22 +255,28 @@ class NotionDatabase(LoggingMixin):
248
255
  )
249
256
  )
250
257
 
251
- page_results: List[NotionPage] = []
258
+ page_results: list[NotionPage] = []
252
259
 
253
260
  for page in search_results.results:
254
- page = NotionPage.from_page_id(page_id=page.id, token=self.client.token)
261
+ page = await NotionPage.from_page_id(
262
+ page_id=page.id, token=self.client.token
263
+ )
255
264
  page_results.append(page)
256
265
 
266
+ self.telemetry.capture(
267
+ QueryOperationEvent(query_type="query_database_by_title")
268
+ )
269
+
257
270
  return page_results
258
271
 
259
272
  async def iter_pages_updated_within(
260
273
  self, hours: int = 24, page_size: int = 100
261
274
  ) -> AsyncGenerator[NotionPage, None]:
262
275
  """
263
- Iterate through pages edited in the last N hours using FilterBuilder.
276
+ Iterate through pages edited in the last N hours using DatabaseFilterBuilder.
264
277
  """
265
278
 
266
- filter_builder = FilterBuilder()
279
+ filter_builder = DatabaseFilterBuilder()
267
280
  filter_builder.with_updated_last_n_hours(hours).with_page_size(page_size)
268
281
  filter_conditions = filter_builder.build()
269
282
 
@@ -272,13 +285,39 @@ class NotionDatabase(LoggingMixin):
272
285
  ):
273
286
  yield page
274
287
 
275
- async def get_all_pages(self) -> List[NotionPage]:
288
+ async def get_all_pages(self) -> list[NotionPage]:
276
289
  """
277
290
  Get all pages in the database (use with caution for large databases).
291
+ Uses asyncio.gather to parallelize NotionPage creation per API page.
278
292
  """
279
- pages = []
280
- async for page in self._iter_pages():
281
- pages.append(page)
293
+ pages: list[NotionPage] = []
294
+ start_cursor: Optional[str] = None
295
+ has_more = True
296
+ body: Dict[str, Any] = {"page_size": 100}
297
+
298
+ while has_more:
299
+ current_body = body.copy()
300
+ if start_cursor:
301
+ current_body["start_cursor"] = start_cursor
302
+
303
+ result = await self.client.query_database(
304
+ database_id=self.id, query_data=current_body
305
+ )
306
+
307
+ if not result or not result.results:
308
+ break
309
+
310
+ # Parallelize NotionPage creation for this batch
311
+ page_tasks = [
312
+ NotionPage.from_page_id(page_id=page.id, token=self.client.token)
313
+ for page in result.results
314
+ ]
315
+ batch_pages = await asyncio.gather(*page_tasks)
316
+ pages.extend(batch_pages)
317
+
318
+ has_more = result.has_more
319
+ start_cursor = result.next_cursor if has_more else None
320
+
282
321
  return pages
283
322
 
284
323
  async def get_last_edited_time(self) -> Optional[str]:
@@ -289,7 +328,7 @@ class NotionDatabase(LoggingMixin):
289
328
  ISO 8601 timestamp string of the last database edit, or None if request fails.
290
329
  """
291
330
  try:
292
- db = await self.client.get_database(self.database_id)
331
+ db = await self.client.get_database(self.id)
293
332
 
294
333
  return db.last_edited_time
295
334
 
@@ -301,12 +340,12 @@ class NotionDatabase(LoggingMixin):
301
340
  )
302
341
  return None
303
342
 
304
- def create_filter(self) -> FilterBuilder:
343
+ def create_filter(self) -> DatabaseFilterBuilder:
305
344
  """Create a new filter builder for this database."""
306
- return FilterBuilder()
345
+ return DatabaseFilterBuilder()
307
346
 
308
347
  async def iter_pages_with_filter(
309
- self, filter_builder: FilterBuilder, page_size: int = 100
348
+ self, filter_builder: DatabaseFilterBuilder, page_size: int = 100
310
349
  ):
311
350
  """Iterate pages using a filter builder."""
312
351
  filter_config = filter_builder.build()
@@ -352,7 +391,9 @@ class NotionDatabase(LoggingMixin):
352
391
  return
353
392
 
354
393
  for page in result.results:
355
- yield await NotionPage.from_page_id(page_id=page.id, token=self.client.token)
394
+ yield await NotionPage.from_page_id(
395
+ page_id=page.id, token=self.client.token
396
+ )
356
397
 
357
398
  has_more = result.has_more
358
399
  start_cursor = result.next_cursor if has_more else None
@@ -426,7 +467,7 @@ class NotionDatabase(LoggingMixin):
426
467
  except (KeyError, TypeError, AttributeError):
427
468
  return None
428
469
 
429
- async def _get_relation_options(self, property_name: str) -> List[Dict[str, Any]]:
470
+ async def _get_relation_options(self, property_name: str) -> list[Dict[str, Any]]:
430
471
  """
431
472
  Retrieve the titles of all pages related to a relation property.
432
473
 
@@ -0,0 +1,7 @@
1
+ from .client import NotionFileUploadClient
2
+ from .notion_file_upload import NotionFileUpload
3
+
4
+ __all__ = [
5
+ "NotionFileUploadClient",
6
+ "NotionFileUpload",
7
+ ]
@@ -0,0 +1,254 @@
1
+ from typing import Optional, BinaryIO
2
+ from io import BytesIO
3
+ from pathlib import Path
4
+ import httpx
5
+
6
+ import aiofiles
7
+
8
+ from notionary.base_notion_client import BaseNotionClient
9
+ from notionary.file_upload.models import (
10
+ FileUploadResponse,
11
+ FileUploadListResponse,
12
+ FileUploadCreateRequest,
13
+ FileUploadCompleteRequest,
14
+ )
15
+ from notionary.util import singleton
16
+
17
+
18
+ @singleton
19
+ class NotionFileUploadClient(BaseNotionClient):
20
+ """
21
+ Client for Notion file upload operations.
22
+ Inherits base HTTP functionality from BaseNotionClient.
23
+ """
24
+
25
+ async def create_file_upload(
26
+ self,
27
+ filename: str,
28
+ content_type: Optional[str] = None,
29
+ content_length: Optional[int] = None,
30
+ mode: str = "single_part",
31
+ ) -> Optional[FileUploadResponse]:
32
+ """
33
+ Create a new file upload.
34
+
35
+ Args:
36
+ filename: Name of the file (max 900 bytes)
37
+ content_type: MIME type of the file
38
+ content_length: Size of the file in bytes
39
+ mode: Upload mode ("single_part" or "multi_part")
40
+
41
+ Returns:
42
+ FileUploadResponse or None if failed
43
+ """
44
+ request_data = FileUploadCreateRequest(
45
+ filename=filename,
46
+ content_type=content_type,
47
+ content_length=content_length,
48
+ mode=mode,
49
+ )
50
+
51
+ response = await self.post("file_uploads", data=request_data.model_dump())
52
+ if response is None:
53
+ return None
54
+
55
+ try:
56
+ return FileUploadResponse.model_validate(response)
57
+ except Exception as e:
58
+ self.logger.error("Failed to validate file upload response: %s", e)
59
+ return None
60
+
61
+ async def send_file_upload(
62
+ self,
63
+ file_upload_id: str,
64
+ file_content: BinaryIO,
65
+ filename: Optional[str] = None,
66
+ part_number: Optional[int] = None,
67
+ ) -> bool:
68
+ """
69
+ Send file content to Notion.
70
+
71
+ Args:
72
+ file_upload_id: ID of the file upload
73
+ file_content: Binary file content
74
+ filename: Optional filename for the form data
75
+ part_number: Part number for multi-part uploads
76
+
77
+ Returns:
78
+ True if successful, False otherwise
79
+ """
80
+ await self.ensure_initialized()
81
+
82
+ if not self.client:
83
+ self.logger.error("HTTP client not initialized")
84
+ return False
85
+
86
+ url = f"{self.BASE_URL}/file_uploads/{file_upload_id}/send"
87
+
88
+ try:
89
+ # Read all content from BinaryIO into bytes
90
+ if hasattr(file_content, "read"):
91
+ file_bytes = file_content.read()
92
+ # Reset position if possible (for BytesIO objects)
93
+ if hasattr(file_content, "seek"):
94
+ file_content.seek(0)
95
+ else:
96
+ file_bytes = file_content
97
+
98
+ # Prepare files dict for multipart upload
99
+ files = {"file": (filename or "file", file_bytes)}
100
+
101
+ # Prepare form data (only for multi-part uploads)
102
+ data = {}
103
+ if part_number is not None:
104
+ data["part_number"] = str(part_number)
105
+
106
+ # Create a new client instance specifically for this upload
107
+ # This avoids issues with the base client's default JSON headers
108
+ upload_headers = {
109
+ "Authorization": f"Bearer {self.token}",
110
+ "Notion-Version": self.NOTION_VERSION,
111
+ # Explicitly do NOT set Content-Type - let httpx handle multipart
112
+ }
113
+
114
+ self.logger.debug(
115
+ "Sending file upload to %s with filename %s", url, filename
116
+ )
117
+
118
+ # Use a temporary client for the multipart upload
119
+ async with httpx.AsyncClient(timeout=self.timeout) as upload_client:
120
+ response = await upload_client.post(
121
+ url,
122
+ files=files,
123
+ data=data if data else None,
124
+ headers=upload_headers,
125
+ )
126
+
127
+ response.raise_for_status()
128
+ self.logger.debug("File upload sent successfully: %s", file_upload_id)
129
+ return True
130
+
131
+ except httpx.HTTPStatusError as e:
132
+ try:
133
+ error_text = e.response.text
134
+ except:
135
+ error_text = "Unable to read error response"
136
+ error_msg = f"HTTP {e.response.status_code}: {error_text}"
137
+ self.logger.error("Send file upload failed (%s): %s", url, error_msg)
138
+ return False
139
+
140
+ except httpx.RequestError as e:
141
+ self.logger.error("Request error sending file upload (%s): %s", url, str(e))
142
+ return False
143
+
144
+ except Exception as e:
145
+ self.logger.error("Unexpected error in send_file_upload: %s", str(e))
146
+ import traceback
147
+
148
+ self.logger.debug("Full traceback: %s", traceback.format_exc())
149
+ return False
150
+
151
+ async def complete_file_upload(
152
+ self, file_upload_id: str
153
+ ) -> Optional[FileUploadResponse]:
154
+ """
155
+ Complete a multi-part file upload.
156
+
157
+ Args:
158
+ file_upload_id: ID of the file upload
159
+
160
+ Returns:
161
+ FileUploadResponse or None if failed
162
+ """
163
+ request_data = FileUploadCompleteRequest()
164
+
165
+ response = await self.post(
166
+ f"file_uploads/{file_upload_id}/complete", data=request_data.model_dump()
167
+ )
168
+ if response is None:
169
+ return None
170
+
171
+ try:
172
+ return FileUploadResponse.model_validate(response)
173
+ except Exception as e:
174
+ self.logger.error("Failed to validate complete file upload response: %s", e)
175
+ return None
176
+
177
+ async def retrieve_file_upload(
178
+ self, file_upload_id: str
179
+ ) -> Optional[FileUploadResponse]:
180
+ """
181
+ Retrieve details of a file upload.
182
+
183
+ Args:
184
+ file_upload_id: ID of the file upload
185
+
186
+ Returns:
187
+ FileUploadResponse or None if failed
188
+ """
189
+ response = await self.get(f"file_uploads/{file_upload_id}")
190
+ if response is None:
191
+ return None
192
+
193
+ try:
194
+ return FileUploadResponse.model_validate(response)
195
+ except Exception as e:
196
+ self.logger.error("Failed to validate retrieve file upload response: %s", e)
197
+ return None
198
+
199
+ async def list_file_uploads(
200
+ self, page_size: int = 100, start_cursor: Optional[str] = None
201
+ ) -> Optional[FileUploadListResponse]:
202
+ """
203
+ List file uploads for the current bot integration.
204
+
205
+ Args:
206
+ page_size: Number of uploads per page (max 100)
207
+ start_cursor: Cursor for pagination
208
+
209
+ Returns:
210
+ FileUploadListResponse or None if failed
211
+ """
212
+ params = {"page_size": min(page_size, 100)}
213
+ if start_cursor:
214
+ params["start_cursor"] = start_cursor
215
+
216
+ response = await self.get("file_uploads", params=params)
217
+ if response is None:
218
+ return None
219
+
220
+ try:
221
+ return FileUploadListResponse.model_validate(response)
222
+ except Exception as e:
223
+ self.logger.error("Failed to validate list file uploads response: %s", e)
224
+ return None
225
+
226
+ async def send_file_from_path(
227
+ self, file_upload_id: str, file_path: Path, part_number: Optional[int] = None
228
+ ) -> bool:
229
+ """
230
+ Convenience method to send file from file path.
231
+
232
+ Args:
233
+ file_upload_id: ID of the file upload
234
+ file_path: Path to the file
235
+ part_number: Part number for multi-part uploads
236
+
237
+ Returns:
238
+ True if successful, False otherwise
239
+ """
240
+ try:
241
+ # Read file content into memory first using aiofiles
242
+ async with aiofiles.open(file_path, "rb") as f:
243
+ file_content = await f.read()
244
+
245
+ # Use BytesIO for the upload
246
+ return await self.send_file_upload(
247
+ file_upload_id=file_upload_id,
248
+ file_content=BytesIO(file_content),
249
+ filename=file_path.name,
250
+ part_number=part_number,
251
+ )
252
+ except Exception as e:
253
+ self.logger.error("Failed to send file from path %s: %s", file_path, e)
254
+ return False
@@ -0,0 +1,60 @@
1
+ from typing import Literal, Optional, List
2
+ from pydantic import BaseModel
3
+
4
+
5
+ class FileUploadResponse(BaseModel):
6
+ """
7
+ Represents a Notion file upload object as returned by the File Upload API.
8
+ """
9
+
10
+ object: Literal["file_upload"]
11
+ id: str
12
+ created_time: str
13
+ last_edited_time: str
14
+ expiry_time: str
15
+ upload_url: str
16
+ archived: bool
17
+ status: str # "pending", "uploaded", "failed", etc.
18
+ filename: Optional[str] = None
19
+ content_type: Optional[str] = None
20
+ content_length: Optional[int] = None
21
+ request_id: str
22
+
23
+
24
+ class FileUploadListResponse(BaseModel):
25
+ """
26
+ Response model for listing file uploads from /v1/file_uploads endpoint.
27
+ """
28
+
29
+ object: Literal["list"]
30
+ results: List[FileUploadResponse]
31
+ next_cursor: Optional[str] = None
32
+ has_more: bool
33
+ type: Literal["file_upload"]
34
+ file_upload: dict = {}
35
+ request_id: str
36
+
37
+
38
+ class FileUploadCreateRequest(BaseModel):
39
+ """
40
+ Request model for creating a file upload.
41
+ """
42
+
43
+ filename: str
44
+ content_type: Optional[str] = None
45
+ content_length: Optional[int] = None
46
+ mode: Literal["single_part", "multi_part"] = "single_part"
47
+
48
+ def model_dump(self, **kwargs):
49
+ """Override to exclude None values"""
50
+ data = super().model_dump(**kwargs)
51
+ return {k: v for k, v in data.items() if v is not None}
52
+
53
+
54
+ class FileUploadCompleteRequest(BaseModel):
55
+ """
56
+ Request model for completing a multi-part file upload.
57
+ """
58
+
59
+ # Usually empty for complete requests, but keeping for future extensibility
60
+ pass