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/client.py
CHANGED
|
@@ -1,12 +1,9 @@
|
|
|
1
|
-
import
|
|
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,145 @@ 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 PaginatedResponse, paginate_notion_api, paginate_notion_api_generator
|
|
17
15
|
|
|
18
16
|
|
|
19
17
|
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(
|
|
18
|
+
async def create_single_part_upload(
|
|
26
19
|
self,
|
|
27
20
|
filename: str,
|
|
28
21
|
content_type: str | None = None,
|
|
29
|
-
|
|
30
|
-
|
|
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(
|
|
22
|
+
) -> FileUploadResponse:
|
|
23
|
+
return await self._create_upload(
|
|
45
24
|
filename=filename,
|
|
46
25
|
content_type=content_type,
|
|
47
|
-
|
|
48
|
-
|
|
26
|
+
mode=UploadMode.SINGLE_PART,
|
|
27
|
+
number_of_parts=None,
|
|
49
28
|
)
|
|
50
29
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
30
|
+
async def create_multi_part_upload(
|
|
31
|
+
self,
|
|
32
|
+
filename: str,
|
|
33
|
+
number_of_parts: int,
|
|
34
|
+
content_type: str | None = None,
|
|
35
|
+
) -> FileUploadResponse:
|
|
36
|
+
return await self._create_upload(
|
|
37
|
+
filename=filename,
|
|
38
|
+
content_type=content_type,
|
|
39
|
+
mode=UploadMode.MULTI_PART,
|
|
40
|
+
number_of_parts=number_of_parts,
|
|
41
|
+
)
|
|
60
42
|
|
|
61
|
-
async def
|
|
43
|
+
async def send_file_content(
|
|
62
44
|
self,
|
|
63
45
|
file_upload_id: str,
|
|
64
|
-
file_content:
|
|
65
|
-
filename: str
|
|
46
|
+
file_content: bytes,
|
|
47
|
+
filename: str,
|
|
66
48
|
part_number: int | None = None,
|
|
67
|
-
) ->
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
|
49
|
+
) -> FileUploadResponse:
|
|
50
|
+
await self._ensure_initialized()
|
|
51
|
+
|
|
52
|
+
url = self._build_send_url(file_upload_id)
|
|
53
|
+
files = {"file": (filename, file_content)}
|
|
54
|
+
data = self._build_part_number_data(part_number)
|
|
181
55
|
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
56
|
+
response = await self._send_multipart_request(url, files=files, data=data)
|
|
57
|
+
return FileUploadResponse.model_validate(response.json())
|
|
58
|
+
|
|
59
|
+
async def complete_upload(self, file_upload_id: str) -> FileUploadResponse:
|
|
60
|
+
request = FileUploadCompleteRequest()
|
|
61
|
+
response = await self.post(
|
|
62
|
+
f"file_uploads/{file_upload_id}/complete",
|
|
63
|
+
data=request.model_dump(),
|
|
64
|
+
)
|
|
65
|
+
return FileUploadResponse.model_validate(response)
|
|
66
|
+
|
|
67
|
+
async def get_file_upload(self, file_upload_id: str) -> FileUploadResponse:
|
|
68
|
+
response = await self.get(f"file_uploads/{file_upload_id}")
|
|
69
|
+
return FileUploadResponse.model_validate(response)
|
|
187
70
|
|
|
188
71
|
async def list_file_uploads(
|
|
189
|
-
self,
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
72
|
+
self,
|
|
73
|
+
query: FileUploadQuery | None = None,
|
|
74
|
+
) -> list[FileUploadResponse]:
|
|
75
|
+
query = query or FileUploadQuery()
|
|
76
|
+
return await paginate_notion_api(
|
|
77
|
+
lambda **kwargs: self._fetch_file_uploads_page(query=query, **kwargs),
|
|
78
|
+
total_results_limit=query.total_results_limit,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
async def list_file_uploads_stream(
|
|
82
|
+
self,
|
|
83
|
+
query: FileUploadQuery | None = None,
|
|
84
|
+
) -> AsyncGenerator[FileUploadResponse]:
|
|
85
|
+
query = query or FileUploadQuery()
|
|
86
|
+
async for upload in paginate_notion_api_generator(
|
|
87
|
+
lambda **kwargs: self._fetch_file_uploads_page(query=query, **kwargs),
|
|
88
|
+
total_results_limit=query.total_results_limit,
|
|
89
|
+
):
|
|
90
|
+
yield upload
|
|
91
|
+
|
|
92
|
+
async def _create_upload(
|
|
93
|
+
self,
|
|
94
|
+
filename: str,
|
|
95
|
+
mode: UploadMode,
|
|
96
|
+
content_type: str | None,
|
|
97
|
+
number_of_parts: int | None,
|
|
98
|
+
) -> FileUploadResponse:
|
|
99
|
+
request = FileUploadCreateRequest(
|
|
100
|
+
filename=filename,
|
|
101
|
+
content_type=content_type,
|
|
102
|
+
mode=mode,
|
|
103
|
+
number_of_parts=number_of_parts,
|
|
104
|
+
)
|
|
105
|
+
response = await self.post("file_uploads", data=request.model_dump())
|
|
106
|
+
return FileUploadResponse.model_validate(response)
|
|
107
|
+
|
|
108
|
+
async def _send_multipart_request(
|
|
109
|
+
self,
|
|
110
|
+
url: str,
|
|
111
|
+
files: dict,
|
|
112
|
+
data: dict | None = None,
|
|
113
|
+
) -> httpx.Response:
|
|
114
|
+
headers = self._build_multipart_headers()
|
|
115
|
+
|
|
116
|
+
async with httpx.AsyncClient(headers=headers, timeout=self.timeout) as client:
|
|
117
|
+
response = await client.post(url, files=files, data=data)
|
|
118
|
+
|
|
119
|
+
response.raise_for_status()
|
|
120
|
+
return response
|
|
121
|
+
|
|
122
|
+
async def _fetch_file_uploads_page(
|
|
123
|
+
self,
|
|
124
|
+
query: FileUploadQuery,
|
|
125
|
+
start_cursor: str | None = None,
|
|
126
|
+
**kwargs,
|
|
127
|
+
) -> PaginatedResponse:
|
|
128
|
+
params = query.model_dump(exclude_none=True)
|
|
129
|
+
params["page_size"] = min(query.page_size_limit or 100, 100)
|
|
130
|
+
|
|
202
131
|
if start_cursor:
|
|
203
132
|
params["start_cursor"] = start_cursor
|
|
204
133
|
|
|
205
134
|
response = await self.get("file_uploads", params=params)
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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
|
|
135
|
+
parsed = FileUploadListResponse.model_validate(response)
|
|
136
|
+
|
|
137
|
+
return PaginatedResponse(
|
|
138
|
+
results=parsed.results,
|
|
139
|
+
has_more=parsed.has_more,
|
|
140
|
+
next_cursor=parsed.next_cursor,
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
def _build_send_url(self, file_upload_id: str) -> str:
|
|
144
|
+
return f"{self.BASE_URL}/file_uploads/{file_upload_id}/send"
|
|
145
|
+
|
|
146
|
+
def _build_part_number_data(self, part_number: int | None) -> dict | None:
|
|
147
|
+
if part_number is not None:
|
|
148
|
+
return {"part_number": str(part_number)}
|
|
149
|
+
return None
|
|
150
|
+
|
|
151
|
+
def _build_multipart_headers(self) -> dict:
|
|
152
|
+
return {
|
|
153
|
+
"Authorization": f"Bearer {self.token}",
|
|
154
|
+
"Notion-Version": self.NOTION_VERSION,
|
|
155
|
+
}
|
|
@@ -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,32 @@
|
|
|
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, gt=0, description="Maximum time in seconds to wait for an upload to complete."
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
poll_interval: int = Field(default=2, gt=0, description="Interval in seconds for polling the upload status.")
|
|
29
|
+
|
|
30
|
+
base_upload_path: Path | None = Field(
|
|
31
|
+
default=None, description="Optional default base path for resolving relative file uploads."
|
|
32
|
+
)
|
|
@@ -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,54 @@
|
|
|
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(total_results_limit)
|
|
46
|
+
return self
|
|
47
|
+
|
|
48
|
+
def _validate_total_results_limit(self, value: int) -> int:
|
|
49
|
+
if not (1 <= value <= 100):
|
|
50
|
+
raise ValueError(f"total_results_limit must be between 1 and 100, got {value}")
|
|
51
|
+
return value
|
|
52
|
+
|
|
53
|
+
def build(self) -> FileUploadQuery:
|
|
54
|
+
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,78 @@
|
|
|
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("number_of_parts should not be provided for 'single_part' mode")
|
|
56
|
+
return self
|
|
57
|
+
|
|
58
|
+
def model_dump(self, **kwargs):
|
|
59
|
+
data = super().model_dump(**kwargs)
|
|
60
|
+
return {k: v for k, v in data.items() if v is not None}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class FileUploadSendData(BaseModel):
|
|
64
|
+
file: bytes
|
|
65
|
+
part_number: int | None = Field(None, ge=1)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class FileUploadCompleteRequest(BaseModel):
|
|
69
|
+
pass
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class FileUploadAttachment(BaseModel):
|
|
73
|
+
file_upload: dict[str, str]
|
|
74
|
+
name: str | None = None
|
|
75
|
+
|
|
76
|
+
@classmethod
|
|
77
|
+
def from_id(cls, file_upload_id: str, name: str | None = None):
|
|
78
|
+
return cls(type="file_upload", file_upload={"id": file_upload_id}, name=name)
|