notionary 0.3.1__py3-none-any.whl → 0.4.1__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 (201) hide show
  1. notionary/__init__.py +49 -1
  2. notionary/blocks/client.py +37 -11
  3. notionary/blocks/enums.py +0 -6
  4. notionary/blocks/rich_text/markdown_rich_text_converter.py +49 -15
  5. notionary/blocks/rich_text/models.py +13 -4
  6. notionary/blocks/rich_text/name_id_resolver/data_source.py +9 -3
  7. notionary/blocks/rich_text/name_id_resolver/person.py +6 -2
  8. notionary/blocks/rich_text/rich_text_markdown_converter.py +10 -3
  9. notionary/blocks/schemas.py +33 -78
  10. notionary/comments/client.py +19 -6
  11. notionary/comments/factory.py +10 -3
  12. notionary/comments/schemas.py +10 -31
  13. notionary/comments/service.py +12 -4
  14. notionary/data_source/http/data_source_instance_client.py +59 -17
  15. notionary/data_source/properties/schemas.py +156 -115
  16. notionary/data_source/query/builder.py +67 -18
  17. notionary/data_source/query/resolver.py +16 -5
  18. notionary/data_source/query/schema.py +24 -6
  19. notionary/data_source/query/validator.py +18 -6
  20. notionary/data_source/schema/registry.py +31 -12
  21. notionary/data_source/schema/service.py +66 -20
  22. notionary/data_source/schemas.py +2 -2
  23. notionary/data_source/service.py +103 -43
  24. notionary/database/client.py +27 -9
  25. notionary/database/database_metadata_update_client.py +12 -4
  26. notionary/database/schemas.py +2 -2
  27. notionary/database/service.py +14 -9
  28. notionary/exceptions/__init__.py +20 -4
  29. notionary/exceptions/api.py +2 -2
  30. notionary/exceptions/base.py +1 -1
  31. notionary/exceptions/block_parsing.py +9 -5
  32. notionary/exceptions/data_source/builder.py +13 -7
  33. notionary/exceptions/data_source/properties.py +6 -4
  34. notionary/exceptions/file_upload.py +76 -0
  35. notionary/exceptions/properties.py +7 -5
  36. notionary/exceptions/search.py +10 -6
  37. notionary/file_upload/__init__.py +4 -0
  38. notionary/file_upload/client.py +128 -210
  39. notionary/file_upload/config/__init__.py +17 -0
  40. notionary/file_upload/config/config.py +39 -0
  41. notionary/file_upload/config/constants.py +16 -0
  42. notionary/file_upload/file/reader.py +28 -0
  43. notionary/file_upload/query/__init__.py +7 -0
  44. notionary/file_upload/query/builder.py +58 -0
  45. notionary/file_upload/query/models.py +37 -0
  46. notionary/file_upload/schemas.py +80 -0
  47. notionary/file_upload/service.py +182 -291
  48. notionary/file_upload/validation/factory.py +66 -0
  49. notionary/file_upload/validation/impl/file_name_length.py +25 -0
  50. notionary/file_upload/validation/models.py +134 -0
  51. notionary/file_upload/validation/port.py +7 -0
  52. notionary/file_upload/validation/service.py +17 -0
  53. notionary/file_upload/validation/validators/__init__.py +11 -0
  54. notionary/file_upload/validation/validators/file_exists.py +15 -0
  55. notionary/file_upload/validation/validators/file_extension.py +131 -0
  56. notionary/file_upload/validation/validators/file_name_length.py +21 -0
  57. notionary/file_upload/validation/validators/upload_limit.py +31 -0
  58. notionary/http/client.py +33 -30
  59. notionary/page/content/__init__.py +9 -0
  60. notionary/page/content/factory.py +21 -7
  61. notionary/page/content/markdown/builder.py +85 -23
  62. notionary/page/content/markdown/nodes/audio.py +8 -4
  63. notionary/page/content/markdown/nodes/base.py +3 -3
  64. notionary/page/content/markdown/nodes/bookmark.py +5 -3
  65. notionary/page/content/markdown/nodes/breadcrumb.py +2 -2
  66. notionary/page/content/markdown/nodes/bulleted_list.py +5 -3
  67. notionary/page/content/markdown/nodes/callout.py +2 -2
  68. notionary/page/content/markdown/nodes/code.py +5 -3
  69. notionary/page/content/markdown/nodes/columns.py +3 -3
  70. notionary/page/content/markdown/nodes/container.py +9 -5
  71. notionary/page/content/markdown/nodes/divider.py +2 -2
  72. notionary/page/content/markdown/nodes/embed.py +8 -4
  73. notionary/page/content/markdown/nodes/equation.py +4 -2
  74. notionary/page/content/markdown/nodes/file.py +8 -4
  75. notionary/page/content/markdown/nodes/heading.py +2 -2
  76. notionary/page/content/markdown/nodes/image.py +8 -4
  77. notionary/page/content/markdown/nodes/mixins/caption.py +5 -3
  78. notionary/page/content/markdown/nodes/numbered_list.py +5 -3
  79. notionary/page/content/markdown/nodes/paragraph.py +4 -2
  80. notionary/page/content/markdown/nodes/pdf.py +8 -4
  81. notionary/page/content/markdown/nodes/quote.py +2 -2
  82. notionary/page/content/markdown/nodes/space.py +2 -2
  83. notionary/page/content/markdown/nodes/table.py +8 -5
  84. notionary/page/content/markdown/nodes/table_of_contents.py +2 -2
  85. notionary/page/content/markdown/nodes/todo.py +15 -7
  86. notionary/page/content/markdown/nodes/toggle.py +2 -2
  87. notionary/page/content/markdown/nodes/video.py +8 -4
  88. notionary/page/content/markdown/structured_output/__init__.py +73 -0
  89. notionary/page/content/markdown/structured_output/models.py +391 -0
  90. notionary/page/content/markdown/structured_output/service.py +211 -0
  91. notionary/page/content/parser/context.py +1 -1
  92. notionary/page/content/parser/factory.py +26 -8
  93. notionary/page/content/parser/parsers/audio.py +12 -32
  94. notionary/page/content/parser/parsers/base.py +2 -2
  95. notionary/page/content/parser/parsers/bookmark.py +2 -2
  96. notionary/page/content/parser/parsers/breadcrumb.py +2 -2
  97. notionary/page/content/parser/parsers/bulleted_list.py +19 -6
  98. notionary/page/content/parser/parsers/callout.py +15 -5
  99. notionary/page/content/parser/parsers/caption.py +9 -3
  100. notionary/page/content/parser/parsers/code.py +21 -7
  101. notionary/page/content/parser/parsers/column.py +8 -4
  102. notionary/page/content/parser/parsers/column_list.py +19 -7
  103. notionary/page/content/parser/parsers/divider.py +2 -2
  104. notionary/page/content/parser/parsers/embed.py +2 -4
  105. notionary/page/content/parser/parsers/equation.py +8 -4
  106. notionary/page/content/parser/parsers/file.py +12 -34
  107. notionary/page/content/parser/parsers/file_like_block.py +109 -0
  108. notionary/page/content/parser/parsers/heading.py +31 -10
  109. notionary/page/content/parser/parsers/image.py +12 -34
  110. notionary/page/content/parser/parsers/numbered_list.py +18 -6
  111. notionary/page/content/parser/parsers/paragraph.py +3 -1
  112. notionary/page/content/parser/parsers/pdf.py +12 -34
  113. notionary/page/content/parser/parsers/quote.py +28 -9
  114. notionary/page/content/parser/parsers/space.py +2 -2
  115. notionary/page/content/parser/parsers/table.py +31 -10
  116. notionary/page/content/parser/parsers/table_of_contents.py +7 -3
  117. notionary/page/content/parser/parsers/todo.py +15 -5
  118. notionary/page/content/parser/parsers/toggle.py +15 -5
  119. notionary/page/content/parser/parsers/video.py +12 -34
  120. notionary/page/content/parser/post_processing/handlers/rich_text_length.py +8 -2
  121. notionary/page/content/parser/post_processing/handlers/rich_text_length_truncation.py +8 -2
  122. notionary/page/content/parser/post_processing/service.py +3 -1
  123. notionary/page/content/parser/pre_processsing/handlers/column_syntax.py +21 -7
  124. notionary/page/content/parser/pre_processsing/handlers/indentation.py +11 -4
  125. notionary/page/content/parser/pre_processsing/handlers/video_syntax.py +13 -6
  126. notionary/page/content/parser/service.py +4 -1
  127. notionary/page/content/renderer/context.py +15 -5
  128. notionary/page/content/renderer/factory.py +12 -6
  129. notionary/page/content/renderer/post_processing/handlers/numbered_list.py +19 -9
  130. notionary/page/content/renderer/renderers/audio.py +20 -23
  131. notionary/page/content/renderer/renderers/base.py +3 -3
  132. notionary/page/content/renderer/renderers/bookmark.py +3 -1
  133. notionary/page/content/renderer/renderers/bulleted_list.py +11 -5
  134. notionary/page/content/renderer/renderers/callout.py +19 -7
  135. notionary/page/content/renderer/renderers/captioned_block.py +11 -5
  136. notionary/page/content/renderer/renderers/code.py +6 -2
  137. notionary/page/content/renderer/renderers/column.py +3 -1
  138. notionary/page/content/renderer/renderers/column_list.py +3 -1
  139. notionary/page/content/renderer/renderers/embed.py +3 -1
  140. notionary/page/content/renderer/renderers/equation.py +3 -1
  141. notionary/page/content/renderer/renderers/file.py +20 -23
  142. notionary/page/content/renderer/renderers/file_like_block.py +47 -0
  143. notionary/page/content/renderer/renderers/heading.py +22 -8
  144. notionary/page/content/renderer/renderers/image.py +20 -23
  145. notionary/page/content/renderer/renderers/numbered_list.py +8 -3
  146. notionary/page/content/renderer/renderers/paragraph.py +12 -4
  147. notionary/page/content/renderer/renderers/pdf.py +20 -23
  148. notionary/page/content/renderer/renderers/quote.py +14 -6
  149. notionary/page/content/renderer/renderers/table.py +15 -5
  150. notionary/page/content/renderer/renderers/todo.py +16 -6
  151. notionary/page/content/renderer/renderers/toggle.py +8 -4
  152. notionary/page/content/renderer/renderers/video.py +20 -23
  153. notionary/page/content/renderer/service.py +9 -3
  154. notionary/page/content/service.py +21 -7
  155. notionary/page/content/syntax/definition/__init__.py +11 -0
  156. notionary/page/content/syntax/definition/models.py +57 -0
  157. notionary/page/content/syntax/definition/registry.py +371 -0
  158. notionary/page/content/syntax/prompts/__init__.py +4 -0
  159. notionary/page/content/syntax/prompts/models.py +11 -0
  160. notionary/page/content/syntax/prompts/registry.py +703 -0
  161. notionary/page/page_metadata_update_client.py +12 -4
  162. notionary/page/properties/client.py +46 -16
  163. notionary/page/properties/factory.py +6 -2
  164. notionary/page/properties/{models.py → schemas.py} +93 -107
  165. notionary/page/properties/service.py +111 -37
  166. notionary/page/schemas.py +3 -3
  167. notionary/page/service.py +21 -7
  168. notionary/shared/entity/client.py +6 -2
  169. notionary/shared/entity/dto_parsers.py +4 -37
  170. notionary/shared/entity/entity_metadata_update_client.py +25 -5
  171. notionary/shared/entity/schemas.py +6 -6
  172. notionary/shared/entity/service.py +89 -35
  173. notionary/shared/models/file.py +36 -6
  174. notionary/shared/models/icon.py +5 -12
  175. notionary/user/base.py +6 -2
  176. notionary/user/bot.py +22 -14
  177. notionary/user/client.py +3 -1
  178. notionary/user/person.py +3 -1
  179. notionary/user/schemas.py +3 -1
  180. notionary/user/service.py +6 -2
  181. notionary/utils/decorators.py +13 -9
  182. notionary/utils/fuzzy.py +6 -2
  183. notionary/utils/mixins/logging.py +3 -1
  184. notionary/utils/pagination.py +14 -4
  185. notionary/workspace/__init__.py +6 -2
  186. notionary/workspace/query/__init__.py +2 -1
  187. notionary/workspace/query/service.py +42 -13
  188. notionary/workspace/service.py +74 -46
  189. {notionary-0.3.1.dist-info → notionary-0.4.1.dist-info}/METADATA +1 -1
  190. notionary-0.4.1.dist-info/RECORD +236 -0
  191. notionary/file_upload/models.py +0 -69
  192. notionary/page/blocks/client.py +0 -1
  193. notionary/page/content/syntax/__init__.py +0 -4
  194. notionary/page/content/syntax/models.py +0 -66
  195. notionary/page/content/syntax/registry.py +0 -393
  196. notionary/page/page_context.py +0 -50
  197. notionary/shared/models/cover.py +0 -20
  198. notionary-0.3.1.dist-info/RECORD +0 -211
  199. /notionary/page/content/syntax/{grammar.py → definition/grammar.py} +0 -0
  200. {notionary-0.3.1.dist-info → notionary-0.4.1.dist-info}/WHEEL +0 -0
  201. {notionary-0.3.1.dist-info → notionary-0.4.1.dist-info}/licenses/LICENSE +0 -0
@@ -1,12 +1,9 @@
1
- import traceback
2
- from io import BytesIO
3
- from pathlib import Path
4
- from typing import BinaryIO
1
+ from collections.abc import AsyncGenerator
5
2
 
6
- import aiofiles
7
3
  import httpx
8
4
 
9
- from notionary.file_upload.models import (
5
+ from notionary.file_upload.query.models import FileUploadQuery
6
+ from notionary.file_upload.schemas import (
10
7
  FileUploadCompleteRequest,
11
8
  FileUploadCreateRequest,
12
9
  FileUploadListResponse,
@@ -14,228 +11,149 @@ from notionary.file_upload.models import (
14
11
  UploadMode,
15
12
  )
16
13
  from notionary.http.client import NotionHttpClient
14
+ from notionary.utils.pagination import (
15
+ PaginatedResponse,
16
+ paginate_notion_api,
17
+ paginate_notion_api_generator,
18
+ )
17
19
 
18
20
 
19
21
  class FileUploadHttpClient(NotionHttpClient):
20
- """
21
- Client for Notion file upload operations.
22
- Inherits base HTTP functionality from NotionHttpClient.
23
- """
24
-
25
- async def create_file_upload(
22
+ async def create_single_part_upload(
26
23
  self,
27
24
  filename: str,
28
25
  content_type: str | None = None,
29
- content_length: int | None = None,
30
- mode: UploadMode = UploadMode.SINGLE_PART,
31
- ) -> FileUploadResponse | None:
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 (UploadMode.SINGLE_PART or UploadMode.MULTI_PART)
40
-
41
- Returns:
42
- FileUploadResponse or None if failed
43
- """
44
- request_data = FileUploadCreateRequest(
26
+ ) -> FileUploadResponse:
27
+ return await self._create_upload(
45
28
  filename=filename,
46
29
  content_type=content_type,
47
- content_length=content_length,
48
- mode=mode,
30
+ mode=UploadMode.SINGLE_PART,
31
+ number_of_parts=None,
49
32
  )
50
33
 
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
34
+ async def create_multi_part_upload(
35
+ self,
36
+ filename: str,
37
+ number_of_parts: int,
38
+ content_type: str | None = None,
39
+ ) -> FileUploadResponse:
40
+ return await self._create_upload(
41
+ filename=filename,
42
+ content_type=content_type,
43
+ mode=UploadMode.MULTI_PART,
44
+ number_of_parts=number_of_parts,
45
+ )
60
46
 
61
- async def send_file_upload(
47
+ async def send_file_content(
62
48
  self,
63
49
  file_upload_id: str,
64
- file_content: BinaryIO,
65
- filename: str | None = None,
50
+ file_content: bytes,
51
+ filename: str,
66
52
  part_number: int | None = 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
- if not self.client:
81
- self.logger.error("HTTP client not initialized")
82
- return False
83
-
84
- url = f"{self.BASE_URL}/file_uploads/{file_upload_id}/send"
85
-
86
- try:
87
- # Read all content from BinaryIO into bytes
88
- if hasattr(file_content, "read"):
89
- file_bytes = file_content.read()
90
- # Reset position if possible (for BytesIO objects)
91
- if hasattr(file_content, "seek"):
92
- file_content.seek(0)
93
- else:
94
- file_bytes = file_content
95
-
96
- # Prepare files dict for multipart upload
97
- files = {"file": (filename or "file", file_bytes)}
98
-
99
- # Prepare form data (only for multi-part uploads)
100
- data = {}
101
- if part_number is not None:
102
- data["part_number"] = str(part_number)
103
-
104
- # Create a new client instance specifically for this upload
105
- # This avoids issues with the base client's default JSON headers
106
- upload_headers = {
107
- "Authorization": f"Bearer {self.token}",
108
- "Notion-Version": self.NOTION_VERSION,
109
- # Explicitly do NOT set Content-Type - let httpx handle multipart
110
- }
111
-
112
- self.logger.debug("Sending file upload to %s with filename %s", url, filename)
113
-
114
- # Use a temporary client for the multipart upload
115
- async with httpx.AsyncClient(timeout=self.timeout) as upload_client:
116
- response = await upload_client.post(
117
- url,
118
- files=files,
119
- data=data if data else None,
120
- headers=upload_headers,
121
- )
122
-
123
- response.raise_for_status()
124
- self.logger.debug("File upload sent successfully: %s", file_upload_id)
125
- return True
126
-
127
- except httpx.HTTPStatusError as e:
128
- try:
129
- error_text = e.response.text
130
- except Exception:
131
- error_text = "Unable to read error response"
132
- error_msg = f"HTTP {e.response.status_code}: {error_text}"
133
- self.logger.error("Send file upload failed (%s): %s", url, error_msg)
134
- return False
135
-
136
- except httpx.RequestError as e:
137
- self.logger.error("Request error sending file upload (%s): %s", url, str(e))
138
- return False
139
-
140
- except Exception as e:
141
- self.logger.error("Unexpected error in send_file_upload: %s", str(e))
142
-
143
- self.logger.debug("Full traceback: %s", traceback.format_exc())
144
- return False
145
-
146
- async def complete_file_upload(self, file_upload_id: str) -> FileUploadResponse | None:
147
- """
148
- Complete a multi-part file upload.
149
-
150
- Args:
151
- file_upload_id: ID of the file upload
152
-
153
- Returns:
154
- FileUploadResponse or None if failed
155
- """
156
- request_data = FileUploadCompleteRequest()
157
-
158
- response = await self.post(f"file_uploads/{file_upload_id}/complete", data=request_data.model_dump())
159
- if response is None:
160
- return None
161
-
162
- try:
163
- return FileUploadResponse.model_validate(response)
164
- except Exception as e:
165
- self.logger.error("Failed to validate complete file upload response: %s", e)
166
- return None
167
-
168
- async def retrieve_file_upload(self, file_upload_id: str) -> FileUploadResponse | None:
169
- """
170
- Retrieve details of a file upload.
171
-
172
- Args:
173
- file_upload_id: ID of the file upload
174
-
175
- Returns:
176
- FileUploadResponse or None if failed
177
- """
178
- response = await self.get(f"file_uploads/{file_upload_id}")
179
- if response is None:
180
- return None
53
+ ) -> FileUploadResponse:
54
+ await self._ensure_initialized()
55
+
56
+ url = self._build_send_url(file_upload_id)
57
+ files = {"file": (filename, file_content)}
58
+ data = self._build_part_number_data(part_number)
59
+
60
+ response = await self._send_multipart_request(url, files=files, data=data)
61
+ return FileUploadResponse.model_validate(response.json())
62
+
63
+ async def complete_upload(self, file_upload_id: str) -> FileUploadResponse:
64
+ request = FileUploadCompleteRequest()
65
+ response = await self.post(
66
+ f"file_uploads/{file_upload_id}/complete",
67
+ data=request.model_dump(),
68
+ )
69
+ return FileUploadResponse.model_validate(response)
181
70
 
182
- try:
183
- return FileUploadResponse.model_validate(response)
184
- except Exception as e:
185
- self.logger.error("Failed to validate retrieve file upload response: %s", e)
186
- return None
71
+ async def get_file_upload(self, file_upload_id: str) -> FileUploadResponse:
72
+ response = await self.get(f"file_uploads/{file_upload_id}")
73
+ return FileUploadResponse.model_validate(response)
187
74
 
188
75
  async def list_file_uploads(
189
- self, page_size: int = 100, start_cursor: str | None = None
190
- ) -> FileUploadListResponse | None:
191
- """
192
- List file uploads for the current bot integration.
193
-
194
- Args:
195
- page_size: Number of uploads per page (max 100)
196
- start_cursor: Cursor for pagination
197
-
198
- Returns:
199
- FileUploadListResponse or None if failed
200
- """
201
- params = {"page_size": min(page_size, 100)}
76
+ self,
77
+ query: FileUploadQuery | None = None,
78
+ ) -> list[FileUploadResponse]:
79
+ query = query or FileUploadQuery()
80
+ return await paginate_notion_api(
81
+ lambda **kwargs: self._fetch_file_uploads_page(query=query, **kwargs),
82
+ total_results_limit=query.total_results_limit,
83
+ )
84
+
85
+ async def list_file_uploads_stream(
86
+ self,
87
+ query: FileUploadQuery | None = None,
88
+ ) -> AsyncGenerator[FileUploadResponse]:
89
+ query = query or FileUploadQuery()
90
+ async for upload in paginate_notion_api_generator(
91
+ lambda **kwargs: self._fetch_file_uploads_page(query=query, **kwargs),
92
+ total_results_limit=query.total_results_limit,
93
+ ):
94
+ yield upload
95
+
96
+ async def _create_upload(
97
+ self,
98
+ filename: str,
99
+ mode: UploadMode,
100
+ content_type: str | None,
101
+ number_of_parts: int | None,
102
+ ) -> FileUploadResponse:
103
+ request = FileUploadCreateRequest(
104
+ filename=filename,
105
+ content_type=content_type,
106
+ mode=mode,
107
+ number_of_parts=number_of_parts,
108
+ )
109
+ response = await self.post("file_uploads", data=request.model_dump())
110
+ return FileUploadResponse.model_validate(response)
111
+
112
+ async def _send_multipart_request(
113
+ self,
114
+ url: str,
115
+ files: dict,
116
+ data: dict | None = None,
117
+ ) -> httpx.Response:
118
+ headers = self._build_multipart_headers()
119
+
120
+ async with httpx.AsyncClient(headers=headers, timeout=self.timeout) as client:
121
+ response = await client.post(url, files=files, data=data)
122
+
123
+ response.raise_for_status()
124
+ return response
125
+
126
+ async def _fetch_file_uploads_page(
127
+ self,
128
+ query: FileUploadQuery,
129
+ start_cursor: str | None = None,
130
+ **kwargs,
131
+ ) -> PaginatedResponse:
132
+ params = query.model_dump(exclude_none=True)
133
+ params["page_size"] = min(query.page_size_limit or 100, 100)
134
+
202
135
  if start_cursor:
203
136
  params["start_cursor"] = start_cursor
204
137
 
205
138
  response = await self.get("file_uploads", params=params)
206
- if response is None:
207
- return None
208
-
209
- try:
210
- return FileUploadListResponse.model_validate(response)
211
- except Exception as e:
212
- self.logger.error("Failed to validate list file uploads response: %s", e)
213
- return None
214
-
215
- async def send_file_from_path(self, file_upload_id: str, file_path: Path, part_number: int | None = None) -> bool:
216
- """
217
- Convenience method to send file from file path.
218
-
219
- Args:
220
- file_upload_id: ID of the file upload
221
- file_path: Path to the file
222
- part_number: Part number for multi-part uploads
223
-
224
- Returns:
225
- True if successful, False otherwise
226
- """
227
- try:
228
- # Read file content into memory first using aiofiles
229
- async with aiofiles.open(file_path, "rb") as f:
230
- file_content = await f.read()
231
-
232
- # Use BytesIO for the upload
233
- return await self.send_file_upload(
234
- file_upload_id=file_upload_id,
235
- file_content=BytesIO(file_content),
236
- filename=file_path.name,
237
- part_number=part_number,
238
- )
239
- except Exception as e:
240
- self.logger.error("Failed to send file from path %s: %s", file_path, e)
241
- return False
139
+ parsed = FileUploadListResponse.model_validate(response)
140
+
141
+ return PaginatedResponse(
142
+ results=parsed.results,
143
+ has_more=parsed.has_more,
144
+ next_cursor=parsed.next_cursor,
145
+ )
146
+
147
+ def _build_send_url(self, file_upload_id: str) -> str:
148
+ return f"{self.BASE_URL}/file_uploads/{file_upload_id}/send"
149
+
150
+ def _build_part_number_data(self, part_number: int | None) -> dict | None:
151
+ if part_number is not None:
152
+ return {"part_number": str(part_number)}
153
+ return None
154
+
155
+ def _build_multipart_headers(self) -> dict:
156
+ return {
157
+ "Authorization": f"Bearer {self.token}",
158
+ "Notion-Version": self.NOTION_VERSION,
159
+ }
@@ -0,0 +1,17 @@
1
+ from .config import FileUploadConfig
2
+ from .constants import (
3
+ NOTION_MAX_FILENAME_BYTES,
4
+ NOTION_MULTI_PART_CHUNK_SIZE_MAX,
5
+ NOTION_MULTI_PART_CHUNK_SIZE_MIN,
6
+ NOTION_RECOMMENDED_CHUNK_SIZE,
7
+ NOTION_SINGLE_PART_MAX_SIZE,
8
+ )
9
+
10
+ __all__ = [
11
+ "NOTION_MAX_FILENAME_BYTES",
12
+ "NOTION_MULTI_PART_CHUNK_SIZE_MAX",
13
+ "NOTION_MULTI_PART_CHUNK_SIZE_MIN",
14
+ "NOTION_RECOMMENDED_CHUNK_SIZE",
15
+ "NOTION_SINGLE_PART_MAX_SIZE",
16
+ "FileUploadConfig",
17
+ ]
@@ -0,0 +1,39 @@
1
+ from pathlib import Path
2
+
3
+ from pydantic import BaseModel, ConfigDict, Field
4
+
5
+ from notionary.file_upload.config.constants import (
6
+ NOTION_MULTI_PART_CHUNK_SIZE_MAX,
7
+ NOTION_MULTI_PART_CHUNK_SIZE_MIN,
8
+ NOTION_RECOMMENDED_CHUNK_SIZE,
9
+ )
10
+
11
+
12
+ class FileUploadConfig(BaseModel):
13
+ model_config = ConfigDict(frozen=True)
14
+
15
+ multi_part_chunk_size: int = Field(
16
+ default=NOTION_RECOMMENDED_CHUNK_SIZE,
17
+ ge=NOTION_MULTI_PART_CHUNK_SIZE_MIN,
18
+ le=NOTION_MULTI_PART_CHUNK_SIZE_MAX,
19
+ description=(
20
+ "The part size (in bytes) for multi-part uploads. Must be within Notion's allowed range (e.g., 5MB-20MB)."
21
+ ),
22
+ )
23
+
24
+ max_upload_timeout: int = Field(
25
+ default=300,
26
+ gt=0,
27
+ description="Maximum time in seconds to wait for an upload to complete.",
28
+ )
29
+
30
+ poll_interval: int = Field(
31
+ default=2,
32
+ gt=0,
33
+ description="Interval in seconds for polling the upload status.",
34
+ )
35
+
36
+ base_upload_path: Path | None = Field(
37
+ default=None,
38
+ description="Optional default base path for resolving relative file uploads.",
39
+ )
@@ -0,0 +1,16 @@
1
+ """
2
+ Fixed API limits and recommendations from Notion.
3
+ These values should not be changed, as they are specified by the API.
4
+
5
+ Source: https://developers.notion.com/reference/file-uploads
6
+ """
7
+
8
+ _MB = 1024 * 1024
9
+
10
+ NOTION_SINGLE_PART_MAX_SIZE: int = 20 * _MB
11
+ NOTION_MAX_FILENAME_BYTES: int = 900
12
+
13
+ NOTION_MULTI_PART_CHUNK_SIZE_MIN: int = 5 * _MB
14
+ NOTION_MULTI_PART_CHUNK_SIZE_MAX: int = 20 * _MB
15
+
16
+ NOTION_RECOMMENDED_CHUNK_SIZE: int = 10 * _MB
@@ -0,0 +1,28 @@
1
+ from collections.abc import AsyncGenerator
2
+ from pathlib import Path
3
+
4
+ import aiofiles
5
+
6
+ from notionary.file_upload.config.config import FileUploadConfig
7
+
8
+
9
+ class FileContentReader:
10
+ def __init__(self, config: FileUploadConfig | None = None):
11
+ config = config or FileUploadConfig()
12
+ self._chunk_size = config.multi_part_chunk_size
13
+
14
+ async def read_full_file(self, file_path: Path) -> bytes:
15
+ async with aiofiles.open(file_path, "rb") as f:
16
+ return await f.read()
17
+
18
+ async def read_file_chunks(self, file_path: Path) -> AsyncGenerator[bytes]:
19
+ async with aiofiles.open(file_path, "rb") as file:
20
+ while True:
21
+ chunk = await file.read(self._chunk_size)
22
+ if not chunk:
23
+ break
24
+ yield chunk
25
+
26
+ async def bytes_to_chunks(self, file_content: bytes) -> AsyncGenerator[bytes]:
27
+ for i in range(0, len(file_content), self._chunk_size):
28
+ yield file_content[i : i + self._chunk_size]
@@ -0,0 +1,7 @@
1
+ from .builder import FileUploadQueryBuilder
2
+ from .models import FileUploadQuery
3
+
4
+ __all__ = [
5
+ "FileUploadQuery",
6
+ "FileUploadQueryBuilder",
7
+ ]
@@ -0,0 +1,58 @@
1
+ from typing import Self
2
+
3
+ from notionary.file_upload.query.models import FileUploadQuery
4
+ from notionary.file_upload.schemas import FileUploadStatus
5
+
6
+
7
+ class FileUploadQueryBuilder:
8
+ def __init__(self, query: FileUploadQuery | None = None):
9
+ self._query = query or FileUploadQuery()
10
+
11
+ def with_status(self, status: FileUploadStatus) -> Self:
12
+ self._query.status = status
13
+ return self
14
+
15
+ def with_uploaded_status_only(self) -> Self:
16
+ self._query.status = FileUploadStatus.UPLOADED
17
+ return self
18
+
19
+ def with_pending_status_only(self) -> Self:
20
+ self._query.status = FileUploadStatus.PENDING
21
+ return self
22
+
23
+ def with_failed_status_only(self) -> Self:
24
+ self._query.status = FileUploadStatus.FAILED
25
+ return self
26
+
27
+ def with_expired_status_only(self) -> Self:
28
+ self._query.status = FileUploadStatus.EXPIRED
29
+ return self
30
+
31
+ def with_archived(self, archived: bool) -> Self:
32
+ self._query.archived = archived
33
+ return self
34
+
35
+ def with_page_size_limit(self, page_size_limit: int) -> Self:
36
+ self._query.page_size_limit = self._validate_page_size_limit(page_size_limit)
37
+ return self
38
+
39
+ def _validate_page_size_limit(self, value: int) -> int:
40
+ if not (1 <= value <= 100):
41
+ raise ValueError(f"page_size_limit must be between 1 and 100, got {value}")
42
+ return value
43
+
44
+ def with_total_results_limit(self, total_results_limit: int) -> Self:
45
+ self._query.total_results_limit = self._validate_total_results_limit(
46
+ total_results_limit
47
+ )
48
+ return self
49
+
50
+ def _validate_total_results_limit(self, value: int) -> int:
51
+ if not (1 <= value <= 100):
52
+ raise ValueError(
53
+ f"total_results_limit must be between 1 and 100, got {value}"
54
+ )
55
+ return value
56
+
57
+ def build(self) -> FileUploadQuery:
58
+ return self._query
@@ -0,0 +1,37 @@
1
+ from pydantic import BaseModel, field_validator, model_serializer
2
+
3
+ from notionary.file_upload.schemas import FileUploadStatus
4
+
5
+
6
+ class FileUploadQuery(BaseModel):
7
+ status: FileUploadStatus | None = None
8
+ archived: bool | None = None
9
+
10
+ page_size_limit: int | None = None
11
+ total_results_limit: int | None = None
12
+
13
+ @field_validator("page_size_limit")
14
+ @classmethod
15
+ def validate_page_size(cls, value: int | None) -> int | None:
16
+ if value is None:
17
+ return None
18
+ return max(1, min(value, 100))
19
+
20
+ @field_validator("total_results_limit")
21
+ @classmethod
22
+ def validate_total_results(cls, value: int | None) -> int:
23
+ if value is None:
24
+ return 100
25
+ return max(1, value)
26
+
27
+ @model_serializer
28
+ def serialize_model(self) -> dict[str, str | bool | None]:
29
+ result = {}
30
+
31
+ if self.status is not None:
32
+ result["status"] = self.status
33
+
34
+ if self.archived is not None:
35
+ result["archived"] = self.archived
36
+
37
+ return result
@@ -0,0 +1,80 @@
1
+ from enum import StrEnum
2
+
3
+ from pydantic import BaseModel, Field, model_validator
4
+
5
+
6
+ class UploadMode(StrEnum):
7
+ SINGLE_PART = "single_part"
8
+ MULTI_PART = "multi_part"
9
+
10
+
11
+ class FileUploadStatus(StrEnum):
12
+ PENDING = "pending"
13
+ UPLOADED = "uploaded"
14
+ FAILED = "failed"
15
+ EXPIRED = "expired"
16
+
17
+
18
+ class FileUploadResponse(BaseModel):
19
+ id: str
20
+ created_time: str
21
+ last_edited_time: str
22
+ expiry_time: str | None = None
23
+ upload_url: str | None = None
24
+ archived: bool
25
+ status: FileUploadStatus
26
+ filename: str | None = None
27
+ content_type: str | None = None
28
+ content_length: int | None = None
29
+ request_id: str | None = None
30
+
31
+
32
+ class FileUploadFilter(BaseModel):
33
+ status: FileUploadStatus | None = None
34
+ archived: bool | None = None
35
+
36
+
37
+ class FileUploadListResponse(BaseModel):
38
+ results: list[FileUploadResponse]
39
+ next_cursor: str | None = None
40
+ has_more: bool
41
+
42
+
43
+ class FileUploadCreateRequest(BaseModel):
44
+ filename: str = Field(..., max_length=900)
45
+ content_type: str | None = None
46
+ content_length: int | None = None
47
+ mode: UploadMode = UploadMode.SINGLE_PART
48
+ number_of_parts: int | None = Field(None, ge=1)
49
+
50
+ @model_validator(mode="after")
51
+ def validate_multipart_requirements(self):
52
+ if self.mode == UploadMode.MULTI_PART and self.number_of_parts is None:
53
+ raise ValueError("number_of_parts is required when mode is 'multi_part'")
54
+ if self.mode == UploadMode.SINGLE_PART and self.number_of_parts is not None:
55
+ raise ValueError(
56
+ "number_of_parts should not be provided for 'single_part' mode"
57
+ )
58
+ return self
59
+
60
+ def model_dump(self, **kwargs):
61
+ data = super().model_dump(**kwargs)
62
+ return {k: v for k, v in data.items() if v is not None}
63
+
64
+
65
+ class FileUploadSendData(BaseModel):
66
+ file: bytes
67
+ part_number: int | None = Field(None, ge=1)
68
+
69
+
70
+ class FileUploadCompleteRequest(BaseModel):
71
+ pass
72
+
73
+
74
+ class FileUploadAttachment(BaseModel):
75
+ file_upload: dict[str, str]
76
+ name: str | None = None
77
+
78
+ @classmethod
79
+ def from_id(cls, file_upload_id: str, name: str | None = None):
80
+ return cls(type="file_upload", file_upload={"id": file_upload_id}, name=name)