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,351 +1,242 @@
1
1
  import asyncio
2
2
  import mimetypes
3
- from datetime import datetime, timedelta
4
- from io import BytesIO
3
+ from collections.abc import AsyncGenerator, AsyncIterator, Callable
5
4
  from pathlib import Path
6
5
 
7
- from notionary.file_upload.models import FileUploadResponse, UploadMode
6
+ from notionary.exceptions.file_upload import UploadFailedError, UploadTimeoutError
7
+ from notionary.file_upload.client import FileUploadHttpClient
8
+ from notionary.file_upload.config import NOTION_SINGLE_PART_MAX_SIZE, FileUploadConfig
9
+ from notionary.file_upload.file.reader import FileContentReader
10
+ from notionary.file_upload.query import FileUploadQuery, FileUploadQueryBuilder
11
+ from notionary.file_upload.schemas import FileUploadResponse, FileUploadStatus
12
+ from notionary.file_upload.validation.factory import (
13
+ create_bytes_upload_validation_service,
14
+ create_file_upload_validation_service,
15
+ )
8
16
  from notionary.utils.mixins.logging import LoggingMixin
9
17
 
10
18
 
11
19
  class NotionFileUpload(LoggingMixin):
12
- """
13
- High-level service for managing Notion file uploads.
14
- Handles both small file (single-part) and large file (multi-part) uploads.
15
- """
16
-
17
- # Notion's file size limits
18
- SINGLE_PART_MAX_SIZE = 20 * 1024 * 1024 # 20MB
19
- MULTI_PART_CHUNK_SIZE = 10 * 1024 * 1024 # 10MB per part
20
- MAX_FILENAME_BYTES = 900
21
-
22
- def __init__(self, token: str | None = None):
23
- """Initialize the file upload service."""
24
- from notionary.file_upload import FileUploadHttpClient
25
-
26
- self.client = FileUploadHttpClient(token=token)
27
-
28
- async def upload_file(self, file_path: Path, filename: str | None = None) -> FileUploadResponse | None:
29
- """
30
- Upload a file to Notion, automatically choosing single-part or multi-part based on size.
31
-
32
- Args:
33
- file_path: Path to the file to upload
34
- filename: Optional custom filename (defaults to file_path.name)
35
-
36
- Returns:
37
- FileUploadResponse if successful, None otherwise
38
- """
39
- if not file_path.exists():
40
- self.logger.error("File does not exist: %s", file_path)
41
- return None
20
+ def __init__(
21
+ self,
22
+ client: FileUploadHttpClient | None = None,
23
+ config: FileUploadConfig | None = None,
24
+ file_reader: FileContentReader | None = None,
25
+ ):
26
+ self._client = client or FileUploadHttpClient()
27
+ self._config = config or FileUploadConfig()
28
+ self._file_reader = file_reader or FileContentReader(config=self._config)
29
+
30
+ async def upload_file(
31
+ self, file_path: Path, filename: str | None = None
32
+ ) -> FileUploadResponse:
33
+ file_path = Path(file_path)
34
+
35
+ if not file_path.is_absolute() and self._config.base_upload_path:
36
+ file_path = Path(self._config.base_upload_path) / file_path
37
+ file_path = file_path.resolve()
38
+
39
+ validator_service = create_file_upload_validation_service(file_path)
40
+ await validator_service.validate()
42
41
 
43
42
  file_size = file_path.stat().st_size
44
43
  filename = filename or file_path.name
44
+ content_type = self._guess_content_type(filename)
45
45
 
46
- # Validate filename length
47
- if len(filename.encode("utf-8")) > self.MAX_FILENAME_BYTES:
48
- self.logger.error(
49
- "Filename too long: %d bytes (max %d)",
50
- len(filename.encode("utf-8")),
51
- self.MAX_FILENAME_BYTES,
46
+ if self._fits_in_single_part(file_size):
47
+ content = await self._file_reader.read_full_file(file_path)
48
+ return await self._upload_single_part_content(
49
+ content, filename, content_type
52
50
  )
53
- return None
54
-
55
- # Choose upload method based on file size
56
- if file_size <= self.SINGLE_PART_MAX_SIZE:
57
- return await self._upload_small_file(file_path, filename, file_size)
58
51
  else:
59
- return await self._upload_large_file(file_path, filename, file_size)
52
+ return await self._upload_multi_part_content(
53
+ filename,
54
+ content_type,
55
+ file_size,
56
+ self._file_reader.read_file_chunks(file_path),
57
+ )
60
58
 
61
59
  async def upload_from_bytes(
62
- self, file_content: bytes, filename: str, content_type: str | None = None
63
- ) -> FileUploadResponse | None:
64
- """
65
- Upload file content from bytes.
66
-
67
- Args:
68
- file_content: File content as bytes
69
- filename: Name for the file
70
- content_type: Optional MIME type
71
-
72
- Returns:
73
- FileUploadResponse if successful, None otherwise
74
- """
60
+ self,
61
+ file_content: bytes,
62
+ filename: str,
63
+ content_type: str | None = None,
64
+ ) -> FileUploadResponse:
75
65
  file_size = len(file_content)
76
66
 
77
- # Validate filename length
78
- if len(filename.encode("utf-8")) > self.MAX_FILENAME_BYTES:
79
- self.logger.error(
80
- "Filename too long: %d bytes (max %d)",
81
- len(filename.encode("utf-8")),
82
- self.MAX_FILENAME_BYTES,
83
- )
84
- return None
85
-
86
- # Guess content type if not provided
87
- if not content_type:
88
- content_type, _ = mimetypes.guess_type(filename)
89
-
90
- # Choose upload method based on size
91
- if file_size <= self.SINGLE_PART_MAX_SIZE:
92
- return await self._upload_small_file_from_bytes(file_content, filename, content_type, file_size)
93
- else:
94
- return await self._upload_large_file_from_bytes(file_content, filename, content_type, file_size)
95
-
96
- async def get_upload_status(self, file_upload_id: str) -> str | None:
97
- """
98
- Get the current status of a file upload.
99
-
100
- Args:
101
- file_upload_id: ID of the file upload
102
-
103
- Returns:
104
- Status string ("pending", "uploaded", etc.) or None if failed
105
- """
106
- upload_info = await self.client.retrieve_file_upload(file_upload_id)
107
- return upload_info.status if upload_info else None
108
-
109
- async def wait_for_upload_completion(
110
- self, file_upload_id: str, timeout_seconds: int = 300, poll_interval: int = 2
111
- ) -> FileUploadResponse | None:
112
- """
113
- Wait for a file upload to complete.
114
-
115
- Args:
116
- file_upload_id: ID of the file upload
117
- timeout_seconds: Maximum time to wait
118
- poll_interval: Seconds between status checks
119
-
120
- Returns:
121
- FileUploadResponse when complete, None if timeout or failed
122
- """
123
- start_time = datetime.now()
124
- timeout_delta = timedelta(seconds=timeout_seconds)
125
-
126
- while datetime.now() - start_time < timeout_delta:
127
- upload_info = await self.client.retrieve_file_upload(file_upload_id)
128
-
129
- if not upload_info:
130
- self.logger.error("Failed to retrieve upload info for %s", file_upload_id)
131
- return None
132
-
133
- if upload_info.status == "uploaded":
134
- self.logger.info("Upload completed: %s", file_upload_id)
135
- return upload_info
136
- elif upload_info.status == "failed":
137
- self.logger.error("Upload failed: %s", file_upload_id)
138
- return None
139
-
140
- await asyncio.sleep(poll_interval)
141
-
142
- self.logger.warning("Upload timeout: %s", file_upload_id)
143
- return None
144
-
145
- async def list_recent_uploads(self, limit: int = 50) -> list[FileUploadResponse]:
146
- """
147
- List recent file uploads.
148
-
149
- Args:
150
- limit: Maximum number of uploads to return
151
-
152
- Returns:
153
- List of FileUploadResponse objects
154
- """
155
- uploads = []
156
- start_cursor = None
157
- remaining = limit
158
-
159
- while remaining > 0:
160
- page_size = min(remaining, 100) # API max per request
161
-
162
- response = await self.client.list_file_uploads(page_size=page_size, start_cursor=start_cursor)
163
-
164
- if not response or not response.results:
165
- break
166
-
167
- uploads.extend(response.results)
168
- remaining -= len(response.results)
169
-
170
- if not response.has_more or not response.next_cursor:
171
- break
172
-
173
- start_cursor = response.next_cursor
174
-
175
- return uploads[:limit]
176
-
177
- async def _upload_small_file(self, file_path: Path, filename: str, file_size: int) -> FileUploadResponse | None:
178
- """Upload a small file using single-part upload."""
179
- content_type, _ = mimetypes.guess_type(str(file_path))
180
-
181
- # Create file upload
182
- file_upload = await self.client.create_file_upload(
67
+ validator_service = create_bytes_upload_validation_service(
183
68
  filename=filename,
184
- content_type=content_type,
185
- content_length=file_size,
186
- mode=UploadMode.SINGLE_PART,
69
+ file_size_bytes=file_size,
187
70
  )
71
+ await validator_service.validate()
188
72
 
189
- if not file_upload:
190
- self.logger.error("Failed to create file upload for %s", filename)
191
- return None
192
-
193
- # Send file content
194
- success = await self.client.send_file_from_path(file_upload_id=file_upload.id, file_path=file_path)
73
+ content_type = content_type or self._guess_content_type(filename)
195
74
 
196
- if not success:
197
- self.logger.error("Failed to send file content for %s", filename)
198
- return None
199
-
200
- self.logger.info("Successfully uploaded file: %s (ID: %s)", filename, file_upload.id)
201
- return file_upload
202
-
203
- async def _upload_large_file(self, file_path: Path, filename: str, file_size: int) -> FileUploadResponse | None:
204
- """Upload a large file using multi-part upload."""
205
- content_type, _ = mimetypes.guess_type(str(file_path))
75
+ if self._fits_in_single_part(file_size):
76
+ return await self._upload_single_part_content(
77
+ file_content, filename, content_type
78
+ )
206
79
 
207
- # Create file upload with multi-part mode
208
- file_upload = await self.client.create_file_upload(
209
- filename=filename,
210
- content_type=content_type,
211
- content_length=file_size,
212
- mode=UploadMode.MULTI_PART,
80
+ return await self._upload_multi_part_content(
81
+ filename,
82
+ content_type,
83
+ file_size,
84
+ self._file_reader.bytes_to_chunks(file_content),
213
85
  )
214
86
 
215
- if not file_upload:
216
- self.logger.error("Failed to create multi-part file upload for %s", filename)
217
- return None
218
-
219
- # Upload file in parts
220
- success = await self._upload_file_parts(file_upload.id, file_path, file_size)
221
-
222
- if not success:
223
- self.logger.error("Failed to upload file parts for %s", filename)
224
- return None
225
-
226
- # Complete the upload
227
- completed_upload = await self.client.complete_file_upload(file_upload.id)
228
-
229
- if not completed_upload:
230
- self.logger.error("Failed to complete file upload for %s", filename)
231
- return None
232
-
233
- self.logger.info("Successfully uploaded large file: %s (ID: %s)", filename, file_upload.id)
234
- return completed_upload
235
-
236
- async def _upload_small_file_from_bytes(
237
- self,
238
- file_content: bytes,
239
- filename: str,
240
- content_type: str | None,
241
- file_size: int,
242
- ) -> FileUploadResponse | None:
243
- """Upload small file from bytes."""
244
- # Create file upload
245
- file_upload = await self.client.create_file_upload(
87
+ async def _upload_single_part_content(
88
+ self, content: bytes, filename: str, content_type: str | None
89
+ ) -> FileUploadResponse:
90
+ file_upload = await self._client.create_single_part_upload(
246
91
  filename=filename,
247
92
  content_type=content_type,
248
- content_length=file_size,
249
- mode=UploadMode.SINGLE_PART,
250
93
  )
251
94
 
252
- if not file_upload:
253
- return None
254
-
255
- # Send file content
256
- from io import BytesIO
257
-
258
- success = await self.client.send_file_upload(
95
+ await self._client.send_file_content(
259
96
  file_upload_id=file_upload.id,
260
- file_content=BytesIO(file_content),
97
+ file_content=content,
261
98
  filename=filename,
262
99
  )
263
100
 
264
- return file_upload if success else None
101
+ self.logger.info(
102
+ "Single-part content sent, waiting for completion... (ID: %s)",
103
+ file_upload.id,
104
+ )
105
+ return await self._wait_for_completion(file_upload.id)
265
106
 
266
- async def _upload_large_file_from_bytes(
107
+ async def _upload_multi_part_content(
267
108
  self,
268
- file_content: bytes,
269
109
  filename: str,
270
110
  content_type: str | None,
271
111
  file_size: int,
272
- ) -> FileUploadResponse | None:
273
- """Upload large file from bytes using multi-part."""
274
- # Create file upload
275
- file_upload = await self.client.create_file_upload(
112
+ chunk_generator: AsyncGenerator[bytes],
113
+ ) -> FileUploadResponse:
114
+ part_count = self._calculate_part_count(file_size)
115
+
116
+ file_upload = await self._client.create_multi_part_upload(
276
117
  filename=filename,
277
118
  content_type=content_type,
278
- content_length=file_size,
279
- mode=UploadMode.MULTI_PART,
119
+ number_of_parts=part_count,
280
120
  )
281
121
 
282
- if not file_upload:
283
- return None
122
+ await self._send_parts(file_upload.id, filename, part_count, chunk_generator)
284
123
 
285
- # Upload in chunks
286
- success = await self._upload_bytes_parts(file_upload.id, file_content)
124
+ await self._client.complete_upload(file_upload.id)
287
125
 
288
- if not success:
289
- return None
290
-
291
- # Complete the upload
292
- return await self.client.complete_file_upload(file_upload.id)
126
+ self.logger.info(
127
+ "Multi-part content sent, waiting for completion... (ID: %s)",
128
+ file_upload.id,
129
+ )
130
+ return await self._wait_for_completion(file_upload.id)
293
131
 
294
- async def _upload_file_parts(self, file_upload_id: str, file_path: Path, file_size: int) -> bool:
295
- """Upload file in parts for multi-part upload."""
132
+ async def _send_parts(
133
+ self,
134
+ file_upload_id: str,
135
+ filename: str,
136
+ total_parts: int,
137
+ chunk_generator: AsyncGenerator[bytes],
138
+ ) -> None:
296
139
  part_number = 1
297
- total_parts = (file_size + self.MULTI_PART_CHUNK_SIZE - 1) // self.MULTI_PART_CHUNK_SIZE
298
-
299
140
  try:
300
- import aiofiles
141
+ async for chunk in chunk_generator:
142
+ await self._client.send_file_content(
143
+ file_upload_id=file_upload_id,
144
+ file_content=chunk,
145
+ filename=filename,
146
+ part_number=part_number,
147
+ )
301
148
 
302
- async with aiofiles.open(file_path, "rb") as file:
303
- while True:
304
- chunk = await file.read(self.MULTI_PART_CHUNK_SIZE)
305
- if not chunk:
306
- break
149
+ self.logger.debug("Uploaded part %d/%d", part_number, total_parts)
150
+ part_number += 1
307
151
 
308
- success = await self.client.send_file_upload(
309
- file_upload_id=file_upload_id,
310
- file_content=BytesIO(chunk),
311
- filename=file_path.name,
312
- part_number=part_number,
313
- )
152
+ except Exception as e:
153
+ raise UploadFailedError(
154
+ file_upload_id=file_upload_id,
155
+ reason=f"Failed to upload part {part_number}/{total_parts}: {e}",
156
+ ) from e
314
157
 
315
- if not success:
316
- self.logger.error("Failed to upload part %d/%d", part_number, total_parts)
317
- return False
158
+ def _fits_in_single_part(self, file_size: int) -> bool:
159
+ return file_size <= NOTION_SINGLE_PART_MAX_SIZE
318
160
 
319
- self.logger.debug("Uploaded part %d/%d", part_number, total_parts)
320
- part_number += 1
161
+ def _guess_content_type(self, filename: str) -> str | None:
162
+ content_type, _ = mimetypes.guess_type(filename)
163
+ return content_type
321
164
 
322
- self.logger.info("Successfully uploaded all %d parts", total_parts)
323
- return True
165
+ def _calculate_part_count(self, file_size: int) -> int:
166
+ return (
167
+ file_size + self._config.multi_part_chunk_size - 1
168
+ ) // self._config.multi_part_chunk_size
324
169
 
170
+ async def get_upload_status(self, file_upload_id: str) -> str:
171
+ try:
172
+ upload_info = await self._client.get_file_upload(file_upload_id)
173
+ return upload_info.status
325
174
  except Exception as e:
326
- self.logger.error("Error uploading file parts: %s", e)
327
- return False
328
-
329
- async def _upload_bytes_parts(self, file_upload_id: str, file_content: bytes) -> bool:
330
- """Upload bytes in parts for multi-part upload."""
331
- part_number = 1
332
- total_parts = (len(file_content) + self.MULTI_PART_CHUNK_SIZE - 1) // self.MULTI_PART_CHUNK_SIZE
175
+ raise UploadFailedError(file_upload_id, reason=str(e)) from e
333
176
 
334
- for i in range(0, len(file_content), self.MULTI_PART_CHUNK_SIZE):
335
- chunk = file_content[i : i + self.MULTI_PART_CHUNK_SIZE]
177
+ async def _wait_for_completion(
178
+ self,
179
+ file_upload_id: str,
180
+ timeout_seconds: int | None = None,
181
+ ) -> FileUploadResponse:
182
+ timeout = timeout_seconds or self._config.max_upload_timeout
336
183
 
337
- success = await self.client.send_file_upload(
338
- file_upload_id=file_upload_id,
339
- file_content=BytesIO(chunk),
340
- part_number=part_number,
184
+ try:
185
+ return await asyncio.wait_for(
186
+ self._poll_status_until_complete(file_upload_id), timeout=timeout
341
187
  )
342
188
 
343
- if not success:
344
- self.logger.error("Failed to upload part %d/%d", part_number, total_parts)
345
- return False
189
+ except TimeoutError as e:
190
+ raise UploadTimeoutError(file_upload_id, timeout) from e
191
+
192
+ async def _poll_status_until_complete(
193
+ self, file_upload_id: str
194
+ ) -> FileUploadResponse:
195
+ while True:
196
+ upload_info = await self._client.get_file_upload(file_upload_id)
197
+
198
+ if upload_info.status == FileUploadStatus.UPLOADED:
199
+ self.logger.info("Upload completed: %s", file_upload_id)
200
+ return upload_info
346
201
 
347
- self.logger.debug("Uploaded part %d/%d", part_number, total_parts)
348
- part_number += 1
202
+ if upload_info.status == FileUploadStatus.FAILED:
203
+ raise UploadFailedError(file_upload_id)
349
204
 
350
- self.logger.info("Successfully uploaded all %d parts", total_parts)
351
- return True
205
+ await asyncio.sleep(self._config.poll_interval)
206
+
207
+ async def get_uploads(
208
+ self,
209
+ *,
210
+ filter_fn: Callable[[FileUploadQueryBuilder], FileUploadQueryBuilder]
211
+ | None = None,
212
+ query: FileUploadQuery | None = None,
213
+ ) -> list[FileUploadResponse]:
214
+ resolved_query = self._resolve_query(filter_fn=filter_fn, query=query)
215
+ return await self._client.list_file_uploads(query=resolved_query)
216
+
217
+ async def iter_uploads(
218
+ self,
219
+ *,
220
+ filter_fn: Callable[[FileUploadQueryBuilder], FileUploadQueryBuilder]
221
+ | None = None,
222
+ query: FileUploadQuery | None = None,
223
+ ) -> AsyncIterator[FileUploadResponse]:
224
+ resolved_query = self._resolve_query(filter_fn=filter_fn, query=query)
225
+ async for upload in self._client.list_file_uploads_stream(query=resolved_query):
226
+ yield upload
227
+
228
+ def _resolve_query(
229
+ self,
230
+ filter_fn: Callable[[FileUploadQueryBuilder], FileUploadQueryBuilder]
231
+ | None = None,
232
+ query: FileUploadQuery | None = None,
233
+ ) -> FileUploadQuery:
234
+ if filter_fn and query:
235
+ raise ValueError("Use either filter_fn OR query, not both")
236
+
237
+ if filter_fn:
238
+ builder = FileUploadQueryBuilder()
239
+ configured_builder = filter_fn(builder)
240
+ return configured_builder.build()
241
+
242
+ return query or FileUploadQuery()
@@ -0,0 +1,66 @@
1
+ from pathlib import Path
2
+
3
+ from notionary.file_upload.validation.service import FileUploadValidationService
4
+ from notionary.file_upload.validation.validators import (
5
+ FileExistsValidator,
6
+ FileExtensionValidator,
7
+ FileNameLengthValidator,
8
+ FileUploadLimitValidator,
9
+ )
10
+
11
+
12
+ def create_file_upload_validation_service(
13
+ file_path: Path,
14
+ ) -> FileUploadValidationService:
15
+ file_path = Path(file_path)
16
+ filename = file_path.name
17
+ file_size_bytes = file_path.stat().st_size
18
+
19
+ validation_service = FileUploadValidationService()
20
+
21
+ file_exists_validator = _create_file_exists_validator(file_path)
22
+ filename_length_validator = _create_filename_length_validator(filename)
23
+ extension_validator = _create_extension_validator(filename)
24
+ size_validator = _create_size_validator(filename, file_size_bytes)
25
+
26
+ validation_service.register(file_exists_validator)
27
+ validation_service.register(filename_length_validator)
28
+ validation_service.register(extension_validator)
29
+ validation_service.register(size_validator)
30
+
31
+ return validation_service
32
+
33
+
34
+ def create_bytes_upload_validation_service(
35
+ filename: str,
36
+ file_size_bytes: int,
37
+ ) -> FileUploadValidationService:
38
+ validation_service = FileUploadValidationService()
39
+
40
+ filename_length_validator = _create_filename_length_validator(filename)
41
+ extension_validator = _create_extension_validator(filename)
42
+ size_validator = _create_size_validator(filename, file_size_bytes)
43
+
44
+ validation_service.register(filename_length_validator)
45
+ validation_service.register(extension_validator)
46
+ validation_service.register(size_validator)
47
+
48
+ return validation_service
49
+
50
+
51
+ def _create_file_exists_validator(file_path: Path) -> FileExistsValidator:
52
+ return FileExistsValidator(file_path=file_path)
53
+
54
+
55
+ def _create_filename_length_validator(filename: str) -> FileNameLengthValidator:
56
+ return FileNameLengthValidator(filename=filename)
57
+
58
+
59
+ def _create_extension_validator(filename: str) -> FileExtensionValidator:
60
+ return FileExtensionValidator(filename=filename)
61
+
62
+
63
+ def _create_size_validator(
64
+ filename: str, file_size_bytes: int
65
+ ) -> FileUploadLimitValidator:
66
+ return FileUploadLimitValidator(filename=filename, file_size_bytes=file_size_bytes)
@@ -0,0 +1,25 @@
1
+ from typing import override
2
+
3
+ from notionary.exceptions.file_upload import FilenameTooLongError
4
+ from notionary.file_upload.config.config import FileUploadConfig
5
+ from notionary.file_upload.validation.port import FileUploadValidator
6
+
7
+
8
+ class FileNameLengthValidator(FileUploadValidator):
9
+ def __init__(
10
+ self, filename: str, file_upload_config: FileUploadConfig | None = None
11
+ ) -> None:
12
+ self._filename = filename
13
+
14
+ file_upload_config = file_upload_config or FileUploadConfig()
15
+ self._max_filename_bytes = file_upload_config.MAX_FILENAME_BYTES
16
+
17
+ @override
18
+ async def validate(self) -> None:
19
+ filename_bytes = len(self._filename.encode("utf-8"))
20
+ if filename_bytes > self._max_filename_bytes:
21
+ raise FilenameTooLongError(
22
+ filename=self._filename,
23
+ filename_bytes=filename_bytes,
24
+ max_filename_bytes=self._max_filename_bytes,
25
+ )