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.
Files changed (35) hide show
  1. notionary/__init__.py +9 -5
  2. notionary/base_notion_client.py +18 -7
  3. notionary/blocks/__init__.py +2 -0
  4. notionary/blocks/document_element.py +194 -0
  5. notionary/database/__init__.py +4 -0
  6. notionary/database/database.py +481 -0
  7. notionary/database/{filter_builder.py → database_filter_builder.py} +27 -29
  8. notionary/database/{notion_database_provider.py → database_provider.py} +4 -4
  9. notionary/database/notion_database.py +45 -18
  10. notionary/file_upload/__init__.py +7 -0
  11. notionary/file_upload/client.py +254 -0
  12. notionary/file_upload/models.py +60 -0
  13. notionary/file_upload/notion_file_upload.py +387 -0
  14. notionary/page/notion_page.py +4 -3
  15. notionary/telemetry/views.py +15 -6
  16. notionary/user/__init__.py +11 -0
  17. notionary/user/base_notion_user.py +52 -0
  18. notionary/user/client.py +129 -0
  19. notionary/user/models.py +83 -0
  20. notionary/user/notion_bot_user.py +227 -0
  21. notionary/user/notion_user.py +256 -0
  22. notionary/user/notion_user_manager.py +173 -0
  23. notionary/user/notion_user_provider.py +1 -0
  24. notionary/util/__init__.py +3 -5
  25. notionary/util/{factory_decorator.py → factory_only.py} +9 -5
  26. notionary/util/fuzzy.py +74 -0
  27. notionary/util/logging_mixin.py +12 -12
  28. notionary/workspace.py +38 -2
  29. {notionary-0.2.16.dist-info → notionary-0.2.17.dist-info}/METADATA +2 -1
  30. {notionary-0.2.16.dist-info → notionary-0.2.17.dist-info}/RECORD +34 -20
  31. notionary/util/fuzzy_matcher.py +0 -82
  32. /notionary/database/{database_exceptions.py → exceptions.py} +0 -0
  33. /notionary/util/{singleton_decorator.py → singleton.py} +0 -0
  34. {notionary-0.2.16.dist-info → notionary-0.2.17.dist-info}/LICENSE +0 -0
  35. {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, 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,11 @@ 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
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) -> List[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) -> List[NotionPage]:
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: List[NotionPage] = []
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 FilterBuilder.
276
+ Iterate through pages edited in the last N hours using DatabaseFilterBuilder.
276
277
  """
277
278
 
278
- filter_builder = FilterBuilder()
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) -> List[NotionPage]:
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
- async for page in self._iter_pages():
293
- 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
+
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) -> FilterBuilder:
343
+ def create_filter(self) -> DatabaseFilterBuilder:
317
344
  """Create a new filter builder for this database."""
318
- return FilterBuilder()
345
+ return DatabaseFilterBuilder()
319
346
 
320
347
  async def iter_pages_with_filter(
321
- self, filter_builder: FilterBuilder, page_size: int = 100
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) -> List[Dict[str, Any]]:
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,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