notex-python 0.1.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.
- notex/__init__.py +105 -0
- notex/py.typed +1 -0
- notex_python-0.1.0.dist-info/METADATA +323 -0
- notex_python-0.1.0.dist-info/RECORD +15 -0
- notex_python-0.1.0.dist-info/WHEEL +5 -0
- notex_python-0.1.0.dist-info/licenses/LICENSE +21 -0
- notex_python-0.1.0.dist-info/top_level.txt +2 -0
- notex_sdk/__init__.py +108 -0
- notex_sdk/async_client.py +100 -0
- notex_sdk/client.py +496 -0
- notex_sdk/config.py +25 -0
- notex_sdk/errors.py +45 -0
- notex_sdk/models.py +429 -0
- notex_sdk/py.typed +1 -0
- notex_sdk/transport.py +140 -0
notex_sdk/client.py
ADDED
|
@@ -0,0 +1,496 @@
|
|
|
1
|
+
"""Synchronous, dependency-free client for API-key-enabled NoteX endpoints."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import time
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, BinaryIO, Dict, Mapping, Optional, Sequence, Union
|
|
9
|
+
from urllib.parse import quote, urlencode
|
|
10
|
+
|
|
11
|
+
from .config import (
|
|
12
|
+
CREDITS_ME_ENDPOINT,
|
|
13
|
+
CREDITS_QUOTA_ENDPOINT,
|
|
14
|
+
DEFAULT_BASE_URL,
|
|
15
|
+
FLASHCARDS_CREATE_ENDPOINT,
|
|
16
|
+
MINDMAP_CREATE_ENDPOINT,
|
|
17
|
+
NOTE_CREATE_ENDPOINT,
|
|
18
|
+
NOTE_VALIDATE_ENDPOINT,
|
|
19
|
+
PODCAST_CREATE_ENDPOINT,
|
|
20
|
+
PRESIGNED_URL_ENDPOINT,
|
|
21
|
+
QUIZ_CREATE_ENDPOINT,
|
|
22
|
+
QUIZ_VIDEO_CREATE_ENDPOINT,
|
|
23
|
+
SHORTS_CREATE_ENDPOINT,
|
|
24
|
+
SLIDE_CREATE_ENDPOINT,
|
|
25
|
+
TASK_RESULT_ENDPOINT,
|
|
26
|
+
TRANSLATE_CREATE_ENDPOINT,
|
|
27
|
+
USER_NOTES_ENDPOINT,
|
|
28
|
+
USER_PROFILE_ENDPOINT,
|
|
29
|
+
)
|
|
30
|
+
from .errors import (
|
|
31
|
+
NotexAPIError,
|
|
32
|
+
NotexAuthenticationError,
|
|
33
|
+
NotexTaskError,
|
|
34
|
+
NotexUploadError,
|
|
35
|
+
)
|
|
36
|
+
from .models import (
|
|
37
|
+
Credits,
|
|
38
|
+
NotesPage,
|
|
39
|
+
PresignedUpload,
|
|
40
|
+
Quota,
|
|
41
|
+
SourceValidation,
|
|
42
|
+
TaskResult,
|
|
43
|
+
TaskSubmission,
|
|
44
|
+
UserProfile,
|
|
45
|
+
)
|
|
46
|
+
from .transport import RetryPolicy, UrllibTransport
|
|
47
|
+
|
|
48
|
+
Headers = Optional[Mapping[str, str]]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class _RedactedHeaders(dict):
|
|
52
|
+
"""Request headers whose repr never exposes authentication credentials."""
|
|
53
|
+
|
|
54
|
+
_SENSITIVE_NAMES = {"authorization", "x-api-key"}
|
|
55
|
+
|
|
56
|
+
def __repr__(self) -> str:
|
|
57
|
+
redacted = {
|
|
58
|
+
key: "<redacted>" if key.lower() in self._SENSITIVE_NAMES else value
|
|
59
|
+
for key, value in self.items()
|
|
60
|
+
}
|
|
61
|
+
return repr(redacted)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class NotexClient:
|
|
65
|
+
"""A request-scoped client for one NoteX API key.
|
|
66
|
+
|
|
67
|
+
In a multi-user application, create this client from the credential
|
|
68
|
+
resolved for the current request or background job. The SDK never stores,
|
|
69
|
+
creates, lists, or deletes user API keys.
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
def __init__(
|
|
73
|
+
self,
|
|
74
|
+
api_key: Optional[str] = None,
|
|
75
|
+
*,
|
|
76
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
77
|
+
auth_header: str = "X-API-Key",
|
|
78
|
+
timeout: float = 30.0,
|
|
79
|
+
retry_policy: Optional[RetryPolicy] = None,
|
|
80
|
+
transport: Optional[UrllibTransport] = None,
|
|
81
|
+
) -> None:
|
|
82
|
+
self.api_key = api_key
|
|
83
|
+
self.base_url = base_url.rstrip("/")
|
|
84
|
+
self.auth_header = auth_header
|
|
85
|
+
self.timeout = timeout
|
|
86
|
+
|
|
87
|
+
if self.auth_header not in {"X-API-Key", "Authorization"}:
|
|
88
|
+
raise ValueError("auth_header must be 'X-API-Key' or 'Authorization'")
|
|
89
|
+
if self.api_key and not self.api_key.startswith("ntx_"):
|
|
90
|
+
raise ValueError("NoteX API keys must start with 'ntx_'")
|
|
91
|
+
self._transport = transport or UrllibTransport(timeout=timeout, retry_policy=retry_policy)
|
|
92
|
+
|
|
93
|
+
def get_credits(self, *, endpoint: str = CREDITS_ME_ENDPOINT, base_url: Optional[str] = None) -> Credits:
|
|
94
|
+
return Credits.from_response(self.request("GET", endpoint, base_url=base_url))
|
|
95
|
+
|
|
96
|
+
def get_quota(self, *, endpoint: str = CREDITS_QUOTA_ENDPOINT, base_url: Optional[str] = None) -> Quota:
|
|
97
|
+
return Quota.from_response(self.request("GET", endpoint, base_url=base_url))
|
|
98
|
+
|
|
99
|
+
def get_profile(self, *, endpoint: str = USER_PROFILE_ENDPOINT, base_url: Optional[str] = None) -> UserProfile:
|
|
100
|
+
return UserProfile.from_response(self.request("GET", endpoint, base_url=base_url))
|
|
101
|
+
|
|
102
|
+
def list_notes(
|
|
103
|
+
self,
|
|
104
|
+
*,
|
|
105
|
+
note_type: Optional[str] = None,
|
|
106
|
+
tab: Optional[str] = None,
|
|
107
|
+
limit: Optional[int] = None,
|
|
108
|
+
cursor: Optional[str] = None,
|
|
109
|
+
page: Optional[int] = None,
|
|
110
|
+
sort_field: Optional[str] = None,
|
|
111
|
+
sort_order: Optional[int] = None,
|
|
112
|
+
endpoint: str = USER_NOTES_ENDPOINT,
|
|
113
|
+
base_url: Optional[str] = None,
|
|
114
|
+
) -> NotesPage:
|
|
115
|
+
if sort_order is not None and sort_order not in {-1, 1}:
|
|
116
|
+
raise ValueError("sort_order must be 1 (ascending) or -1 (descending)")
|
|
117
|
+
if limit is not None and limit < 1:
|
|
118
|
+
raise ValueError("limit must be greater than 0")
|
|
119
|
+
if page is not None and page < 1:
|
|
120
|
+
raise ValueError("page must be greater than 0")
|
|
121
|
+
return NotesPage.from_response(
|
|
122
|
+
self.request(
|
|
123
|
+
"GET",
|
|
124
|
+
endpoint,
|
|
125
|
+
params={
|
|
126
|
+
key: value
|
|
127
|
+
for key, value in {
|
|
128
|
+
"type": note_type,
|
|
129
|
+
"tab": tab,
|
|
130
|
+
"limit": limit,
|
|
131
|
+
"cursor": cursor,
|
|
132
|
+
"page": page,
|
|
133
|
+
"sort_field": sort_field,
|
|
134
|
+
"sort_order": sort_order,
|
|
135
|
+
}.items()
|
|
136
|
+
if value is not None
|
|
137
|
+
},
|
|
138
|
+
base_url=base_url,
|
|
139
|
+
)
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
def _request_presigned_upload(
|
|
143
|
+
self,
|
|
144
|
+
file_name: str,
|
|
145
|
+
*,
|
|
146
|
+
is_public: Optional[bool] = None,
|
|
147
|
+
endpoint: str = PRESIGNED_URL_ENDPOINT,
|
|
148
|
+
base_url: Optional[str] = None,
|
|
149
|
+
) -> PresignedUpload:
|
|
150
|
+
self._require_text(file_name, "file_name")
|
|
151
|
+
params: Dict[str, Any] = {"file_name": file_name}
|
|
152
|
+
if is_public is not None:
|
|
153
|
+
params["is_public"] = is_public
|
|
154
|
+
return PresignedUpload.from_response(
|
|
155
|
+
self.request("GET", endpoint, params=params, base_url=base_url)
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
def _upload_file(
|
|
159
|
+
self,
|
|
160
|
+
upload: PresignedUpload,
|
|
161
|
+
file_path: Union[str, Path],
|
|
162
|
+
*,
|
|
163
|
+
content_type: Optional[str] = None,
|
|
164
|
+
) -> None:
|
|
165
|
+
path = Path(file_path)
|
|
166
|
+
self._require_text(upload.upload_url, "upload_url")
|
|
167
|
+
try:
|
|
168
|
+
size = path.stat().st_size
|
|
169
|
+
headers = dict(upload.headers)
|
|
170
|
+
signed_content_type = self._get_header(headers, "Content-Type")
|
|
171
|
+
if content_type and signed_content_type and content_type != signed_content_type:
|
|
172
|
+
raise NotexUploadError(
|
|
173
|
+
"content_type does not match the Content-Type returned by NoteX presign"
|
|
174
|
+
)
|
|
175
|
+
if content_type and not signed_content_type:
|
|
176
|
+
headers["Content-Type"] = content_type
|
|
177
|
+
if not self._get_header(headers, "Content-Length"):
|
|
178
|
+
headers["Content-Length"] = str(size)
|
|
179
|
+
with path.open("rb") as file_handle:
|
|
180
|
+
self._send(
|
|
181
|
+
url=upload.upload_url,
|
|
182
|
+
method=upload.method,
|
|
183
|
+
headers=headers,
|
|
184
|
+
content=file_handle,
|
|
185
|
+
)
|
|
186
|
+
except OSError as exc:
|
|
187
|
+
raise NotexUploadError(f"Could not read file '{path.name}' for NoteX upload") from exc
|
|
188
|
+
|
|
189
|
+
def create_note_from_file(
|
|
190
|
+
self,
|
|
191
|
+
file_path: Union[str, Path],
|
|
192
|
+
*,
|
|
193
|
+
upload_file_name: Optional[str] = None,
|
|
194
|
+
is_public: Optional[bool] = None,
|
|
195
|
+
language_hints: Optional[Sequence[str]] = None,
|
|
196
|
+
use_ocr: Optional[bool] = None,
|
|
197
|
+
summary_style: Optional[str] = None,
|
|
198
|
+
writing_style: Optional[str] = None,
|
|
199
|
+
human_style: Optional[str] = None,
|
|
200
|
+
is_record: Optional[bool] = None,
|
|
201
|
+
bot_id: Optional[str] = None,
|
|
202
|
+
record_session_id: Optional[str] = None,
|
|
203
|
+
device_meeting_id: Optional[str] = None,
|
|
204
|
+
target_language: Optional[str] = None,
|
|
205
|
+
content_type: Optional[str] = None,
|
|
206
|
+
presigned_endpoint: str = PRESIGNED_URL_ENDPOINT,
|
|
207
|
+
create_endpoint: str = NOTE_CREATE_ENDPOINT,
|
|
208
|
+
base_url: Optional[str] = None,
|
|
209
|
+
headers: Headers = None,
|
|
210
|
+
) -> TaskSubmission:
|
|
211
|
+
"""Upload a local file internally and submit asynchronous note creation.
|
|
212
|
+
|
|
213
|
+
This method returns as soon as NoteX returns a task id. It never polls
|
|
214
|
+
the task; call it from an application worker rather than a web handler.
|
|
215
|
+
"""
|
|
216
|
+
|
|
217
|
+
path = Path(file_path)
|
|
218
|
+
if not path.is_file():
|
|
219
|
+
raise NotexUploadError(f"File does not exist or is not a file: {path}")
|
|
220
|
+
if upload_file_name is not None:
|
|
221
|
+
self._require_text(upload_file_name, "upload_file_name")
|
|
222
|
+
try:
|
|
223
|
+
upload = self._request_presigned_upload(
|
|
224
|
+
upload_file_name or path.name,
|
|
225
|
+
is_public=is_public,
|
|
226
|
+
endpoint=presigned_endpoint,
|
|
227
|
+
base_url=base_url,
|
|
228
|
+
)
|
|
229
|
+
self._upload_file(upload, path, content_type=content_type)
|
|
230
|
+
return self.create_note(
|
|
231
|
+
file_url=upload.file_url,
|
|
232
|
+
language_hints=language_hints,
|
|
233
|
+
use_ocr=use_ocr,
|
|
234
|
+
summary_style=summary_style,
|
|
235
|
+
writing_style=writing_style,
|
|
236
|
+
human_style=human_style,
|
|
237
|
+
is_record=is_record,
|
|
238
|
+
bot_id=bot_id,
|
|
239
|
+
record_session_id=record_session_id,
|
|
240
|
+
device_meeting_id=device_meeting_id,
|
|
241
|
+
target_language=target_language,
|
|
242
|
+
endpoint=create_endpoint,
|
|
243
|
+
base_url=base_url,
|
|
244
|
+
headers=headers,
|
|
245
|
+
)
|
|
246
|
+
except NotexAPIError:
|
|
247
|
+
raise
|
|
248
|
+
except OSError as exc:
|
|
249
|
+
raise NotexUploadError(f"Could not prepare '{path.name}' for NoteX upload") from exc
|
|
250
|
+
|
|
251
|
+
def validate_source(
|
|
252
|
+
self,
|
|
253
|
+
*,
|
|
254
|
+
web_url: Optional[str] = None,
|
|
255
|
+
file_url: Optional[str] = None,
|
|
256
|
+
endpoint: str = NOTE_VALIDATE_ENDPOINT,
|
|
257
|
+
base_url: Optional[str] = None,
|
|
258
|
+
headers: Headers = None,
|
|
259
|
+
) -> SourceValidation:
|
|
260
|
+
return SourceValidation.from_response(
|
|
261
|
+
self.request(
|
|
262
|
+
"POST",
|
|
263
|
+
endpoint,
|
|
264
|
+
body=self._source_body(web_url=web_url, file_url=file_url),
|
|
265
|
+
base_url=base_url,
|
|
266
|
+
headers=headers,
|
|
267
|
+
)
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
def create_note(
|
|
271
|
+
self,
|
|
272
|
+
*,
|
|
273
|
+
web_url: Optional[str] = None,
|
|
274
|
+
file_url: Optional[str] = None,
|
|
275
|
+
language_hints: Optional[Sequence[str]] = None,
|
|
276
|
+
use_ocr: Optional[bool] = None,
|
|
277
|
+
summary_style: Optional[str] = None,
|
|
278
|
+
writing_style: Optional[str] = None,
|
|
279
|
+
human_style: Optional[str] = None,
|
|
280
|
+
is_record: Optional[bool] = None,
|
|
281
|
+
bot_id: Optional[str] = None,
|
|
282
|
+
record_session_id: Optional[str] = None,
|
|
283
|
+
device_meeting_id: Optional[str] = None,
|
|
284
|
+
target_language: Optional[str] = None,
|
|
285
|
+
endpoint: str = NOTE_CREATE_ENDPOINT,
|
|
286
|
+
base_url: Optional[str] = None,
|
|
287
|
+
headers: Headers = None,
|
|
288
|
+
) -> TaskSubmission:
|
|
289
|
+
body = self._source_body(web_url=web_url, file_url=file_url)
|
|
290
|
+
optional_values = {
|
|
291
|
+
"language_hints": list(language_hints) if language_hints is not None else None,
|
|
292
|
+
"use_ocr": use_ocr,
|
|
293
|
+
"summary_style": summary_style,
|
|
294
|
+
"writing_style": writing_style,
|
|
295
|
+
"human_style": human_style,
|
|
296
|
+
"is_record": is_record,
|
|
297
|
+
"bot_id": bot_id,
|
|
298
|
+
"record_session_id": record_session_id,
|
|
299
|
+
"device_meeting_id": device_meeting_id,
|
|
300
|
+
"target_language": target_language,
|
|
301
|
+
}
|
|
302
|
+
body.update({key: value for key, value in optional_values.items() if value is not None})
|
|
303
|
+
return TaskSubmission.from_response(
|
|
304
|
+
self.request("POST", endpoint, body=body, base_url=base_url, headers=headers)
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
def create_note_from_url(self, web_url: str, **options: Any) -> TaskSubmission:
|
|
308
|
+
return self.create_note(web_url=web_url, **options)
|
|
309
|
+
|
|
310
|
+
def create_note_from_file_url(self, file_url: str, **options: Any) -> TaskSubmission:
|
|
311
|
+
return self.create_note(file_url=file_url, **options)
|
|
312
|
+
|
|
313
|
+
def create_flashcards(
|
|
314
|
+
self,
|
|
315
|
+
note_id: str,
|
|
316
|
+
*,
|
|
317
|
+
num_cards: Optional[Union[int, str]] = None,
|
|
318
|
+
set_name: Optional[str] = None,
|
|
319
|
+
difficulty: Optional[str] = None,
|
|
320
|
+
custom_prompt: Optional[str] = None,
|
|
321
|
+
community: Optional[bool] = None,
|
|
322
|
+
endpoint: str = FLASHCARDS_CREATE_ENDPOINT,
|
|
323
|
+
base_url: Optional[str] = None,
|
|
324
|
+
headers: Headers = None,
|
|
325
|
+
) -> TaskSubmission:
|
|
326
|
+
return self._submit_form_task(endpoint, {"note_id": note_id, "num_cards": num_cards, "set_name": set_name, "difficulty": difficulty, "custom_prompt": custom_prompt, "community": community}, base_url, headers)
|
|
327
|
+
|
|
328
|
+
def create_mindmap(self, note_id: str, *, community: Optional[bool] = None, endpoint: str = MINDMAP_CREATE_ENDPOINT, base_url: Optional[str] = None, headers: Headers = None) -> TaskSubmission:
|
|
329
|
+
return self._submit_form_task(endpoint, {"note_id": note_id, "community": community}, base_url, headers)
|
|
330
|
+
|
|
331
|
+
def create_podcast(
|
|
332
|
+
self,
|
|
333
|
+
note_id: str,
|
|
334
|
+
duration: int,
|
|
335
|
+
*,
|
|
336
|
+
speakers: Optional[int] = None,
|
|
337
|
+
voice: Optional[str] = None,
|
|
338
|
+
voice1: Optional[str] = None,
|
|
339
|
+
voice2: Optional[str] = None,
|
|
340
|
+
community: Optional[bool] = None,
|
|
341
|
+
endpoint: str = PODCAST_CREATE_ENDPOINT,
|
|
342
|
+
base_url: Optional[str] = None,
|
|
343
|
+
headers: Headers = None,
|
|
344
|
+
) -> TaskSubmission:
|
|
345
|
+
if duration <= 0:
|
|
346
|
+
raise ValueError("duration must be greater than 0")
|
|
347
|
+
return self._submit_form_task(endpoint, {"note_id": note_id, "duration": duration, "speakers": speakers, "voice": voice, "voice1": voice1, "voice2": voice2, "community": community}, base_url, headers)
|
|
348
|
+
|
|
349
|
+
def create_quiz(self, note_id: str, *, num_questions: Optional[Union[int, str]] = None, set_name: Optional[str] = None, difficulty: Optional[str] = None, custom_prompt: Optional[str] = None, community: Optional[bool] = None, endpoint: str = QUIZ_CREATE_ENDPOINT, base_url: Optional[str] = None, headers: Headers = None) -> TaskSubmission:
|
|
350
|
+
return self._submit_form_task(endpoint, {"note_id": note_id, "num_questions": num_questions, "set_name": set_name, "difficulty": difficulty, "custom_prompt": custom_prompt, "community": community}, base_url, headers)
|
|
351
|
+
|
|
352
|
+
def create_shorts(self, note_id: str, voice_id: str, *, clip_duration: Optional[int] = None, is_audio_only: Optional[bool] = None, bg_id: Optional[str] = None, endpoint: str = SHORTS_CREATE_ENDPOINT, base_url: Optional[str] = None, headers: Headers = None) -> TaskSubmission:
|
|
353
|
+
return self._submit_form_task(endpoint, {"note_id": note_id, "voice_id": voice_id, "clip_duration": clip_duration, "is_audio_only": is_audio_only, "bg_id": bg_id}, base_url, headers)
|
|
354
|
+
|
|
355
|
+
def create_quiz_video(self, note_id: str, voice_id: str, *, bg_id: Optional[str] = None, template_id: Optional[str] = None, endpoint: str = QUIZ_VIDEO_CREATE_ENDPOINT, base_url: Optional[str] = None, headers: Headers = None) -> TaskSubmission:
|
|
356
|
+
return self._submit_form_task(endpoint, {"note_id": note_id, "voice_id": voice_id, "bg_id": bg_id, "template_id": template_id}, base_url, headers)
|
|
357
|
+
|
|
358
|
+
def create_slide(self, note_id: str, template_id: str, *, language: Optional[str] = None, slide_count_range: Optional[str] = None, community: Optional[bool] = None, endpoint: str = SLIDE_CREATE_ENDPOINT, base_url: Optional[str] = None, headers: Headers = None) -> TaskSubmission:
|
|
359
|
+
return self._submit_form_task(endpoint, {"note_id": note_id, "template_id": template_id, "language": language, "slide_count_range": slide_count_range, "community": community}, base_url, headers)
|
|
360
|
+
|
|
361
|
+
def create_translation(self, note_id: str, language: str, *, only_summary: Optional[bool] = None, community: Optional[bool] = None, endpoint: str = TRANSLATE_CREATE_ENDPOINT, base_url: Optional[str] = None, headers: Headers = None) -> TaskSubmission:
|
|
362
|
+
return self._submit_form_task(endpoint, {"note_id": note_id, "language": language, "only_summary": only_summary, "community": community}, base_url, headers)
|
|
363
|
+
|
|
364
|
+
def get_task_result(self, task_id: str, *, endpoint: str = TASK_RESULT_ENDPOINT, base_url: Optional[str] = None) -> TaskResult:
|
|
365
|
+
self._require_text(task_id, "task_id")
|
|
366
|
+
return TaskResult.from_response(
|
|
367
|
+
self.request("GET", endpoint.format(task_id=quote(task_id.strip(), safe="")), base_url=base_url)
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
def wait_for_task(self, task_id: str, *, poll_interval: float = 5.0, timeout: float = 120.0, sleep_fn=time.sleep, endpoint: str = TASK_RESULT_ENDPOINT, base_url: Optional[str] = None) -> TaskResult:
|
|
371
|
+
if poll_interval < 0:
|
|
372
|
+
raise ValueError("poll_interval cannot be negative")
|
|
373
|
+
if timeout <= 0:
|
|
374
|
+
raise ValueError("timeout must be greater than 0")
|
|
375
|
+
started_at = time.monotonic()
|
|
376
|
+
while True:
|
|
377
|
+
result = self.get_task_result(task_id, endpoint=endpoint, base_url=base_url)
|
|
378
|
+
if result.status == "SUCCESS":
|
|
379
|
+
return result
|
|
380
|
+
if result.status == "FAILURE":
|
|
381
|
+
raise NotexTaskError(result.error or "NoteX task failed", error_key="task_failure", body=self._task_body(result))
|
|
382
|
+
if time.monotonic() - started_at >= timeout:
|
|
383
|
+
raise NotexTaskError(f"NoteX task timed out after {timeout:g} seconds", error_key="task_timeout", body=self._task_body(result))
|
|
384
|
+
sleep_fn(poll_interval)
|
|
385
|
+
|
|
386
|
+
def request(
|
|
387
|
+
self,
|
|
388
|
+
method: str,
|
|
389
|
+
path: str,
|
|
390
|
+
*,
|
|
391
|
+
params: Optional[Mapping[str, Any]] = None,
|
|
392
|
+
body: Optional[Mapping[str, Any]] = None,
|
|
393
|
+
form: Optional[Mapping[str, Any]] = None,
|
|
394
|
+
content: Optional[bytes] = None,
|
|
395
|
+
headers: Headers = None,
|
|
396
|
+
base_url: Optional[str] = None,
|
|
397
|
+
retryable: Optional[bool] = None,
|
|
398
|
+
) -> Dict[str, Any]:
|
|
399
|
+
"""Call a NoteX API endpoint with JSON, form, or binary request data."""
|
|
400
|
+
|
|
401
|
+
if not self.api_key:
|
|
402
|
+
raise NotexAuthenticationError("No NoteX API key supplied", status_code=401, error_key="not_authenticated")
|
|
403
|
+
supplied_bodies = sum(value is not None for value in (body, form, content))
|
|
404
|
+
if supplied_bodies > 1:
|
|
405
|
+
raise ValueError("use only one of body, form, or content")
|
|
406
|
+
base = (base_url or self.base_url).rstrip("/")
|
|
407
|
+
url = f"{base}/{path.lstrip('/')}"
|
|
408
|
+
if params:
|
|
409
|
+
url = f"{url}?{urlencode(self._normalize_values(params), doseq=True)}"
|
|
410
|
+
request_headers = self._auth_headers(headers)
|
|
411
|
+
encoded_content: Optional[bytes] = content
|
|
412
|
+
if body is not None:
|
|
413
|
+
request_headers.setdefault("Content-Type", "application/json")
|
|
414
|
+
encoded_content = json.dumps(body).encode("utf-8")
|
|
415
|
+
elif form is not None:
|
|
416
|
+
request_headers.setdefault("Content-Type", "application/x-www-form-urlencoded")
|
|
417
|
+
encoded_content = urlencode(self._normalize_values(form), doseq=True).encode("utf-8")
|
|
418
|
+
return self._send(
|
|
419
|
+
url=url,
|
|
420
|
+
method=method,
|
|
421
|
+
headers=request_headers,
|
|
422
|
+
content=encoded_content,
|
|
423
|
+
retryable=retryable,
|
|
424
|
+
)
|
|
425
|
+
|
|
426
|
+
def _submit_form_task(self, endpoint: str, form: Mapping[str, Any], base_url: Optional[str], headers: Headers) -> TaskSubmission:
|
|
427
|
+
self._require_text(form.get("note_id"), "note_id")
|
|
428
|
+
return TaskSubmission.from_response(
|
|
429
|
+
self.request(
|
|
430
|
+
"POST",
|
|
431
|
+
endpoint,
|
|
432
|
+
form={key: value for key, value in form.items() if value is not None},
|
|
433
|
+
base_url=base_url,
|
|
434
|
+
headers=headers,
|
|
435
|
+
)
|
|
436
|
+
)
|
|
437
|
+
|
|
438
|
+
@staticmethod
|
|
439
|
+
def _source_body(*, web_url: Optional[str], file_url: Optional[str]) -> Dict[str, Any]:
|
|
440
|
+
supplied = [value for value in (web_url, file_url) if value and value.strip()]
|
|
441
|
+
if len(supplied) != 1:
|
|
442
|
+
raise ValueError("provide exactly one of web_url or file_url")
|
|
443
|
+
return {"web_url": web_url} if web_url and web_url.strip() else {"file_url": file_url}
|
|
444
|
+
|
|
445
|
+
@staticmethod
|
|
446
|
+
def _require_text(value: Any, name: str) -> None:
|
|
447
|
+
if not isinstance(value, str) or not value.strip():
|
|
448
|
+
raise ValueError(f"{name} is required")
|
|
449
|
+
|
|
450
|
+
@staticmethod
|
|
451
|
+
def _normalize_values(values: Mapping[str, Any]) -> Dict[str, Any]:
|
|
452
|
+
normalized: Dict[str, Any] = {}
|
|
453
|
+
for key, value in values.items():
|
|
454
|
+
if isinstance(value, bool):
|
|
455
|
+
normalized[key] = "true" if value else "false"
|
|
456
|
+
elif isinstance(value, (list, tuple)):
|
|
457
|
+
normalized[key] = ["true" if item is True else "false" if item is False else item for item in value]
|
|
458
|
+
else:
|
|
459
|
+
normalized[key] = value
|
|
460
|
+
return normalized
|
|
461
|
+
|
|
462
|
+
def _auth_headers(self, extra_headers: Headers) -> Dict[str, str]:
|
|
463
|
+
headers: Dict[str, str] = _RedactedHeaders({"Accept": "application/json"})
|
|
464
|
+
if self.auth_header == "Authorization":
|
|
465
|
+
headers["Authorization"] = f"Bearer {self.api_key}"
|
|
466
|
+
else:
|
|
467
|
+
headers["X-API-Key"] = str(self.api_key)
|
|
468
|
+
if extra_headers:
|
|
469
|
+
headers.update(extra_headers)
|
|
470
|
+
return headers
|
|
471
|
+
|
|
472
|
+
@staticmethod
|
|
473
|
+
def _get_header(headers: Mapping[str, str], name: str) -> Optional[str]:
|
|
474
|
+
target = name.lower()
|
|
475
|
+
return next((value for key, value in headers.items() if key.lower() == target), None)
|
|
476
|
+
|
|
477
|
+
def _send(
|
|
478
|
+
self,
|
|
479
|
+
*,
|
|
480
|
+
url: str,
|
|
481
|
+
method: str,
|
|
482
|
+
headers: Mapping[str, str],
|
|
483
|
+
content: Optional[Union[bytes, BinaryIO]],
|
|
484
|
+
retryable: Optional[bool] = None,
|
|
485
|
+
) -> Dict[str, Any]:
|
|
486
|
+
return self._transport.send(
|
|
487
|
+
url=url,
|
|
488
|
+
method=method,
|
|
489
|
+
headers=headers,
|
|
490
|
+
content=content,
|
|
491
|
+
retryable=retryable,
|
|
492
|
+
)
|
|
493
|
+
|
|
494
|
+
@staticmethod
|
|
495
|
+
def _task_body(result: TaskResult) -> Dict[str, Any]:
|
|
496
|
+
return {"status": result.status, "data": result.data, "error": result.error}
|
notex_sdk/config.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Centralized NoteX API defaults."""
|
|
2
|
+
|
|
3
|
+
DEFAULT_BASE_URL = "https://api.notexapp.com"
|
|
4
|
+
|
|
5
|
+
API_V1 = "/v1"
|
|
6
|
+
API_V2 = "/v2"
|
|
7
|
+
|
|
8
|
+
CREDITS_ME_ENDPOINT = f"{API_V2}/credits/me"
|
|
9
|
+
CREDITS_QUOTA_ENDPOINT = f"{API_V2}/credits/quota"
|
|
10
|
+
USER_PROFILE_ENDPOINT = f"{API_V1}/user/profile"
|
|
11
|
+
USER_NOTES_ENDPOINT = f"{API_V2}/user/notes"
|
|
12
|
+
TASK_RESULT_ENDPOINT = f"{API_V1}/create/tasks/{{task_id}}/result"
|
|
13
|
+
|
|
14
|
+
PRESIGNED_URL_ENDPOINT = f"{API_V1}/presigned-url"
|
|
15
|
+
NOTE_CREATE_ENDPOINT = "/v9/create/note"
|
|
16
|
+
NOTE_VALIDATE_ENDPOINT = "/v9/create/validate"
|
|
17
|
+
|
|
18
|
+
FLASHCARDS_CREATE_ENDPOINT = "/v6/create/flashcards"
|
|
19
|
+
MINDMAP_CREATE_ENDPOINT = f"{API_V2}/create/mindmap"
|
|
20
|
+
PODCAST_CREATE_ENDPOINT = f"{API_V1}/create/podcast"
|
|
21
|
+
QUIZ_CREATE_ENDPOINT = "/v6/create/quiz"
|
|
22
|
+
SHORTS_CREATE_ENDPOINT = f"{API_V2}/create/shorts"
|
|
23
|
+
QUIZ_VIDEO_CREATE_ENDPOINT = f"{API_V2}/create/quiz-video"
|
|
24
|
+
SLIDE_CREATE_ENDPOINT = f"{API_V2}/create/slide"
|
|
25
|
+
TRANSLATE_CREATE_ENDPOINT = f"{API_V2}/create/translate"
|
notex_sdk/errors.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Error types raised by the NoteX SDK."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Mapping, Optional
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class NotexAPIError(Exception):
|
|
9
|
+
"""An error returned by the NoteX API or the transport layer."""
|
|
10
|
+
|
|
11
|
+
def __init__(
|
|
12
|
+
self,
|
|
13
|
+
message: str,
|
|
14
|
+
*,
|
|
15
|
+
status_code: Optional[int] = None,
|
|
16
|
+
error_key: Optional[str] = None,
|
|
17
|
+
body: Optional[Mapping[str, Any]] = None,
|
|
18
|
+
retry_after: Optional[float] = None,
|
|
19
|
+
) -> None:
|
|
20
|
+
super().__init__(message)
|
|
21
|
+
self.message = message
|
|
22
|
+
self.status_code = status_code
|
|
23
|
+
self.error_key = error_key
|
|
24
|
+
self.body = dict(body or {})
|
|
25
|
+
self.retry_after = retry_after
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class NotexAuthenticationError(NotexAPIError):
|
|
29
|
+
"""The API key is missing, invalid, revoked, or expired."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class NotexPermissionError(NotexAPIError):
|
|
33
|
+
"""The authenticated account cannot perform the requested operation."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class NotexUploadError(NotexAPIError):
|
|
37
|
+
"""A local-file or presigned-upload operation could not be completed."""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class NotexRateLimitError(NotexAPIError):
|
|
41
|
+
"""The NoteX API rate limited the request."""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class NotexTaskError(NotexAPIError):
|
|
45
|
+
"""An asynchronous NoteX task failed or returned an invalid status."""
|