notionary 0.3.0__py3-none-any.whl → 0.4.0__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 +14 -2
- notionary/blocks/enums.py +27 -6
- notionary/blocks/schemas.py +32 -78
- notionary/comments/client.py +6 -9
- notionary/comments/schemas.py +2 -29
- notionary/data_source/http/data_source_instance_client.py +4 -4
- notionary/data_source/properties/schemas.py +128 -107
- notionary/data_source/query/__init__.py +9 -0
- notionary/data_source/query/builder.py +12 -3
- notionary/data_source/query/schema.py +5 -0
- notionary/data_source/schemas.py +2 -2
- notionary/data_source/service.py +43 -132
- notionary/database/schemas.py +2 -2
- notionary/database/service.py +19 -63
- notionary/exceptions/__init__.py +10 -2
- notionary/exceptions/api.py +2 -2
- notionary/exceptions/base.py +1 -1
- notionary/exceptions/block_parsing.py +24 -3
- notionary/exceptions/data_source/builder.py +2 -2
- notionary/exceptions/data_source/properties.py +3 -3
- notionary/exceptions/file_upload.py +67 -0
- notionary/exceptions/properties.py +4 -4
- notionary/exceptions/search.py +4 -4
- notionary/file_upload/__init__.py +4 -0
- notionary/file_upload/client.py +124 -210
- notionary/file_upload/config/__init__.py +17 -0
- notionary/file_upload/config/config.py +32 -0
- notionary/file_upload/config/constants.py +16 -0
- notionary/file_upload/file/reader.py +28 -0
- notionary/file_upload/query/__init__.py +7 -0
- notionary/file_upload/query/builder.py +54 -0
- notionary/file_upload/query/models.py +37 -0
- notionary/file_upload/schemas.py +78 -0
- notionary/file_upload/service.py +152 -289
- notionary/file_upload/validation/factory.py +64 -0
- notionary/file_upload/validation/impl/file_name_length.py +23 -0
- notionary/file_upload/validation/models.py +124 -0
- notionary/file_upload/validation/port.py +7 -0
- notionary/file_upload/validation/service.py +17 -0
- notionary/file_upload/validation/validators/__init__.py +11 -0
- notionary/file_upload/validation/validators/file_exists.py +15 -0
- notionary/file_upload/validation/validators/file_extension.py +122 -0
- notionary/file_upload/validation/validators/file_name_length.py +21 -0
- notionary/file_upload/validation/validators/upload_limit.py +31 -0
- notionary/http/client.py +7 -23
- notionary/page/content/factory.py +2 -0
- notionary/page/content/parser/factory.py +8 -5
- notionary/page/content/parser/parsers/audio.py +8 -33
- notionary/page/content/parser/parsers/embed.py +0 -2
- notionary/page/content/parser/parsers/file.py +8 -35
- notionary/page/content/parser/parsers/file_like_block.py +89 -0
- notionary/page/content/parser/parsers/image.py +8 -35
- notionary/page/content/parser/parsers/pdf.py +8 -35
- notionary/page/content/parser/parsers/video.py +8 -35
- notionary/page/content/parser/pre_processsing/handlers/__init__.py +2 -0
- notionary/page/content/parser/pre_processsing/handlers/column_syntax.py +12 -8
- notionary/page/content/parser/pre_processsing/handlers/indentation.py +2 -0
- notionary/page/content/parser/pre_processsing/handlers/video_syntax.py +66 -0
- notionary/page/content/parser/pre_processsing/handlers/whitespace.py +2 -0
- notionary/page/content/renderer/renderers/audio.py +9 -21
- notionary/page/content/renderer/renderers/file.py +9 -21
- notionary/page/content/renderer/renderers/file_like_block.py +43 -0
- notionary/page/content/renderer/renderers/image.py +9 -21
- notionary/page/content/renderer/renderers/pdf.py +9 -21
- notionary/page/content/renderer/renderers/video.py +9 -21
- notionary/page/content/syntax/__init__.py +2 -1
- notionary/page/content/syntax/registry.py +38 -60
- notionary/page/properties/client.py +3 -3
- notionary/page/properties/{models.py → schemas.py} +93 -107
- notionary/page/properties/service.py +15 -4
- notionary/page/schemas.py +3 -3
- notionary/page/service.py +18 -79
- notionary/shared/entity/dto_parsers.py +1 -36
- notionary/shared/entity/entity_metadata_update_client.py +18 -4
- notionary/shared/entity/schemas.py +6 -6
- notionary/shared/entity/service.py +121 -40
- notionary/shared/models/file.py +34 -6
- notionary/shared/models/icon.py +5 -12
- notionary/user/bot.py +12 -12
- notionary/utils/decorators.py +8 -8
- notionary/utils/pagination.py +36 -32
- notionary/workspace/__init__.py +2 -2
- notionary/workspace/client.py +2 -0
- notionary/workspace/query/__init__.py +3 -2
- notionary/workspace/query/builder.py +25 -1
- notionary/workspace/query/models.py +9 -1
- notionary/workspace/query/service.py +15 -11
- notionary/workspace/service.py +46 -36
- {notionary-0.3.0.dist-info → notionary-0.4.0.dist-info}/METADATA +9 -5
- {notionary-0.3.0.dist-info → notionary-0.4.0.dist-info}/RECORD +92 -71
- notionary/file_upload/models.py +0 -69
- notionary/page/page_context.py +0 -50
- notionary/shared/models/cover.py +0 -20
- {notionary-0.3.0.dist-info → notionary-0.4.0.dist-info}/WHEEL +0 -0
- {notionary-0.3.0.dist-info → notionary-0.4.0.dist-info}/licenses/LICENSE +0 -0
notionary/file_upload/service.py
CHANGED
|
@@ -1,351 +1,214 @@
|
|
|
1
1
|
import asyncio
|
|
2
2
|
import mimetypes
|
|
3
|
-
from
|
|
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
|
|
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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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)
|
|
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)
|
|
27
29
|
|
|
28
|
-
async def upload_file(self, file_path: Path, filename: str | None = None) -> FileUploadResponse
|
|
29
|
-
|
|
30
|
-
Upload a file to Notion, automatically choosing single-part or multi-part based on size.
|
|
30
|
+
async def upload_file(self, file_path: Path, filename: str | None = None) -> FileUploadResponse:
|
|
31
|
+
file_path = Path(file_path)
|
|
31
32
|
|
|
32
|
-
|
|
33
|
-
file_path
|
|
34
|
-
|
|
33
|
+
if not file_path.is_absolute() and self._config.base_upload_path:
|
|
34
|
+
file_path = Path(self._config.base_upload_path) / file_path
|
|
35
|
+
file_path = file_path.resolve()
|
|
35
36
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
"""
|
|
39
|
-
if not file_path.exists():
|
|
40
|
-
self.logger.error("File does not exist: %s", file_path)
|
|
41
|
-
return None
|
|
37
|
+
validator_service = create_file_upload_validation_service(file_path)
|
|
38
|
+
await validator_service.validate()
|
|
42
39
|
|
|
43
40
|
file_size = file_path.stat().st_size
|
|
44
41
|
filename = filename or file_path.name
|
|
42
|
+
content_type = self._guess_content_type(filename)
|
|
45
43
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
self.
|
|
49
|
-
"Filename too long: %d bytes (max %d)",
|
|
50
|
-
len(filename.encode("utf-8")),
|
|
51
|
-
self.MAX_FILENAME_BYTES,
|
|
52
|
-
)
|
|
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)
|
|
44
|
+
if self._fits_in_single_part(file_size):
|
|
45
|
+
content = await self._file_reader.read_full_file(file_path)
|
|
46
|
+
return await self._upload_single_part_content(content, filename, content_type)
|
|
58
47
|
else:
|
|
59
|
-
return await self.
|
|
48
|
+
return await self._upload_multi_part_content(
|
|
49
|
+
filename, content_type, file_size, self._file_reader.read_file_chunks(file_path)
|
|
50
|
+
)
|
|
60
51
|
|
|
61
52
|
async def upload_from_bytes(
|
|
62
|
-
self,
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
"""
|
|
53
|
+
self,
|
|
54
|
+
file_content: bytes,
|
|
55
|
+
filename: str,
|
|
56
|
+
content_type: str | None = None,
|
|
57
|
+
) -> FileUploadResponse:
|
|
75
58
|
file_size = len(file_content)
|
|
76
59
|
|
|
77
|
-
|
|
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(
|
|
60
|
+
validator_service = create_bytes_upload_validation_service(
|
|
183
61
|
filename=filename,
|
|
184
|
-
|
|
185
|
-
content_length=file_size,
|
|
186
|
-
mode=UploadMode.SINGLE_PART,
|
|
62
|
+
file_size_bytes=file_size,
|
|
187
63
|
)
|
|
64
|
+
await validator_service.validate()
|
|
188
65
|
|
|
189
|
-
|
|
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)
|
|
195
|
-
|
|
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
|
|
66
|
+
content_type = content_type or self._guess_content_type(filename)
|
|
202
67
|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
content_type, _ = mimetypes.guess_type(str(file_path))
|
|
68
|
+
if self._fits_in_single_part(file_size):
|
|
69
|
+
return await self._upload_single_part_content(file_content, filename, content_type)
|
|
206
70
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
filename=filename,
|
|
210
|
-
content_type=content_type,
|
|
211
|
-
content_length=file_size,
|
|
212
|
-
mode=UploadMode.MULTI_PART,
|
|
71
|
+
return await self._upload_multi_part_content(
|
|
72
|
+
filename, content_type, file_size, self._file_reader.bytes_to_chunks(file_content)
|
|
213
73
|
)
|
|
214
74
|
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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(
|
|
75
|
+
async def _upload_single_part_content(
|
|
76
|
+
self, content: bytes, filename: str, content_type: str | None
|
|
77
|
+
) -> FileUploadResponse:
|
|
78
|
+
file_upload = await self._client.create_single_part_upload(
|
|
246
79
|
filename=filename,
|
|
247
80
|
content_type=content_type,
|
|
248
|
-
content_length=file_size,
|
|
249
|
-
mode=UploadMode.SINGLE_PART,
|
|
250
81
|
)
|
|
251
82
|
|
|
252
|
-
|
|
253
|
-
return None
|
|
254
|
-
|
|
255
|
-
# Send file content
|
|
256
|
-
from io import BytesIO
|
|
257
|
-
|
|
258
|
-
success = await self.client.send_file_upload(
|
|
83
|
+
await self._client.send_file_content(
|
|
259
84
|
file_upload_id=file_upload.id,
|
|
260
|
-
file_content=
|
|
85
|
+
file_content=content,
|
|
261
86
|
filename=filename,
|
|
262
87
|
)
|
|
263
88
|
|
|
264
|
-
|
|
89
|
+
self.logger.info("Single-part content sent, waiting for completion... (ID: %s)", file_upload.id)
|
|
90
|
+
return await self._wait_for_completion(file_upload.id)
|
|
265
91
|
|
|
266
|
-
async def
|
|
92
|
+
async def _upload_multi_part_content(
|
|
267
93
|
self,
|
|
268
|
-
file_content: bytes,
|
|
269
94
|
filename: str,
|
|
270
95
|
content_type: str | None,
|
|
271
96
|
file_size: int,
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
97
|
+
chunk_generator: AsyncGenerator[bytes],
|
|
98
|
+
) -> FileUploadResponse:
|
|
99
|
+
part_count = self._calculate_part_count(file_size)
|
|
100
|
+
|
|
101
|
+
file_upload = await self._client.create_multi_part_upload(
|
|
276
102
|
filename=filename,
|
|
277
103
|
content_type=content_type,
|
|
278
|
-
|
|
279
|
-
mode=UploadMode.MULTI_PART,
|
|
104
|
+
number_of_parts=part_count,
|
|
280
105
|
)
|
|
281
106
|
|
|
282
|
-
|
|
283
|
-
return None
|
|
107
|
+
await self._send_parts(file_upload.id, filename, part_count, chunk_generator)
|
|
284
108
|
|
|
285
|
-
|
|
286
|
-
success = await self._upload_bytes_parts(file_upload.id, file_content)
|
|
109
|
+
await self._client.complete_upload(file_upload.id)
|
|
287
110
|
|
|
288
|
-
|
|
289
|
-
|
|
111
|
+
self.logger.info("Multi-part content sent, waiting for completion... (ID: %s)", file_upload.id)
|
|
112
|
+
return await self._wait_for_completion(file_upload.id)
|
|
290
113
|
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
114
|
+
async def _send_parts(
|
|
115
|
+
self,
|
|
116
|
+
file_upload_id: str,
|
|
117
|
+
filename: str,
|
|
118
|
+
total_parts: int,
|
|
119
|
+
chunk_generator: AsyncGenerator[bytes],
|
|
120
|
+
) -> None:
|
|
296
121
|
part_number = 1
|
|
297
|
-
total_parts = (file_size + self.MULTI_PART_CHUNK_SIZE - 1) // self.MULTI_PART_CHUNK_SIZE
|
|
298
|
-
|
|
299
122
|
try:
|
|
300
|
-
|
|
123
|
+
async for chunk in chunk_generator:
|
|
124
|
+
await self._client.send_file_content(
|
|
125
|
+
file_upload_id=file_upload_id,
|
|
126
|
+
file_content=chunk,
|
|
127
|
+
filename=filename,
|
|
128
|
+
part_number=part_number,
|
|
129
|
+
)
|
|
301
130
|
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
chunk = await file.read(self.MULTI_PART_CHUNK_SIZE)
|
|
305
|
-
if not chunk:
|
|
306
|
-
break
|
|
131
|
+
self.logger.debug("Uploaded part %d/%d", part_number, total_parts)
|
|
132
|
+
part_number += 1
|
|
307
133
|
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
part_number=part_number,
|
|
313
|
-
)
|
|
134
|
+
except Exception as e:
|
|
135
|
+
raise UploadFailedError(
|
|
136
|
+
file_upload_id=file_upload_id, reason=f"Failed to upload part {part_number}/{total_parts}: {e}"
|
|
137
|
+
) from e
|
|
314
138
|
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
return False
|
|
139
|
+
def _fits_in_single_part(self, file_size: int) -> bool:
|
|
140
|
+
return file_size <= NOTION_SINGLE_PART_MAX_SIZE
|
|
318
141
|
|
|
319
|
-
|
|
320
|
-
|
|
142
|
+
def _guess_content_type(self, filename: str) -> str | None:
|
|
143
|
+
content_type, _ = mimetypes.guess_type(filename)
|
|
144
|
+
return content_type
|
|
321
145
|
|
|
322
|
-
|
|
323
|
-
|
|
146
|
+
def _calculate_part_count(self, file_size: int) -> int:
|
|
147
|
+
return (file_size + self._config.multi_part_chunk_size - 1) // self._config.multi_part_chunk_size
|
|
324
148
|
|
|
149
|
+
async def get_upload_status(self, file_upload_id: str) -> str:
|
|
150
|
+
try:
|
|
151
|
+
upload_info = await self._client.get_file_upload(file_upload_id)
|
|
152
|
+
return upload_info.status
|
|
325
153
|
except Exception as e:
|
|
326
|
-
|
|
327
|
-
return False
|
|
154
|
+
raise UploadFailedError(file_upload_id, reason=str(e)) from e
|
|
328
155
|
|
|
329
|
-
async def
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
156
|
+
async def _wait_for_completion(
|
|
157
|
+
self,
|
|
158
|
+
file_upload_id: str,
|
|
159
|
+
timeout_seconds: int | None = None,
|
|
160
|
+
) -> FileUploadResponse:
|
|
161
|
+
timeout = timeout_seconds or self._config.max_upload_timeout
|
|
333
162
|
|
|
334
|
-
|
|
335
|
-
|
|
163
|
+
try:
|
|
164
|
+
return await asyncio.wait_for(self._poll_status_until_complete(file_upload_id), timeout=timeout)
|
|
336
165
|
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
166
|
+
except TimeoutError as e:
|
|
167
|
+
raise UploadTimeoutError(file_upload_id, timeout) from e
|
|
168
|
+
|
|
169
|
+
async def _poll_status_until_complete(self, file_upload_id: str) -> FileUploadResponse:
|
|
170
|
+
while True:
|
|
171
|
+
upload_info = await self._client.get_file_upload(file_upload_id)
|
|
172
|
+
|
|
173
|
+
if upload_info.status == FileUploadStatus.UPLOADED:
|
|
174
|
+
self.logger.info("Upload completed: %s", file_upload_id)
|
|
175
|
+
return upload_info
|
|
342
176
|
|
|
343
|
-
if
|
|
344
|
-
|
|
345
|
-
return False
|
|
177
|
+
if upload_info.status == FileUploadStatus.FAILED:
|
|
178
|
+
raise UploadFailedError(file_upload_id)
|
|
346
179
|
|
|
347
|
-
self.
|
|
348
|
-
part_number += 1
|
|
180
|
+
await asyncio.sleep(self._config.poll_interval)
|
|
349
181
|
|
|
350
|
-
|
|
351
|
-
|
|
182
|
+
async def get_uploads(
|
|
183
|
+
self,
|
|
184
|
+
*,
|
|
185
|
+
filter_fn: Callable[[FileUploadQueryBuilder], FileUploadQueryBuilder] | None = None,
|
|
186
|
+
query: FileUploadQuery | None = None,
|
|
187
|
+
) -> list[FileUploadResponse]:
|
|
188
|
+
resolved_query = self._resolve_query(filter_fn=filter_fn, query=query)
|
|
189
|
+
return await self._client.list_file_uploads(query=resolved_query)
|
|
190
|
+
|
|
191
|
+
async def iter_uploads(
|
|
192
|
+
self,
|
|
193
|
+
*,
|
|
194
|
+
filter_fn: Callable[[FileUploadQueryBuilder], FileUploadQueryBuilder] | None = None,
|
|
195
|
+
query: FileUploadQuery | None = None,
|
|
196
|
+
) -> AsyncIterator[FileUploadResponse]:
|
|
197
|
+
resolved_query = self._resolve_query(filter_fn=filter_fn, query=query)
|
|
198
|
+
async for upload in self._client.list_file_uploads_stream(query=resolved_query):
|
|
199
|
+
yield upload
|
|
200
|
+
|
|
201
|
+
def _resolve_query(
|
|
202
|
+
self,
|
|
203
|
+
filter_fn: Callable[[FileUploadQueryBuilder], FileUploadQueryBuilder] | None = None,
|
|
204
|
+
query: FileUploadQuery | None = None,
|
|
205
|
+
) -> FileUploadQuery:
|
|
206
|
+
if filter_fn and query:
|
|
207
|
+
raise ValueError("Use either filter_fn OR query, not both")
|
|
208
|
+
|
|
209
|
+
if filter_fn:
|
|
210
|
+
builder = FileUploadQueryBuilder()
|
|
211
|
+
configured_builder = filter_fn(builder)
|
|
212
|
+
return configured_builder.build()
|
|
213
|
+
|
|
214
|
+
return query or FileUploadQuery()
|
|
@@ -0,0 +1,64 @@
|
|
|
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(filename: str, file_size_bytes: int) -> FileUploadLimitValidator:
|
|
64
|
+
return FileUploadLimitValidator(filename=filename, file_size_bytes=file_size_bytes)
|
|
@@ -0,0 +1,23 @@
|
|
|
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__(self, filename: str, file_upload_config: FileUploadConfig | None = None) -> None:
|
|
10
|
+
self._filename = filename
|
|
11
|
+
|
|
12
|
+
file_upload_config = file_upload_config or FileUploadConfig()
|
|
13
|
+
self._max_filename_bytes = file_upload_config.MAX_FILENAME_BYTES
|
|
14
|
+
|
|
15
|
+
@override
|
|
16
|
+
async def validate(self) -> None:
|
|
17
|
+
filename_bytes = len(self._filename.encode("utf-8"))
|
|
18
|
+
if filename_bytes > self._max_filename_bytes:
|
|
19
|
+
raise FilenameTooLongError(
|
|
20
|
+
filename=self._filename,
|
|
21
|
+
filename_bytes=filename_bytes,
|
|
22
|
+
max_filename_bytes=self._max_filename_bytes,
|
|
23
|
+
)
|