notionary 0.2.16__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 +18 -7
- notionary/blocks/__init__.py +2 -0
- notionary/blocks/document_element.py +194 -0
- 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} +4 -4
- notionary/database/notion_database.py +45 -18
- 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 +4 -3
- notionary/telemetry/views.py +15 -6
- 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 +3 -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.16.dist-info → notionary-0.2.17.dist-info}/METADATA +2 -1
- {notionary-0.2.16.dist-info → notionary-0.2.17.dist-info}/RECORD +34 -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.16.dist-info → notionary-0.2.17.dist-info}/LICENSE +0 -0
- {notionary-0.2.16.dist-info → notionary-0.2.17.dist-info}/WHEEL +0 -0
@@ -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,11 @@ 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
|
16
17
|
from notionary.telemetry import (
|
17
18
|
ProductTelemetry,
|
18
19
|
DatabaseFactoryUsedEvent,
|
@@ -27,7 +28,7 @@ class NotionDatabase(LoggingMixin):
|
|
27
28
|
Focused exclusively on creating basic pages and retrieving page managers
|
28
29
|
for further page operations.
|
29
30
|
"""
|
30
|
-
|
31
|
+
|
31
32
|
telemetry = ProductTelemetry()
|
32
33
|
|
33
34
|
@factory_only("from_database_id", "from_database_name")
|
@@ -213,7 +214,7 @@ class NotionDatabase(LoggingMixin):
|
|
213
214
|
self.logger.error(f"Error updating database external icon: {str(e)}")
|
214
215
|
return None
|
215
216
|
|
216
|
-
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]:
|
217
218
|
"""
|
218
219
|
Retrieve all option names for a select, multi_select, status, or relation property.
|
219
220
|
|
@@ -244,7 +245,7 @@ class NotionDatabase(LoggingMixin):
|
|
244
245
|
property_schema = self.properties.get(property_name)
|
245
246
|
return property_schema.get("type") if property_schema else None
|
246
247
|
|
247
|
-
async def query_database_by_title(self, page_title: str) ->
|
248
|
+
async def query_database_by_title(self, page_title: str) -> list[NotionPage]:
|
248
249
|
"""
|
249
250
|
Query the database for pages with a specific title.
|
250
251
|
"""
|
@@ -254,7 +255,7 @@ class NotionDatabase(LoggingMixin):
|
|
254
255
|
)
|
255
256
|
)
|
256
257
|
|
257
|
-
page_results:
|
258
|
+
page_results: list[NotionPage] = []
|
258
259
|
|
259
260
|
for page in search_results.results:
|
260
261
|
page = await NotionPage.from_page_id(
|
@@ -272,10 +273,10 @@ class NotionDatabase(LoggingMixin):
|
|
272
273
|
self, hours: int = 24, page_size: int = 100
|
273
274
|
) -> AsyncGenerator[NotionPage, None]:
|
274
275
|
"""
|
275
|
-
Iterate through pages edited in the last N hours using
|
276
|
+
Iterate through pages edited in the last N hours using DatabaseFilterBuilder.
|
276
277
|
"""
|
277
278
|
|
278
|
-
filter_builder =
|
279
|
+
filter_builder = DatabaseFilterBuilder()
|
279
280
|
filter_builder.with_updated_last_n_hours(hours).with_page_size(page_size)
|
280
281
|
filter_conditions = filter_builder.build()
|
281
282
|
|
@@ -284,13 +285,39 @@ class NotionDatabase(LoggingMixin):
|
|
284
285
|
):
|
285
286
|
yield page
|
286
287
|
|
287
|
-
async def get_all_pages(self) ->
|
288
|
+
async def get_all_pages(self) -> list[NotionPage]:
|
288
289
|
"""
|
289
290
|
Get all pages in the database (use with caution for large databases).
|
291
|
+
Uses asyncio.gather to parallelize NotionPage creation per API page.
|
290
292
|
"""
|
291
|
-
pages = []
|
292
|
-
|
293
|
-
|
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
|
+
|
294
321
|
return pages
|
295
322
|
|
296
323
|
async def get_last_edited_time(self) -> Optional[str]:
|
@@ -313,12 +340,12 @@ class NotionDatabase(LoggingMixin):
|
|
313
340
|
)
|
314
341
|
return None
|
315
342
|
|
316
|
-
def create_filter(self) ->
|
343
|
+
def create_filter(self) -> DatabaseFilterBuilder:
|
317
344
|
"""Create a new filter builder for this database."""
|
318
|
-
return
|
345
|
+
return DatabaseFilterBuilder()
|
319
346
|
|
320
347
|
async def iter_pages_with_filter(
|
321
|
-
self, filter_builder:
|
348
|
+
self, filter_builder: DatabaseFilterBuilder, page_size: int = 100
|
322
349
|
):
|
323
350
|
"""Iterate pages using a filter builder."""
|
324
351
|
filter_config = filter_builder.build()
|
@@ -440,7 +467,7 @@ class NotionDatabase(LoggingMixin):
|
|
440
467
|
except (KeyError, TypeError, AttributeError):
|
441
468
|
return None
|
442
469
|
|
443
|
-
async def _get_relation_options(self, property_name: str) ->
|
470
|
+
async def _get_relation_options(self, property_name: str) -> list[Dict[str, Any]]:
|
444
471
|
"""
|
445
472
|
Retrieve the titles of all pages related to a relation property.
|
446
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
|