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/models.py
ADDED
|
@@ -0,0 +1,429 @@
|
|
|
1
|
+
"""Small, dependency-free typed models for NoteX API responses."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any, List, Literal, Mapping, Optional
|
|
7
|
+
|
|
8
|
+
TaskStatus = Literal["PENDING", "PROCESSING", "SUCCESS", "FAILURE"]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _data(response: Mapping[str, Any]) -> Mapping[str, Any]:
|
|
12
|
+
value = response.get("data", {})
|
|
13
|
+
if not isinstance(value, Mapping):
|
|
14
|
+
raise ValueError("NoteX response field 'data' must be an object")
|
|
15
|
+
return value
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class Credits:
|
|
20
|
+
balance: Optional[int]
|
|
21
|
+
total_credits: Optional[int]
|
|
22
|
+
reward_credits: Optional[int]
|
|
23
|
+
purchased_credits: Optional[int]
|
|
24
|
+
plan_code: Optional[str]
|
|
25
|
+
plan_name: Optional[str]
|
|
26
|
+
user_id: Optional[str]
|
|
27
|
+
user_name: Optional[str]
|
|
28
|
+
user_type: Optional[str]
|
|
29
|
+
role: Optional[str]
|
|
30
|
+
has_paid: Optional[bool]
|
|
31
|
+
has_purchased: Optional[bool]
|
|
32
|
+
meeting_access: Optional[bool]
|
|
33
|
+
renew_date: Optional[str]
|
|
34
|
+
enterprise: Mapping[str, Any]
|
|
35
|
+
raw: Mapping[str, Any]
|
|
36
|
+
|
|
37
|
+
@classmethod
|
|
38
|
+
def from_response(cls, response: Mapping[str, Any]) -> "Credits":
|
|
39
|
+
data = _data(response)
|
|
40
|
+
balance = cls._optional_int(data, "balance", "total_credits")
|
|
41
|
+
enterprise = data.get("enterprise")
|
|
42
|
+
return cls(
|
|
43
|
+
balance=balance,
|
|
44
|
+
total_credits=cls._optional_int(data, "total_credits", "balance"),
|
|
45
|
+
reward_credits=cls._optional_int(data, "reward_credits"),
|
|
46
|
+
purchased_credits=cls._optional_int(data, "purchased_credits"),
|
|
47
|
+
plan_code=cls._optional_text(data, "plan_code"),
|
|
48
|
+
plan_name=cls._optional_text(data, "plan_name"),
|
|
49
|
+
user_id=cls._optional_text(data, "user_id"),
|
|
50
|
+
user_name=cls._optional_text(data, "user_name"),
|
|
51
|
+
user_type=cls._optional_text(data, "user_type"),
|
|
52
|
+
role=cls._optional_text(data, "role"),
|
|
53
|
+
has_paid=cls._optional_bool(data, "is_paid_user", "hasPaid"),
|
|
54
|
+
has_purchased=cls._optional_bool(data, "has_purchased", "hasPurchased"),
|
|
55
|
+
meeting_access=cls._optional_bool(data, "meeting_access"),
|
|
56
|
+
renew_date=cls._optional_text(data, "renew_date"),
|
|
57
|
+
enterprise=dict(enterprise) if isinstance(enterprise, Mapping) else {},
|
|
58
|
+
raw=dict(data),
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
@staticmethod
|
|
62
|
+
def _optional_int(data: Mapping[str, Any], *keys: str) -> Optional[int]:
|
|
63
|
+
value = next((data[key] for key in keys if data.get(key) is not None), None)
|
|
64
|
+
return int(value) if value is not None else None
|
|
65
|
+
|
|
66
|
+
@staticmethod
|
|
67
|
+
def _optional_text(data: Mapping[str, Any], *keys: str) -> Optional[str]:
|
|
68
|
+
value = next((data[key] for key in keys if data.get(key) is not None), None)
|
|
69
|
+
return str(value) if value is not None else None
|
|
70
|
+
|
|
71
|
+
@staticmethod
|
|
72
|
+
def _optional_bool(data: Mapping[str, Any], *keys: str) -> Optional[bool]:
|
|
73
|
+
value = next((data[key] for key in keys if data.get(key) is not None), None)
|
|
74
|
+
return bool(value) if value is not None else None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass(frozen=True)
|
|
78
|
+
class Quota:
|
|
79
|
+
data: Mapping[str, Any]
|
|
80
|
+
|
|
81
|
+
@classmethod
|
|
82
|
+
def from_response(cls, response: Mapping[str, Any]) -> "Quota":
|
|
83
|
+
return cls(data=dict(_data(response)))
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@dataclass(frozen=True)
|
|
87
|
+
class UserProfile:
|
|
88
|
+
user_id: str
|
|
89
|
+
email: str
|
|
90
|
+
name: str
|
|
91
|
+
image: Optional[str]
|
|
92
|
+
role: str
|
|
93
|
+
preferences: Mapping[str, Any]
|
|
94
|
+
created_at: Optional[str]
|
|
95
|
+
|
|
96
|
+
@classmethod
|
|
97
|
+
def from_response(cls, response: Mapping[str, Any]) -> "UserProfile":
|
|
98
|
+
data = _data(response)
|
|
99
|
+
required = ("user_id", "email", "name", "role")
|
|
100
|
+
missing = [field for field in required if field not in data]
|
|
101
|
+
if missing:
|
|
102
|
+
raise ValueError(f"NoteX profile response is missing: {', '.join(missing)}")
|
|
103
|
+
return cls(
|
|
104
|
+
user_id=str(data["user_id"]),
|
|
105
|
+
email=str(data["email"]),
|
|
106
|
+
name=str(data["name"]),
|
|
107
|
+
image=str(data["image"]) if data.get("image") is not None else None,
|
|
108
|
+
role=str(data["role"]),
|
|
109
|
+
preferences=dict(data.get("preferences") or {}),
|
|
110
|
+
created_at=str(data["createdAt"]) if data.get("createdAt") is not None else None,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@dataclass(frozen=True)
|
|
115
|
+
class NoteItem:
|
|
116
|
+
note_id: str
|
|
117
|
+
title: str
|
|
118
|
+
folder_id: Optional[str]
|
|
119
|
+
type: str
|
|
120
|
+
created_at: str
|
|
121
|
+
updated_at: str
|
|
122
|
+
duration: Optional[int]
|
|
123
|
+
short_summary: Optional[str]
|
|
124
|
+
is_public: Optional[bool]
|
|
125
|
+
is_password_protected: Optional[bool]
|
|
126
|
+
share_link: Optional[str]
|
|
127
|
+
|
|
128
|
+
@classmethod
|
|
129
|
+
def from_mapping(cls, value: Mapping[str, Any]) -> "NoteItem":
|
|
130
|
+
required = ("note_id", "title", "type", "createdAt", "updatedAt")
|
|
131
|
+
missing = [field for field in required if field not in value]
|
|
132
|
+
if missing:
|
|
133
|
+
raise ValueError(f"NoteX note is missing: {', '.join(missing)}")
|
|
134
|
+
return cls(
|
|
135
|
+
note_id=str(value["note_id"]),
|
|
136
|
+
title=str(value["title"]),
|
|
137
|
+
folder_id=str(value["folder_id"]) if value.get("folder_id") is not None else None,
|
|
138
|
+
type=str(value["type"]),
|
|
139
|
+
created_at=str(value["createdAt"]),
|
|
140
|
+
updated_at=str(value["updatedAt"]),
|
|
141
|
+
duration=int(value["duration"]) if value.get("duration") is not None else None,
|
|
142
|
+
short_summary=(str(value["short_summary"]) if value.get("short_summary") is not None else None),
|
|
143
|
+
is_public=bool(value["is_public"]) if value.get("is_public") is not None else None,
|
|
144
|
+
is_password_protected=(
|
|
145
|
+
bool(value["is_password_protected"])
|
|
146
|
+
if value.get("is_password_protected") is not None
|
|
147
|
+
else None
|
|
148
|
+
),
|
|
149
|
+
share_link=str(value["share_link"]) if value.get("share_link") is not None else None,
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
@dataclass(frozen=True)
|
|
154
|
+
class NotesPage:
|
|
155
|
+
items: List[NoteItem]
|
|
156
|
+
total: Optional[int]
|
|
157
|
+
next_cursor: Optional[str]
|
|
158
|
+
has_more: Optional[bool]
|
|
159
|
+
page: Optional[int]
|
|
160
|
+
limit: Optional[int]
|
|
161
|
+
|
|
162
|
+
@classmethod
|
|
163
|
+
def from_response(cls, response: Mapping[str, Any]) -> "NotesPage":
|
|
164
|
+
raw_data = response.get("data", [])
|
|
165
|
+
if isinstance(raw_data, list):
|
|
166
|
+
raw_items = raw_data
|
|
167
|
+
metadata: Mapping[str, Any] = {}
|
|
168
|
+
elif isinstance(raw_data, Mapping):
|
|
169
|
+
metadata = raw_data
|
|
170
|
+
raw_items = raw_data.get("notes", raw_data.get("items", []))
|
|
171
|
+
else:
|
|
172
|
+
raise ValueError("NoteX notes response field 'data' must be an object or list")
|
|
173
|
+
if not isinstance(raw_items, list):
|
|
174
|
+
raise ValueError("NoteX notes response field 'notes' must be a list")
|
|
175
|
+
return cls(
|
|
176
|
+
items=[NoteItem.from_mapping(item) for item in raw_items if isinstance(item, Mapping)],
|
|
177
|
+
total=(
|
|
178
|
+
int(metadata["total_notes"])
|
|
179
|
+
if metadata.get("total_notes") is not None
|
|
180
|
+
else int(metadata["total"]) if metadata.get("total") is not None else None
|
|
181
|
+
),
|
|
182
|
+
next_cursor=(
|
|
183
|
+
str(metadata["next_cursor"])
|
|
184
|
+
if metadata.get("next_cursor") is not None
|
|
185
|
+
else None
|
|
186
|
+
),
|
|
187
|
+
has_more=bool(metadata["has_more"]) if metadata.get("has_more") is not None else None,
|
|
188
|
+
page=int(metadata["page"]) if metadata.get("page") is not None else None,
|
|
189
|
+
limit=int(metadata["limit"]) if metadata.get("limit") is not None else None,
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
@dataclass(frozen=True)
|
|
194
|
+
class PresignedUpload:
|
|
195
|
+
"""Internal normalized result of the presigned URL API."""
|
|
196
|
+
|
|
197
|
+
upload_url: str
|
|
198
|
+
file_url: str
|
|
199
|
+
file_name: Optional[str]
|
|
200
|
+
full_path: Optional[str]
|
|
201
|
+
method: str
|
|
202
|
+
headers: Mapping[str, str]
|
|
203
|
+
expires_at: Optional[str]
|
|
204
|
+
|
|
205
|
+
@classmethod
|
|
206
|
+
def from_response(cls, response: Mapping[str, Any]) -> "PresignedUpload":
|
|
207
|
+
data = _data(response)
|
|
208
|
+
upload_url = data.get("upload_url") or data.get("presigned_url")
|
|
209
|
+
file_url = data.get("file_url") or data.get("full_path")
|
|
210
|
+
if not upload_url or not file_url:
|
|
211
|
+
raise ValueError("NoteX presigned upload response must include upload_url and file_url")
|
|
212
|
+
raw_headers = data.get("headers") or {}
|
|
213
|
+
if not isinstance(raw_headers, Mapping):
|
|
214
|
+
raise ValueError("NoteX presigned upload response field 'headers' must be an object")
|
|
215
|
+
method = str(data.get("method") or "PUT").upper()
|
|
216
|
+
if method != "PUT":
|
|
217
|
+
raise ValueError(f"Unsupported NoteX presigned upload method: {method}")
|
|
218
|
+
return cls(
|
|
219
|
+
upload_url=str(upload_url),
|
|
220
|
+
file_url=str(file_url),
|
|
221
|
+
file_name=str(data["file_name"]) if data.get("file_name") is not None else None,
|
|
222
|
+
full_path=str(data["full_path"]) if data.get("full_path") is not None else None,
|
|
223
|
+
method=method,
|
|
224
|
+
headers={str(key): str(value) for key, value in raw_headers.items()},
|
|
225
|
+
expires_at=str(data["expires_at"]) if data.get("expires_at") is not None else None,
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
@dataclass(frozen=True)
|
|
230
|
+
class SourceValidation:
|
|
231
|
+
data: Mapping[str, Any]
|
|
232
|
+
|
|
233
|
+
@classmethod
|
|
234
|
+
def from_response(cls, response: Mapping[str, Any]) -> "SourceValidation":
|
|
235
|
+
return cls(data=dict(_data(response)))
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
@dataclass(frozen=True)
|
|
239
|
+
class TaskSubmission:
|
|
240
|
+
task_id: str
|
|
241
|
+
status: Optional[str]
|
|
242
|
+
user_id: Optional[str]
|
|
243
|
+
|
|
244
|
+
@classmethod
|
|
245
|
+
def from_response(cls, response: Mapping[str, Any]) -> "TaskSubmission":
|
|
246
|
+
data = _data(response)
|
|
247
|
+
if not data.get("task_id"):
|
|
248
|
+
raise ValueError("NoteX task submission response is missing 'task_id'")
|
|
249
|
+
return cls(
|
|
250
|
+
task_id=str(data["task_id"]),
|
|
251
|
+
status=str(response["status"]) if response.get("status") is not None else None,
|
|
252
|
+
user_id=str(data["user_id"]) if data.get("user_id") is not None else None,
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
@dataclass(frozen=True)
|
|
257
|
+
class TaskResult:
|
|
258
|
+
status: TaskStatus
|
|
259
|
+
data: Optional[Mapping[str, Any]]
|
|
260
|
+
error: Optional[str]
|
|
261
|
+
|
|
262
|
+
@classmethod
|
|
263
|
+
def from_response(cls, response: Mapping[str, Any]) -> "TaskResult":
|
|
264
|
+
status = str(response.get("status", "")).upper()
|
|
265
|
+
if status not in {"PENDING", "PROCESSING", "SUCCESS", "FAILURE"}:
|
|
266
|
+
raise ValueError(f"Unknown NoteX task status: {status or '<missing>'}")
|
|
267
|
+
value = response.get("data")
|
|
268
|
+
return cls(
|
|
269
|
+
status=status, # type: ignore[arg-type]
|
|
270
|
+
data=dict(value) if isinstance(value, Mapping) else None,
|
|
271
|
+
error=str(response["error"]) if response.get("error") is not None else None,
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
def success_data(self) -> Mapping[str, Any]:
|
|
275
|
+
if self.status != "SUCCESS" or self.data is None:
|
|
276
|
+
raise ValueError("Task result is not a successful response with data")
|
|
277
|
+
return self.data
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
@dataclass(frozen=True)
|
|
281
|
+
class GeneratedNote:
|
|
282
|
+
note_id: str
|
|
283
|
+
title: Optional[str]
|
|
284
|
+
summary: Optional[str]
|
|
285
|
+
|
|
286
|
+
@classmethod
|
|
287
|
+
def from_task_result(cls, result: TaskResult) -> "GeneratedNote":
|
|
288
|
+
data = result.success_data()
|
|
289
|
+
if not data.get("note_id"):
|
|
290
|
+
raise ValueError("Generated note result is missing 'note_id'")
|
|
291
|
+
return cls(
|
|
292
|
+
note_id=str(data["note_id"]),
|
|
293
|
+
title=str(data["title"]) if data.get("title") is not None else None,
|
|
294
|
+
summary=str(data["summary"]) if data.get("summary") is not None else None,
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
@dataclass(frozen=True)
|
|
299
|
+
class Flashcard:
|
|
300
|
+
front: str
|
|
301
|
+
back: str
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
@dataclass(frozen=True)
|
|
305
|
+
class FlashcardSet:
|
|
306
|
+
flashcard_set_id: str
|
|
307
|
+
set_name: Optional[str]
|
|
308
|
+
cards: List[Flashcard]
|
|
309
|
+
|
|
310
|
+
@classmethod
|
|
311
|
+
def from_task_result(cls, result: TaskResult) -> "FlashcardSet":
|
|
312
|
+
data = result.success_data()
|
|
313
|
+
cards = data.get("cards")
|
|
314
|
+
if not data.get("flashcard_set_id") or not isinstance(cards, list):
|
|
315
|
+
raise ValueError("Flashcard task result has an invalid shape")
|
|
316
|
+
return cls(
|
|
317
|
+
flashcard_set_id=str(data["flashcard_set_id"]),
|
|
318
|
+
set_name=str(data["set_name"]) if data.get("set_name") is not None else None,
|
|
319
|
+
cards=[Flashcard(front=str(card["front"]), back=str(card["back"])) for card in cards if isinstance(card, Mapping) and "front" in card and "back" in card],
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
@dataclass(frozen=True)
|
|
324
|
+
class QuizQuestion:
|
|
325
|
+
question: str
|
|
326
|
+
options: List[str]
|
|
327
|
+
answer: str
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
@dataclass(frozen=True)
|
|
331
|
+
class QuizSet:
|
|
332
|
+
quiz_set_id: str
|
|
333
|
+
set_name: Optional[str]
|
|
334
|
+
questions: List[QuizQuestion]
|
|
335
|
+
|
|
336
|
+
@classmethod
|
|
337
|
+
def from_task_result(cls, result: TaskResult) -> "QuizSet":
|
|
338
|
+
data = result.success_data()
|
|
339
|
+
questions = data.get("questions")
|
|
340
|
+
if not data.get("quiz_set_id") or not isinstance(questions, list):
|
|
341
|
+
raise ValueError("Quiz task result has an invalid shape")
|
|
342
|
+
return cls(
|
|
343
|
+
quiz_set_id=str(data["quiz_set_id"]),
|
|
344
|
+
set_name=str(data["set_name"]) if data.get("set_name") is not None else None,
|
|
345
|
+
questions=[
|
|
346
|
+
QuizQuestion(
|
|
347
|
+
question=str(question["question"]),
|
|
348
|
+
options=[str(option) for option in question.get("options", [])],
|
|
349
|
+
answer=str(question["answer"]),
|
|
350
|
+
)
|
|
351
|
+
for question in questions
|
|
352
|
+
if isinstance(question, Mapping) and "question" in question and "answer" in question
|
|
353
|
+
],
|
|
354
|
+
)
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
@dataclass(frozen=True)
|
|
358
|
+
class MindmapNode:
|
|
359
|
+
title: str
|
|
360
|
+
children: List["MindmapNode"]
|
|
361
|
+
|
|
362
|
+
@classmethod
|
|
363
|
+
def from_mapping(cls, value: Mapping[str, Any]) -> "MindmapNode":
|
|
364
|
+
if "title" not in value:
|
|
365
|
+
raise ValueError("Mindmap node is missing 'title'")
|
|
366
|
+
children = value.get("children", [])
|
|
367
|
+
if not isinstance(children, list):
|
|
368
|
+
raise ValueError("Mindmap node field 'children' must be a list")
|
|
369
|
+
return cls(
|
|
370
|
+
title=str(value["title"]),
|
|
371
|
+
children=[cls.from_mapping(child) for child in children if isinstance(child, Mapping)],
|
|
372
|
+
)
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
@dataclass(frozen=True)
|
|
376
|
+
class Mindmap:
|
|
377
|
+
note_id: str
|
|
378
|
+
root: MindmapNode
|
|
379
|
+
|
|
380
|
+
@classmethod
|
|
381
|
+
def from_task_result(cls, result: TaskResult) -> "Mindmap":
|
|
382
|
+
data = result.success_data()
|
|
383
|
+
if not data.get("note_id") or not isinstance(data.get("mindmap"), Mapping):
|
|
384
|
+
raise ValueError("Mindmap task result has an invalid shape")
|
|
385
|
+
return cls(note_id=str(data["note_id"]), root=MindmapNode.from_mapping(data["mindmap"]))
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
@dataclass(frozen=True)
|
|
389
|
+
class Podcast:
|
|
390
|
+
note_id: str
|
|
391
|
+
podcast_url: str
|
|
392
|
+
|
|
393
|
+
@classmethod
|
|
394
|
+
def from_task_result(cls, result: TaskResult) -> "Podcast":
|
|
395
|
+
data = result.success_data()
|
|
396
|
+
if not data.get("note_id") or not data.get("podcast_url"):
|
|
397
|
+
raise ValueError("Podcast task result has an invalid shape")
|
|
398
|
+
return cls(note_id=str(data["note_id"]), podcast_url=str(data["podcast_url"]))
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
@dataclass(frozen=True)
|
|
402
|
+
class Video:
|
|
403
|
+
note_id: str
|
|
404
|
+
video_url: str
|
|
405
|
+
|
|
406
|
+
@classmethod
|
|
407
|
+
def from_task_result(cls, result: TaskResult) -> "Video":
|
|
408
|
+
data = result.success_data()
|
|
409
|
+
if not data.get("note_id") or not data.get("video_url"):
|
|
410
|
+
raise ValueError("Video task result has an invalid shape")
|
|
411
|
+
return cls(note_id=str(data["note_id"]), video_url=str(data["video_url"]))
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
@dataclass(frozen=True)
|
|
415
|
+
class SlideDeck:
|
|
416
|
+
note_id: str
|
|
417
|
+
slide_url: str
|
|
418
|
+
slide_pdf_url: Optional[str]
|
|
419
|
+
|
|
420
|
+
@classmethod
|
|
421
|
+
def from_task_result(cls, result: TaskResult) -> "SlideDeck":
|
|
422
|
+
data = result.success_data()
|
|
423
|
+
if not data.get("note_id") or not data.get("slide_url"):
|
|
424
|
+
raise ValueError("Slide task result has an invalid shape")
|
|
425
|
+
return cls(
|
|
426
|
+
note_id=str(data["note_id"]),
|
|
427
|
+
slide_url=str(data["slide_url"]),
|
|
428
|
+
slide_pdf_url=str(data["slide_pdf_url"]) if data.get("slide_pdf_url") is not None else None,
|
|
429
|
+
)
|
notex_sdk/py.typed
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
notex_sdk/transport.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""HTTP transport and retry policy kept separate from NoteX endpoint logic."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import time
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from email.utils import parsedate_to_datetime
|
|
9
|
+
from typing import Any, BinaryIO, Dict, FrozenSet, Mapping, Optional, Union
|
|
10
|
+
from urllib.error import HTTPError, URLError
|
|
11
|
+
from urllib.request import Request, urlopen
|
|
12
|
+
|
|
13
|
+
from .errors import (
|
|
14
|
+
NotexAPIError,
|
|
15
|
+
NotexAuthenticationError,
|
|
16
|
+
NotexPermissionError,
|
|
17
|
+
NotexRateLimitError,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
RequestContent = Optional[Union[bytes, BinaryIO]]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class RetryPolicy:
|
|
25
|
+
"""Retry only operations that are safe to repeat by default."""
|
|
26
|
+
|
|
27
|
+
max_attempts: int = 3
|
|
28
|
+
initial_delay: float = 0.5
|
|
29
|
+
max_delay: float = 5.0
|
|
30
|
+
retry_status_codes: FrozenSet[int] = frozenset({429, 500, 502, 503, 504})
|
|
31
|
+
retry_methods: FrozenSet[str] = frozenset({"GET", "HEAD"})
|
|
32
|
+
|
|
33
|
+
def __post_init__(self) -> None:
|
|
34
|
+
if self.max_attempts < 1:
|
|
35
|
+
raise ValueError("max_attempts must be at least 1")
|
|
36
|
+
if self.initial_delay < 0 or self.max_delay < 0:
|
|
37
|
+
raise ValueError("retry delays cannot be negative")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class UrllibTransport:
|
|
41
|
+
"""Small standard-library transport with injectable retry behavior."""
|
|
42
|
+
|
|
43
|
+
def __init__(
|
|
44
|
+
self,
|
|
45
|
+
*,
|
|
46
|
+
timeout: float = 30.0,
|
|
47
|
+
retry_policy: Optional[RetryPolicy] = None,
|
|
48
|
+
sleep_fn=time.sleep,
|
|
49
|
+
) -> None:
|
|
50
|
+
if timeout <= 0:
|
|
51
|
+
raise ValueError("timeout must be greater than 0")
|
|
52
|
+
self.timeout = timeout
|
|
53
|
+
self.retry_policy = retry_policy or RetryPolicy()
|
|
54
|
+
self._sleep = sleep_fn
|
|
55
|
+
|
|
56
|
+
def send(
|
|
57
|
+
self,
|
|
58
|
+
*,
|
|
59
|
+
url: str,
|
|
60
|
+
method: str,
|
|
61
|
+
headers: Mapping[str, str],
|
|
62
|
+
content: RequestContent = None,
|
|
63
|
+
retryable: Optional[bool] = None,
|
|
64
|
+
) -> Dict[str, Any]:
|
|
65
|
+
normalized_method = method.upper()
|
|
66
|
+
can_retry = (
|
|
67
|
+
retryable if retryable is not None else normalized_method in self.retry_policy.retry_methods
|
|
68
|
+
)
|
|
69
|
+
# A streamed body cannot be replayed safely without reopening its file.
|
|
70
|
+
if content is not None and not isinstance(content, bytes):
|
|
71
|
+
can_retry = False
|
|
72
|
+
attempts = self.retry_policy.max_attempts if can_retry else 1
|
|
73
|
+
|
|
74
|
+
for attempt in range(1, attempts + 1):
|
|
75
|
+
request = Request(url, data=content, headers=dict(headers), method=normalized_method)
|
|
76
|
+
try:
|
|
77
|
+
with urlopen(request, timeout=self.timeout) as response:
|
|
78
|
+
return self.decode_json(response.read())
|
|
79
|
+
except HTTPError as exc:
|
|
80
|
+
payload = self.decode_json(exc.read())
|
|
81
|
+
retry_after = self._retry_after(exc.headers.get("Retry-After"))
|
|
82
|
+
if exc.code in self.retry_policy.retry_status_codes and attempt < attempts:
|
|
83
|
+
self._sleep(self._delay(attempt, retry_after))
|
|
84
|
+
continue
|
|
85
|
+
raise self._http_error(exc, payload, retry_after) from exc
|
|
86
|
+
except URLError as exc:
|
|
87
|
+
if attempt < attempts:
|
|
88
|
+
self._sleep(self._delay(attempt, None))
|
|
89
|
+
continue
|
|
90
|
+
raise NotexAPIError(f"Could not connect to NoteX API: {exc.reason}") from exc
|
|
91
|
+
|
|
92
|
+
raise AssertionError("retry loop ended unexpectedly")
|
|
93
|
+
|
|
94
|
+
def _delay(self, attempt: int, retry_after: Optional[float]) -> float:
|
|
95
|
+
if retry_after is not None:
|
|
96
|
+
# Servers use this header to control when a request may be retried.
|
|
97
|
+
# Do not shorten it: doing so can create another rate-limit response.
|
|
98
|
+
return retry_after
|
|
99
|
+
return min(self.retry_policy.initial_delay * (2 ** (attempt - 1)), self.retry_policy.max_delay)
|
|
100
|
+
|
|
101
|
+
@staticmethod
|
|
102
|
+
def _retry_after(value: Optional[str]) -> Optional[float]:
|
|
103
|
+
if not value:
|
|
104
|
+
return None
|
|
105
|
+
try:
|
|
106
|
+
return max(0.0, float(value))
|
|
107
|
+
except ValueError:
|
|
108
|
+
try:
|
|
109
|
+
return max(0.0, parsedate_to_datetime(value).timestamp() - time.time())
|
|
110
|
+
except (TypeError, ValueError):
|
|
111
|
+
return None
|
|
112
|
+
|
|
113
|
+
@staticmethod
|
|
114
|
+
def _http_error(
|
|
115
|
+
error: HTTPError,
|
|
116
|
+
payload: Mapping[str, Any],
|
|
117
|
+
retry_after: Optional[float],
|
|
118
|
+
) -> NotexAPIError:
|
|
119
|
+
error_type = {
|
|
120
|
+
401: NotexAuthenticationError,
|
|
121
|
+
403: NotexPermissionError,
|
|
122
|
+
429: NotexRateLimitError,
|
|
123
|
+
}.get(error.code, NotexAPIError)
|
|
124
|
+
return error_type(
|
|
125
|
+
str(payload.get("message") or error.reason or "NoteX API request failed"),
|
|
126
|
+
status_code=error.code,
|
|
127
|
+
error_key=payload.get("error_key"),
|
|
128
|
+
body=payload,
|
|
129
|
+
retry_after=retry_after,
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
@staticmethod
|
|
133
|
+
def decode_json(raw_body: bytes) -> Dict[str, Any]:
|
|
134
|
+
if not raw_body:
|
|
135
|
+
return {}
|
|
136
|
+
try:
|
|
137
|
+
value = json.loads(raw_body.decode("utf-8"))
|
|
138
|
+
except (UnicodeDecodeError, json.JSONDecodeError):
|
|
139
|
+
return {"message": raw_body.decode("utf-8", errors="replace")}
|
|
140
|
+
return value if isinstance(value, dict) else {"data": value}
|