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.
- notionary/__init__.py +9 -5
- notionary/base_notion_client.py +19 -8
- notionary/blocks/__init__.py +2 -0
- notionary/blocks/document_element.py +194 -0
- notionary/blocks/registry/block_registry.py +27 -3
- notionary/database/__init__.py +4 -0
- notionary/database/database.py +481 -0
- notionary/database/{filter_builder.py → database_filter_builder.py} +27 -29
- notionary/database/{notion_database_provider.py → database_provider.py} +6 -10
- notionary/database/notion_database.py +73 -32
- notionary/file_upload/__init__.py +7 -0
- notionary/file_upload/client.py +254 -0
- notionary/file_upload/models.py +60 -0
- notionary/file_upload/notion_file_upload.py +387 -0
- notionary/page/notion_page.py +5 -6
- notionary/telemetry/__init__.py +19 -0
- notionary/telemetry/service.py +136 -0
- notionary/telemetry/views.py +73 -0
- notionary/user/__init__.py +11 -0
- notionary/user/base_notion_user.py +52 -0
- notionary/user/client.py +129 -0
- notionary/user/models.py +83 -0
- notionary/user/notion_bot_user.py +227 -0
- notionary/user/notion_user.py +256 -0
- notionary/user/notion_user_manager.py +173 -0
- notionary/user/notion_user_provider.py +1 -0
- notionary/util/__init__.py +5 -5
- notionary/util/{factory_decorator.py → factory_only.py} +9 -5
- notionary/util/fuzzy.py +74 -0
- notionary/util/logging_mixin.py +12 -12
- notionary/workspace.py +38 -2
- {notionary-0.2.15.dist-info → notionary-0.2.17.dist-info}/METADATA +3 -1
- {notionary-0.2.15.dist-info → notionary-0.2.17.dist-info}/RECORD +37 -20
- notionary/util/fuzzy_matcher.py +0 -82
- /notionary/database/{database_exceptions.py → exceptions.py} +0 -0
- /notionary/util/{singleton_decorator.py → singleton.py} +0 -0
- {notionary-0.2.15.dist-info → notionary-0.2.17.dist-info}/LICENSE +0 -0
- {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.
|
|
5
|
+
from notionary.database.exceptions import DatabaseNotFoundException
|
|
6
6
|
from notionary.models.notion_database_response import NotionDatabaseResponse
|
|
7
|
-
from notionary.util import LoggingMixin,
|
|
8
|
-
from notionary.util.
|
|
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 =
|
|
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,
|
|
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
|
|
12
|
+
from notionary import NotionPage
|
|
12
13
|
|
|
13
|
-
from notionary.database.
|
|
14
|
+
from notionary.database.database_provider import NotionDatabaseProvider
|
|
14
15
|
|
|
15
|
-
from notionary.database.
|
|
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) ->
|
|
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) ->
|
|
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:
|
|
258
|
+
page_results: list[NotionPage] = []
|
|
252
259
|
|
|
253
260
|
for page in search_results.results:
|
|
254
|
-
page = NotionPage.from_page_id(
|
|
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
|
|
276
|
+
Iterate through pages edited in the last N hours using DatabaseFilterBuilder.
|
|
264
277
|
"""
|
|
265
278
|
|
|
266
|
-
filter_builder =
|
|
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) ->
|
|
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
|
-
|
|
281
|
-
|
|
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.
|
|
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) ->
|
|
343
|
+
def create_filter(self) -> DatabaseFilterBuilder:
|
|
305
344
|
"""Create a new filter builder for this database."""
|
|
306
|
-
return
|
|
345
|
+
return DatabaseFilterBuilder()
|
|
307
346
|
|
|
308
347
|
async def iter_pages_with_filter(
|
|
309
|
-
self, filter_builder:
|
|
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(
|
|
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) ->
|
|
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,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
|