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_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}