autolecture 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.
- autolecture/__init__.py +88 -0
- autolecture/_http.py +209 -0
- autolecture/_polling.py +79 -0
- autolecture/client.py +349 -0
- autolecture/exceptions.py +184 -0
- autolecture/types.py +192 -0
- autolecture-0.1.0.dist-info/METADATA +188 -0
- autolecture-0.1.0.dist-info/RECORD +10 -0
- autolecture-0.1.0.dist-info/WHEEL +4 -0
- autolecture-0.1.0.dist-info/licenses/LICENSE +21 -0
autolecture/__init__.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
r"""autolecture — Official Python SDK for AutoLecture (https://autolecture.ai).
|
|
2
|
+
|
|
3
|
+
Quickstart:
|
|
4
|
+
|
|
5
|
+
from autolecture import Client
|
|
6
|
+
|
|
7
|
+
al = Client(api_key="al_live_…") # mint at https://autolecture.ai/account
|
|
8
|
+
project = al.create_project(name="My first video")
|
|
9
|
+
al.upload_asset(project["id"], "./recording.mp3")
|
|
10
|
+
al.put_tex(project["id"], "main.tex", r'''
|
|
11
|
+
\title{Demo}
|
|
12
|
+
\begin{videotex}
|
|
13
|
+
\begin{view}\say{Hello, AutoLecture!}\end{view}
|
|
14
|
+
\end{videotex}
|
|
15
|
+
''')
|
|
16
|
+
job = al.compile(project["id"], on_progress=lambda j: print(j["progress_pct"], "%"))
|
|
17
|
+
al.download_preview(project["id"], dest="./out.mp4")
|
|
18
|
+
|
|
19
|
+
Errors raised by the SDK all inherit from `AutoLectureError`. The
|
|
20
|
+
common subclasses (`InsufficientCreditsError`, `QuotaExceededError`,
|
|
21
|
+
`RateLimitError`, `CompileFailedError`, …) carry the server's
|
|
22
|
+
structured-error fields as attributes — no need to re-parse JSON.
|
|
23
|
+
"""
|
|
24
|
+
from autolecture.client import Client
|
|
25
|
+
from autolecture.exceptions import (
|
|
26
|
+
APIError,
|
|
27
|
+
AuthenticationError,
|
|
28
|
+
AutoLectureError,
|
|
29
|
+
CompileCancelledError,
|
|
30
|
+
CompileFailedError,
|
|
31
|
+
InsufficientCreditsError,
|
|
32
|
+
NotFoundError,
|
|
33
|
+
PermissionError,
|
|
34
|
+
QuotaExceededError,
|
|
35
|
+
RateLimitError,
|
|
36
|
+
ServerError,
|
|
37
|
+
)
|
|
38
|
+
from autolecture.types import (
|
|
39
|
+
ApiKeyMintResult,
|
|
40
|
+
ApiKeyStatus,
|
|
41
|
+
Asset,
|
|
42
|
+
Balance,
|
|
43
|
+
CompileBlockInfo,
|
|
44
|
+
CompileJob,
|
|
45
|
+
CompileStatus,
|
|
46
|
+
CreditTopup,
|
|
47
|
+
Project,
|
|
48
|
+
ProjectFull,
|
|
49
|
+
ProjectQuota,
|
|
50
|
+
Quota,
|
|
51
|
+
Usage,
|
|
52
|
+
VoiceSampleStatus,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
__version__ = "0.1.0"
|
|
56
|
+
|
|
57
|
+
__all__ = [
|
|
58
|
+
# client
|
|
59
|
+
"Client",
|
|
60
|
+
# exceptions
|
|
61
|
+
"APIError",
|
|
62
|
+
"AuthenticationError",
|
|
63
|
+
"AutoLectureError",
|
|
64
|
+
"CompileCancelledError",
|
|
65
|
+
"CompileFailedError",
|
|
66
|
+
"InsufficientCreditsError",
|
|
67
|
+
"NotFoundError",
|
|
68
|
+
"PermissionError",
|
|
69
|
+
"QuotaExceededError",
|
|
70
|
+
"RateLimitError",
|
|
71
|
+
"ServerError",
|
|
72
|
+
# types
|
|
73
|
+
"ApiKeyMintResult",
|
|
74
|
+
"ApiKeyStatus",
|
|
75
|
+
"Asset",
|
|
76
|
+
"Balance",
|
|
77
|
+
"CompileBlockInfo",
|
|
78
|
+
"CompileJob",
|
|
79
|
+
"CompileStatus",
|
|
80
|
+
"CreditTopup",
|
|
81
|
+
"Project",
|
|
82
|
+
"ProjectFull",
|
|
83
|
+
"ProjectQuota",
|
|
84
|
+
"Quota",
|
|
85
|
+
"Usage",
|
|
86
|
+
"VoiceSampleStatus",
|
|
87
|
+
"__version__",
|
|
88
|
+
]
|
autolecture/_http.py
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
"""Internal HTTP layer — owns the `httpx.Client`, auth header injection,
|
|
2
|
+
and the response → exception mapping. Public API never touches this
|
|
3
|
+
directly; `client.py` calls `_HTTP.request(...)` and trusts it to either
|
|
4
|
+
return a parsed JSON body or raise the right exception subclass.
|
|
5
|
+
|
|
6
|
+
Why a wrapper instead of using `httpx.Client` directly:
|
|
7
|
+
- Centralizes the Bearer token logic (one header, every call)
|
|
8
|
+
- Centralizes the response → exception mapping (one switch, every
|
|
9
|
+
call) — keeps `client.py` focused on the API surface
|
|
10
|
+
- One natural place to add retries / observability later
|
|
11
|
+
|
|
12
|
+
Retries: not added by default. The AutoLecture backend's 5xx are
|
|
13
|
+
usually deterministic engine failures (e.g. Manim couldn't compile),
|
|
14
|
+
not transient. Retrying would just burn API credits. Callers that
|
|
15
|
+
DO want retries wrap individual methods themselves.
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
import httpx
|
|
22
|
+
|
|
23
|
+
from autolecture.exceptions import (
|
|
24
|
+
APIError,
|
|
25
|
+
AuthenticationError,
|
|
26
|
+
AutoLectureError,
|
|
27
|
+
InsufficientCreditsError,
|
|
28
|
+
NotFoundError,
|
|
29
|
+
PermissionError,
|
|
30
|
+
QuotaExceededError,
|
|
31
|
+
RateLimitError,
|
|
32
|
+
ServerError,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# Map (status_code, code) → exception class. The 402 line is two-headed
|
|
37
|
+
# because the backend uses 402 for both quota and credits gates with
|
|
38
|
+
# different `code` fields. `None` means "match any code for that status".
|
|
39
|
+
_DISPATCH: dict[tuple[int, str | None], type[AutoLectureError]] = {
|
|
40
|
+
(401, None): AuthenticationError,
|
|
41
|
+
(403, None): PermissionError,
|
|
42
|
+
(404, None): NotFoundError,
|
|
43
|
+
(402, "project_quota_exceeded"): QuotaExceededError,
|
|
44
|
+
(402, "insufficient_credits"): InsufficientCreditsError,
|
|
45
|
+
(429, None): RateLimitError,
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _classify(status_code: int, code: str) -> type[AutoLectureError]:
|
|
50
|
+
"""Pick the most specific exception class for this (status, code)."""
|
|
51
|
+
if (status_code, code) in _DISPATCH:
|
|
52
|
+
return _DISPATCH[(status_code, code)]
|
|
53
|
+
if (status_code, None) in _DISPATCH:
|
|
54
|
+
return _DISPATCH[(status_code, None)]
|
|
55
|
+
if status_code >= 500:
|
|
56
|
+
return ServerError
|
|
57
|
+
return APIError
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _parse_detail(body: Any) -> tuple[str, str, dict[str, Any]]:
|
|
61
|
+
"""Coerce whatever the server sent into (code, message, full_dict).
|
|
62
|
+
|
|
63
|
+
Three shapes we handle:
|
|
64
|
+
1. `{"detail": {"code": "...", "message": "...", ...}}` — structured
|
|
65
|
+
2. `{"detail": "plain string"}` — FastAPI default
|
|
66
|
+
3. `<anything else>` — fall back to repr
|
|
67
|
+
|
|
68
|
+
Returns:
|
|
69
|
+
(code, message, response_dict). The response_dict is the inner
|
|
70
|
+
body (so exception classes can read extra fields like
|
|
71
|
+
`.balance`, `.limit`, ...).
|
|
72
|
+
"""
|
|
73
|
+
if isinstance(body, dict):
|
|
74
|
+
detail = body.get("detail")
|
|
75
|
+
if isinstance(detail, dict):
|
|
76
|
+
return (
|
|
77
|
+
str(detail.get("code", "unknown")),
|
|
78
|
+
str(detail.get("message", "")),
|
|
79
|
+
detail,
|
|
80
|
+
)
|
|
81
|
+
if isinstance(detail, str):
|
|
82
|
+
return ("unknown", detail, {})
|
|
83
|
+
return ("unknown", str(body), {})
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class _HTTP:
|
|
87
|
+
"""Thin wrapper around `httpx.Client`. Owns the auth header and the
|
|
88
|
+
response → exception mapping.
|
|
89
|
+
|
|
90
|
+
`request()` is what `client.py` uses for JSON endpoints — body is
|
|
91
|
+
decoded, errors are raised. `stream_to_file()` is the streaming
|
|
92
|
+
variant for video / audio downloads.
|
|
93
|
+
"""
|
|
94
|
+
|
|
95
|
+
def __init__(
|
|
96
|
+
self,
|
|
97
|
+
api_key: str,
|
|
98
|
+
base_url: str,
|
|
99
|
+
*,
|
|
100
|
+
timeout: float = 30.0,
|
|
101
|
+
user_agent: str = "autolecture-python/0.1.0",
|
|
102
|
+
) -> None:
|
|
103
|
+
# Strip trailing slash so callers can pass `https://autolecture.ai`
|
|
104
|
+
# or `https://autolecture.ai/` and we always build paths the same.
|
|
105
|
+
self._base_url = base_url.rstrip("/")
|
|
106
|
+
self._client = httpx.Client(
|
|
107
|
+
timeout=timeout,
|
|
108
|
+
headers={
|
|
109
|
+
"Authorization": f"Bearer {api_key}",
|
|
110
|
+
"User-Agent": user_agent,
|
|
111
|
+
"Accept": "application/json",
|
|
112
|
+
},
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
# ── core request ────────────────────────────────────────────────
|
|
116
|
+
|
|
117
|
+
def request(
|
|
118
|
+
self,
|
|
119
|
+
method: str,
|
|
120
|
+
path: str,
|
|
121
|
+
*,
|
|
122
|
+
params: dict[str, Any] | None = None,
|
|
123
|
+
json: Any | None = None,
|
|
124
|
+
files: dict[str, Any] | None = None,
|
|
125
|
+
data: dict[str, Any] | None = None,
|
|
126
|
+
) -> Any:
|
|
127
|
+
"""Make a request, return parsed JSON, raise on non-2xx.
|
|
128
|
+
|
|
129
|
+
Args:
|
|
130
|
+
method: HTTP verb.
|
|
131
|
+
path: leading-slash path (`/api/v2/projects`). Concatenated
|
|
132
|
+
onto `base_url`.
|
|
133
|
+
params: query string.
|
|
134
|
+
json: JSON body (mutually exclusive with `files` / `data`).
|
|
135
|
+
files: multipart upload (FastAPI's `UploadFile` expects this).
|
|
136
|
+
data: multipart form fields alongside `files`.
|
|
137
|
+
"""
|
|
138
|
+
url = f"{self._base_url}{path}"
|
|
139
|
+
response = self._client.request(
|
|
140
|
+
method, url, params=params, json=json, files=files, data=data,
|
|
141
|
+
)
|
|
142
|
+
self._raise_for_status(response)
|
|
143
|
+
# 204 / empty body — return None so callers don't have to guard.
|
|
144
|
+
if not response.content:
|
|
145
|
+
return None
|
|
146
|
+
# Some endpoints (e.g. raw file streams) come back with non-JSON
|
|
147
|
+
# content-type; callers using `request()` for those is a bug.
|
|
148
|
+
# Trust the JSON parse to raise if so.
|
|
149
|
+
return response.json()
|
|
150
|
+
|
|
151
|
+
# ── streaming download ──────────────────────────────────────────
|
|
152
|
+
|
|
153
|
+
def stream_to_file(self, path: str, dest: Any) -> None:
|
|
154
|
+
"""GET `path` and stream the body to `dest` (str / Path / file
|
|
155
|
+
object). Used for /preview mp4 downloads — avoids loading the
|
|
156
|
+
whole file into memory.
|
|
157
|
+
"""
|
|
158
|
+
from pathlib import Path
|
|
159
|
+
|
|
160
|
+
url = f"{self._base_url}{path}"
|
|
161
|
+
with self._client.stream("GET", url) as response:
|
|
162
|
+
self._raise_for_status(response)
|
|
163
|
+
if isinstance(dest, (str, Path)):
|
|
164
|
+
with open(dest, "wb") as f:
|
|
165
|
+
for chunk in response.iter_bytes(chunk_size=64 * 1024):
|
|
166
|
+
f.write(chunk)
|
|
167
|
+
else:
|
|
168
|
+
# File-like object the caller already opened.
|
|
169
|
+
for chunk in response.iter_bytes(chunk_size=64 * 1024):
|
|
170
|
+
dest.write(chunk)
|
|
171
|
+
|
|
172
|
+
# ── error mapping ───────────────────────────────────────────────
|
|
173
|
+
|
|
174
|
+
@staticmethod
|
|
175
|
+
def _raise_for_status(response: httpx.Response) -> None:
|
|
176
|
+
if response.is_success:
|
|
177
|
+
return
|
|
178
|
+
# Best-effort body parse. A 502 from a proxy might be HTML —
|
|
179
|
+
# `_parse_detail` falls back to repr in that case.
|
|
180
|
+
body: Any
|
|
181
|
+
try:
|
|
182
|
+
body = response.json()
|
|
183
|
+
except Exception:
|
|
184
|
+
body = response.text
|
|
185
|
+
code, message, detail = _parse_detail(body)
|
|
186
|
+
# Stream responses haven't read the body — _raise_for_status
|
|
187
|
+
# for streams runs BEFORE we start iter_bytes, so we must
|
|
188
|
+
# consume now to free the connection. Cheap (error bodies are
|
|
189
|
+
# small) and safe (httpx no-ops if already read).
|
|
190
|
+
if not message:
|
|
191
|
+
message = f"HTTP {response.status_code} ({response.reason_phrase or 'error'})"
|
|
192
|
+
cls = _classify(response.status_code, code)
|
|
193
|
+
raise cls(
|
|
194
|
+
message,
|
|
195
|
+
code=code,
|
|
196
|
+
status_code=response.status_code,
|
|
197
|
+
response=detail,
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
# ── lifecycle ───────────────────────────────────────────────────
|
|
201
|
+
|
|
202
|
+
def close(self) -> None:
|
|
203
|
+
self._client.close()
|
|
204
|
+
|
|
205
|
+
def __enter__(self) -> "_HTTP":
|
|
206
|
+
return self
|
|
207
|
+
|
|
208
|
+
def __exit__(self, *exc_info: Any) -> None:
|
|
209
|
+
self.close()
|
autolecture/_polling.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Internal poll-until-terminal helper for compile jobs.
|
|
2
|
+
|
|
3
|
+
Used by `Client.compile()` to block until the job reaches a terminal
|
|
4
|
+
state (`succeeded` / `failed` / `cancelled`), invoking an optional
|
|
5
|
+
progress callback after each poll.
|
|
6
|
+
|
|
7
|
+
Kept separate from `client.py` so a clock-injecting test can verify
|
|
8
|
+
polling logic without real sleeps.
|
|
9
|
+
|
|
10
|
+
Backoff:
|
|
11
|
+
No backoff today. The backend's GET `/compile/jobs/{id}` is a cheap
|
|
12
|
+
one-row SELECT; polling at a fixed 2s interval is fine for the
|
|
13
|
+
expected job-runtimes (30s – 30min). Add backoff if we ever see
|
|
14
|
+
the polling endpoint show up in slow-query logs.
|
|
15
|
+
|
|
16
|
+
Cancellation:
|
|
17
|
+
Caller cancels by raising KeyboardInterrupt (Ctrl+C) — Python
|
|
18
|
+
propagates it through `time.sleep`. The poll loop will exit
|
|
19
|
+
cleanly without retrying.
|
|
20
|
+
"""
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import time
|
|
24
|
+
from collections.abc import Callable
|
|
25
|
+
from typing import TYPE_CHECKING
|
|
26
|
+
|
|
27
|
+
from autolecture.exceptions import CompileCancelledError, CompileFailedError
|
|
28
|
+
from autolecture.types import CompileJob
|
|
29
|
+
|
|
30
|
+
if TYPE_CHECKING:
|
|
31
|
+
from autolecture._http import _HTTP
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
_TERMINAL_STATES: frozenset[str] = frozenset({"succeeded", "failed", "cancelled"})
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def poll_compile_job(
|
|
38
|
+
http: "_HTTP",
|
|
39
|
+
job_id: str,
|
|
40
|
+
*,
|
|
41
|
+
on_progress: Callable[[CompileJob], None] | None = None,
|
|
42
|
+
poll_interval: float = 2.0,
|
|
43
|
+
_sleep: Callable[[float], None] = time.sleep,
|
|
44
|
+
) -> CompileJob:
|
|
45
|
+
"""Poll `/compile/jobs/{job_id}` until terminal, return the final job.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
http: the SDK's internal HTTP wrapper.
|
|
49
|
+
job_id: the compile job id (UUID string).
|
|
50
|
+
on_progress: callback invoked after each poll with the latest
|
|
51
|
+
CompileJob dict. Use to drive a progress bar / log line.
|
|
52
|
+
poll_interval: seconds between polls. Default 2s.
|
|
53
|
+
_sleep: injectable for tests — defaults to `time.sleep`.
|
|
54
|
+
|
|
55
|
+
Returns:
|
|
56
|
+
The final CompileJob with status == "succeeded".
|
|
57
|
+
|
|
58
|
+
Raises:
|
|
59
|
+
CompileFailedError: when status terminates as "failed".
|
|
60
|
+
CompileCancelledError: when status terminates as "cancelled".
|
|
61
|
+
"""
|
|
62
|
+
while True:
|
|
63
|
+
job: CompileJob = http.request("GET", f"/api/v2/compile/jobs/{job_id}")
|
|
64
|
+
if on_progress is not None:
|
|
65
|
+
on_progress(job)
|
|
66
|
+
status = job["status"]
|
|
67
|
+
if status in _TERMINAL_STATES:
|
|
68
|
+
if status == "failed":
|
|
69
|
+
raise CompileFailedError(
|
|
70
|
+
f"compile job {job_id} failed: {job.get('error_log') or '(no log)'}",
|
|
71
|
+
job=job,
|
|
72
|
+
)
|
|
73
|
+
if status == "cancelled":
|
|
74
|
+
raise CompileCancelledError(
|
|
75
|
+
f"compile job {job_id} was cancelled",
|
|
76
|
+
job=job,
|
|
77
|
+
)
|
|
78
|
+
return job # succeeded
|
|
79
|
+
_sleep(poll_interval)
|
autolecture/client.py
ADDED
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
"""The `Client` class — the public surface of the SDK.
|
|
2
|
+
|
|
3
|
+
Every method maps to a single AutoLecture HTTP endpoint with one
|
|
4
|
+
exception: `compile()` (high-level) wraps `start_compile()` +
|
|
5
|
+
polling + on-success return. Use `start_compile()` + `get_compile_job()`
|
|
6
|
+
directly when you need to drive the loop yourself.
|
|
7
|
+
|
|
8
|
+
All methods raise subclasses of `AutoLectureError` on failure — see
|
|
9
|
+
`exceptions.py` for the hierarchy. No `try / except` swallowing.
|
|
10
|
+
|
|
11
|
+
The Client is sync-only in v0.1 (httpx.Client). Async variant deferred
|
|
12
|
+
to v0.2 if a real use case appears.
|
|
13
|
+
|
|
14
|
+
Thread safety:
|
|
15
|
+
Underlying `httpx.Client` is thread-safe for concurrent requests.
|
|
16
|
+
Sharing one `Client` across threads is fine.
|
|
17
|
+
|
|
18
|
+
Resource management:
|
|
19
|
+
Use as a context manager when possible to close the underlying
|
|
20
|
+
HTTP connection pool:
|
|
21
|
+
|
|
22
|
+
with Client(api_key="al_live_…") as al:
|
|
23
|
+
al.list_projects()
|
|
24
|
+
"""
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
from collections.abc import Callable
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
from typing import IO, Any
|
|
30
|
+
|
|
31
|
+
from autolecture._http import _HTTP
|
|
32
|
+
from autolecture._polling import poll_compile_job
|
|
33
|
+
from autolecture.types import (
|
|
34
|
+
ApiKeyMintResult,
|
|
35
|
+
ApiKeyStatus,
|
|
36
|
+
Asset,
|
|
37
|
+
Balance,
|
|
38
|
+
CompileJob,
|
|
39
|
+
Project,
|
|
40
|
+
ProjectFull,
|
|
41
|
+
Quota,
|
|
42
|
+
Usage,
|
|
43
|
+
VoiceSampleStatus,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class Client:
|
|
48
|
+
"""AutoLecture API client.
|
|
49
|
+
|
|
50
|
+
Args:
|
|
51
|
+
api_key: An `al_live_…` token minted at https://autolecture.ai/account.
|
|
52
|
+
(Legacy JWTs from the web-UI login also work — the backend
|
|
53
|
+
sniffs the prefix — but API keys are the supported path
|
|
54
|
+
for SDK callers.)
|
|
55
|
+
base_url: Server base URL. Defaults to production. Override to
|
|
56
|
+
`https://dev.autolecture.ai` for the dev backend, or
|
|
57
|
+
`http://localhost:8000` / `:8001` for local development.
|
|
58
|
+
timeout: Per-request timeout in seconds. Default 30s. Downloads
|
|
59
|
+
(which can be long) use this for the connect timeout only;
|
|
60
|
+
streaming bodies are not bounded by it.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
def __init__(
|
|
64
|
+
self,
|
|
65
|
+
api_key: str,
|
|
66
|
+
*,
|
|
67
|
+
base_url: str = "https://autolecture.ai",
|
|
68
|
+
timeout: float = 30.0,
|
|
69
|
+
) -> None:
|
|
70
|
+
self._http = _HTTP(api_key=api_key, base_url=base_url, timeout=timeout)
|
|
71
|
+
|
|
72
|
+
# ── lifecycle ──────────────────────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
def close(self) -> None:
|
|
75
|
+
self._http.close()
|
|
76
|
+
|
|
77
|
+
def __enter__(self) -> "Client":
|
|
78
|
+
return self
|
|
79
|
+
|
|
80
|
+
def __exit__(self, *exc_info: Any) -> None:
|
|
81
|
+
self.close()
|
|
82
|
+
|
|
83
|
+
# ── Projects ───────────────────────────────────────────────────────
|
|
84
|
+
|
|
85
|
+
def list_projects(self, *, archived: bool = False) -> list[Project]:
|
|
86
|
+
"""GET /api/v2/projects."""
|
|
87
|
+
params = {"archived": "true"} if archived else None
|
|
88
|
+
payload = self._http.request("GET", "/api/v2/projects", params=params)
|
|
89
|
+
return payload["projects"]
|
|
90
|
+
|
|
91
|
+
def create_project(self, name: str, *, template: str = "blank") -> Project:
|
|
92
|
+
"""POST /api/v2/projects. `template` is one of the built-in
|
|
93
|
+
template ids ("blank", "ai_drama", …) — defaults to blank."""
|
|
94
|
+
return self._http.request(
|
|
95
|
+
"POST", "/api/v2/projects",
|
|
96
|
+
json={"name": name, "template": template},
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
def create_project_from_zip(self, zip_path: Path | str, *, name: str | None = None) -> Project:
|
|
100
|
+
"""POST /api/v2/projects/from-zip. Overleaf-style import:
|
|
101
|
+
unpacks the zip, registers every file as an Asset, auto-wires
|
|
102
|
+
`main.tex`."""
|
|
103
|
+
zip_path = Path(zip_path)
|
|
104
|
+
files: dict[str, Any] = {"zipfile": (zip_path.name, zip_path.read_bytes(), "application/zip")}
|
|
105
|
+
data = {"name": name} if name else None
|
|
106
|
+
return self._http.request(
|
|
107
|
+
"POST", "/api/v2/projects/from-zip",
|
|
108
|
+
files=files, data=data,
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
def get_project(self, project_id: str) -> ProjectFull:
|
|
112
|
+
"""GET /api/v2/projects/{id} — includes `main_tex`."""
|
|
113
|
+
return self._http.request("GET", f"/api/v2/projects/{project_id}")
|
|
114
|
+
|
|
115
|
+
def update_project(
|
|
116
|
+
self, project_id: str, *, name: str | None = None, main_tex: str | None = None,
|
|
117
|
+
) -> None:
|
|
118
|
+
"""PATCH /api/v2/projects/{id}. Pass `name` or `main_tex` (or
|
|
119
|
+
both). Returns nothing — re-fetch with `get_project` if needed."""
|
|
120
|
+
body: dict[str, Any] = {}
|
|
121
|
+
if name is not None:
|
|
122
|
+
body["name"] = name
|
|
123
|
+
if main_tex is not None:
|
|
124
|
+
body["main_tex"] = main_tex
|
|
125
|
+
self._http.request("PATCH", f"/api/v2/projects/{project_id}", json=body)
|
|
126
|
+
|
|
127
|
+
def delete_project(self, project_id: str) -> None:
|
|
128
|
+
"""DELETE /api/v2/projects/{id} — hard delete, cascades to
|
|
129
|
+
assets / compile jobs / cache."""
|
|
130
|
+
self._http.request("DELETE", f"/api/v2/projects/{project_id}")
|
|
131
|
+
|
|
132
|
+
def archive_project(self, project_id: str) -> None:
|
|
133
|
+
"""POST /api/v2/projects/{id}/archive — soft delete; freed
|
|
134
|
+
quota slot. Reverse with `unarchive_project`."""
|
|
135
|
+
self._http.request("POST", f"/api/v2/projects/{project_id}/archive")
|
|
136
|
+
|
|
137
|
+
def unarchive_project(self, project_id: str) -> None:
|
|
138
|
+
"""POST /api/v2/projects/{id}/unarchive — re-enforces project quota."""
|
|
139
|
+
self._http.request("POST", f"/api/v2/projects/{project_id}/unarchive")
|
|
140
|
+
|
|
141
|
+
def duplicate_project(self, project_id: str) -> Project:
|
|
142
|
+
"""POST /api/v2/projects/{id}/duplicate — clone project + assets."""
|
|
143
|
+
return self._http.request("POST", f"/api/v2/projects/{project_id}/duplicate")
|
|
144
|
+
|
|
145
|
+
# ── Tex source ─────────────────────────────────────────────────────
|
|
146
|
+
|
|
147
|
+
def get_tex(self, project_id: str, *, path: str = "main.tex") -> str:
|
|
148
|
+
"""GET /api/v2/projects/{id}/tex?path=…"""
|
|
149
|
+
payload = self._http.request(
|
|
150
|
+
"GET", f"/api/v2/projects/{project_id}/tex",
|
|
151
|
+
params={"path": path},
|
|
152
|
+
)
|
|
153
|
+
return payload["content"]
|
|
154
|
+
|
|
155
|
+
def put_tex(self, project_id: str, path: str, content: str) -> None:
|
|
156
|
+
"""PUT /api/v2/projects/{id}/tex — write any `.tex` file under
|
|
157
|
+
the project (`main.tex` and any `\\input{...}` sub-tex)."""
|
|
158
|
+
self._http.request(
|
|
159
|
+
"PUT", f"/api/v2/projects/{project_id}/tex",
|
|
160
|
+
json={"path": path, "content": content},
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
# ── Assets ─────────────────────────────────────────────────────────
|
|
164
|
+
|
|
165
|
+
def upload_asset(
|
|
166
|
+
self, project_id: str, file: Path | str, *, rel_path: str | None = None,
|
|
167
|
+
) -> Asset:
|
|
168
|
+
"""POST /api/v2/projects/{id}/assets — multipart upload.
|
|
169
|
+
|
|
170
|
+
`rel_path` defaults to the file's basename. Use it to put
|
|
171
|
+
assets in subfolders: `rel_path="scenes/v1.py"`."""
|
|
172
|
+
p = Path(file)
|
|
173
|
+
files: dict[str, Any] = {"file": (p.name, p.read_bytes(), "application/octet-stream")}
|
|
174
|
+
data = {"rel_path": rel_path} if rel_path else None
|
|
175
|
+
return self._http.request(
|
|
176
|
+
"POST", f"/api/v2/projects/{project_id}/assets",
|
|
177
|
+
files=files, data=data,
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
def list_assets(self, project_id: str) -> list[Asset]:
|
|
181
|
+
"""GET /api/v2/projects/{id}/assets."""
|
|
182
|
+
payload = self._http.request("GET", f"/api/v2/projects/{project_id}/assets")
|
|
183
|
+
return payload["assets"]
|
|
184
|
+
|
|
185
|
+
def delete_asset(self, project_id: str, rel_path: str) -> None:
|
|
186
|
+
"""DELETE /api/v2/projects/{id}/assets?rel_path=…"""
|
|
187
|
+
self._http.request(
|
|
188
|
+
"DELETE", f"/api/v2/projects/{project_id}/assets",
|
|
189
|
+
params={"rel_path": rel_path},
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
# ── Compile ────────────────────────────────────────────────────────
|
|
193
|
+
|
|
194
|
+
def compile(
|
|
195
|
+
self,
|
|
196
|
+
project_id: str,
|
|
197
|
+
*,
|
|
198
|
+
tex_path: str = "main.tex",
|
|
199
|
+
only_block_index: int | None = None,
|
|
200
|
+
only_block_hash: str | None = None,
|
|
201
|
+
force_rerender: bool = False,
|
|
202
|
+
on_progress: Callable[[CompileJob], None] | None = None,
|
|
203
|
+
poll_interval: float = 2.0,
|
|
204
|
+
) -> CompileJob:
|
|
205
|
+
"""High-level: start a compile, poll until terminal, return
|
|
206
|
+
the final job (or raise CompileFailedError / CompileCancelledError).
|
|
207
|
+
|
|
208
|
+
Args:
|
|
209
|
+
project_id: project to compile.
|
|
210
|
+
tex_path: which .tex within the project (default `main.tex`).
|
|
211
|
+
only_block_index: render just one view (0-indexed). Useful
|
|
212
|
+
for iterating on a single scene.
|
|
213
|
+
only_block_hash: render just one view by hash (preferred over
|
|
214
|
+
index because indexes shift when blocks are added/removed).
|
|
215
|
+
force_rerender: skip the cache; re-render every selected block.
|
|
216
|
+
on_progress: callback invoked after each poll with the latest
|
|
217
|
+
CompileJob. Use to drive a progress bar / log line.
|
|
218
|
+
poll_interval: seconds between polls. Default 2s.
|
|
219
|
+
|
|
220
|
+
Returns:
|
|
221
|
+
The final CompileJob with `status == "succeeded"`.
|
|
222
|
+
|
|
223
|
+
Raises:
|
|
224
|
+
CompileFailedError / CompileCancelledError on terminal failure.
|
|
225
|
+
"""
|
|
226
|
+
job = self.start_compile(
|
|
227
|
+
project_id,
|
|
228
|
+
tex_path=tex_path,
|
|
229
|
+
only_block_index=only_block_index,
|
|
230
|
+
only_block_hash=only_block_hash,
|
|
231
|
+
force_rerender=force_rerender,
|
|
232
|
+
)
|
|
233
|
+
return poll_compile_job(
|
|
234
|
+
self._http, job["id"] if "id" in job else job["job_id"],
|
|
235
|
+
on_progress=on_progress,
|
|
236
|
+
poll_interval=poll_interval,
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
def start_compile(
|
|
240
|
+
self,
|
|
241
|
+
project_id: str,
|
|
242
|
+
*,
|
|
243
|
+
tex_path: str = "main.tex",
|
|
244
|
+
only_block_index: int | None = None,
|
|
245
|
+
only_block_hash: str | None = None,
|
|
246
|
+
force_rerender: bool = False,
|
|
247
|
+
) -> dict[str, Any]:
|
|
248
|
+
"""Low-level: POST /api/v2/projects/{id}/compile and return the
|
|
249
|
+
creation response (`{status, job_id, blocks_count, est_cost_credits, ...}`)
|
|
250
|
+
immediately. Use `get_compile_job()` to poll yourself."""
|
|
251
|
+
body: dict[str, Any] = {"tex_path": tex_path, "force_rerender": force_rerender}
|
|
252
|
+
if only_block_index is not None:
|
|
253
|
+
body["only_block_index"] = only_block_index
|
|
254
|
+
if only_block_hash is not None:
|
|
255
|
+
body["only_block_hash"] = only_block_hash
|
|
256
|
+
return self._http.request(
|
|
257
|
+
"POST", f"/api/v2/projects/{project_id}/compile", json=body,
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
def get_compile_job(self, job_id: str) -> CompileJob:
|
|
261
|
+
"""GET /api/v2/compile/jobs/{job_id} — single poll."""
|
|
262
|
+
return self._http.request("GET", f"/api/v2/compile/jobs/{job_id}")
|
|
263
|
+
|
|
264
|
+
def cancel_compile(self, job_id: str) -> CompileJob:
|
|
265
|
+
"""POST /api/v2/compile/jobs/{job_id}/cancel. Cooperative —
|
|
266
|
+
the in-flight block finishes; later blocks are skipped."""
|
|
267
|
+
return self._http.request("POST", f"/api/v2/compile/jobs/{job_id}/cancel")
|
|
268
|
+
|
|
269
|
+
# ── Preview / download ─────────────────────────────────────────────
|
|
270
|
+
|
|
271
|
+
def download_preview(self, project_id: str, dest: Path | str | IO[bytes]) -> None:
|
|
272
|
+
"""GET /api/v2/projects/{id}/preview — stream final.mp4 to `dest`."""
|
|
273
|
+
self._http.stream_to_file(
|
|
274
|
+
f"/api/v2/projects/{project_id}/preview", dest=dest,
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
def download_block_preview(
|
|
278
|
+
self, project_id: str, block_hash: str, dest: Path | str | IO[bytes],
|
|
279
|
+
) -> None:
|
|
280
|
+
"""GET /api/v2/projects/{id}/blocks/by-hash/{hash}/preview —
|
|
281
|
+
stream a single block's mp4."""
|
|
282
|
+
self._http.stream_to_file(
|
|
283
|
+
f"/api/v2/projects/{project_id}/blocks/by-hash/{block_hash}/preview",
|
|
284
|
+
dest=dest,
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
# ── Voice clone ────────────────────────────────────────────────────
|
|
288
|
+
|
|
289
|
+
def upload_voice_sample(
|
|
290
|
+
self, file: Path | str, *, ref_text: str | None = None,
|
|
291
|
+
) -> dict[str, Any]:
|
|
292
|
+
"""POST /api/v2/me/voice-sample — register voice for `\\say[voice=mine]`.
|
|
293
|
+
Auto-enrolls with the cloud TTS provider on success."""
|
|
294
|
+
p = Path(file)
|
|
295
|
+
files: dict[str, Any] = {"file": (p.name, p.read_bytes(), "application/octet-stream")}
|
|
296
|
+
data = {"ref_text": ref_text} if ref_text else None
|
|
297
|
+
return self._http.request(
|
|
298
|
+
"POST", "/api/v2/me/voice-sample", files=files, data=data,
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
def get_voice_sample(self) -> VoiceSampleStatus:
|
|
302
|
+
"""GET /api/v2/me/voice-sample — registration status."""
|
|
303
|
+
return self._http.request("GET", "/api/v2/me/voice-sample")
|
|
304
|
+
|
|
305
|
+
def delete_voice_sample(self) -> None:
|
|
306
|
+
"""DELETE /api/v2/me/voice-sample — removes sample + cloud enrollment."""
|
|
307
|
+
self._http.request("DELETE", "/api/v2/me/voice-sample")
|
|
308
|
+
|
|
309
|
+
# ── Account ────────────────────────────────────────────────────────
|
|
310
|
+
|
|
311
|
+
def get_balance(self) -> Balance:
|
|
312
|
+
"""GET /api/v2/me/balance — credit balance + recent topups."""
|
|
313
|
+
return self._http.request("GET", "/api/v2/me/balance")
|
|
314
|
+
|
|
315
|
+
def get_quota(self) -> Quota:
|
|
316
|
+
"""GET /api/v2/me/quota — plan limits + current consumption."""
|
|
317
|
+
return self._http.request("GET", "/api/v2/me/quota")
|
|
318
|
+
|
|
319
|
+
def get_usage(self, *, range: str = "month") -> Usage: # noqa: A002 — `range` matches API
|
|
320
|
+
"""GET /api/v2/me/usage?range=… ('day' / 'week' / 'month' / 'all')."""
|
|
321
|
+
return self._http.request(
|
|
322
|
+
"GET", "/api/v2/me/usage", params={"range": range},
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
# ── API key management ────────────────────────────────────────────
|
|
326
|
+
# NOTE: minting requires a different bearer (you can't mint a key with
|
|
327
|
+
# an unauthenticated client). These two methods are here for completeness
|
|
328
|
+
# so a script can rotate its own key — the caller still needs the
|
|
329
|
+
# currently-active key in hand to call mint_api_key.
|
|
330
|
+
|
|
331
|
+
def mint_api_key(self) -> ApiKeyMintResult:
|
|
332
|
+
"""POST /api/v2/me/api-key — mint or rotate the user's API key.
|
|
333
|
+
|
|
334
|
+
The full secret is in the response's `api_key` field; this is
|
|
335
|
+
the ONLY time the server returns it. Subsequent calls to
|
|
336
|
+
`get_api_key_status` return only `last4` + `created_at`."""
|
|
337
|
+
return self._http.request("POST", "/api/v2/me/api-key")
|
|
338
|
+
|
|
339
|
+
def get_api_key_status(self) -> ApiKeyStatus:
|
|
340
|
+
"""GET /api/v2/me/api-key — status only (last4 / created_at)."""
|
|
341
|
+
return self._http.request("GET", "/api/v2/me/api-key")
|
|
342
|
+
|
|
343
|
+
def revoke_api_key(self) -> None:
|
|
344
|
+
"""DELETE /api/v2/me/api-key — revoke the current key.
|
|
345
|
+
|
|
346
|
+
Note: this revokes the key the SDK is currently authenticated
|
|
347
|
+
with. Subsequent calls on this Client will 401. You need to
|
|
348
|
+
re-authenticate (e.g. with a fresh JWT) to mint a new one."""
|
|
349
|
+
self._http.request("DELETE", "/api/v2/me/api-key")
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""SDK exception hierarchy.
|
|
2
|
+
|
|
3
|
+
Maps the AutoLecture backend's structured error envelope
|
|
4
|
+
(`{detail: {code, message, **extra}}`) to a typed exception per HTTP
|
|
5
|
+
status / scenario. Callers can switch on `.code`, branch on subclass,
|
|
6
|
+
or just print `str(err)`.
|
|
7
|
+
|
|
8
|
+
The base `AutoLectureError` carries the raw response dict (`.response`)
|
|
9
|
+
so callers that hit a code we don't yet have a subclass for can still
|
|
10
|
+
inspect everything the server sent without re-parsing JSON.
|
|
11
|
+
|
|
12
|
+
The mapping happens in `_http.py::_raise_for_status`. Add a new
|
|
13
|
+
subclass + a row in that dispatch table when the backend grows a new
|
|
14
|
+
404/409/410 code worth surfacing — never raise the bare base.
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class AutoLectureError(Exception):
|
|
22
|
+
"""Base — every SDK exception inherits from this so callers can
|
|
23
|
+
catch `AutoLectureError` once and not miss anything.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
message: human-readable text (from the server's `message` field
|
|
27
|
+
or a fallback we synthesize when the body isn't structured).
|
|
28
|
+
code: stable identifier the server sent (`insufficient_credits`,
|
|
29
|
+
`email_not_verified`, ...). Defaults to `"unknown"` for
|
|
30
|
+
non-structured errors (plain-string `detail` responses).
|
|
31
|
+
status_code: HTTP status. Always set when the error originated
|
|
32
|
+
from an HTTP response; None for client-side issues like a
|
|
33
|
+
broken poll loop.
|
|
34
|
+
response: raw response dict (parsed JSON body) so callers can
|
|
35
|
+
inspect fields we don't surface as attributes yet.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def __init__(
|
|
39
|
+
self,
|
|
40
|
+
message: str,
|
|
41
|
+
*,
|
|
42
|
+
code: str = "unknown",
|
|
43
|
+
status_code: int | None = None,
|
|
44
|
+
response: dict[str, Any] | None = None,
|
|
45
|
+
) -> None:
|
|
46
|
+
super().__init__(message)
|
|
47
|
+
self.message = message
|
|
48
|
+
self.code = code
|
|
49
|
+
self.status_code = status_code
|
|
50
|
+
self.response = response or {}
|
|
51
|
+
|
|
52
|
+
def __repr__(self) -> str:
|
|
53
|
+
return (
|
|
54
|
+
f"{type(self).__name__}(code={self.code!r}, "
|
|
55
|
+
f"status_code={self.status_code!r}, message={self.message!r})"
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class AuthenticationError(AutoLectureError):
|
|
60
|
+
"""401 — bad / missing / revoked API key (or expired JWT)."""
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class PermissionError(AutoLectureError): # noqa: A001 — shadow std `PermissionError` intentionally
|
|
64
|
+
"""403 — authenticated but not allowed. Includes the
|
|
65
|
+
`email_not_verified` gate (`.code == "email_not_verified"`)."""
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class NotFoundError(AutoLectureError):
|
|
69
|
+
"""404 — resource does not exist (or you can't see it)."""
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class QuotaExceededError(AutoLectureError):
|
|
73
|
+
"""402 with `code == "project_quota_exceeded"`.
|
|
74
|
+
|
|
75
|
+
Extra attrs: `.plan`, `.used`, `.limit` (mirrors the server payload).
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
plan: str | None
|
|
79
|
+
used: int | None
|
|
80
|
+
limit: int | None
|
|
81
|
+
|
|
82
|
+
def __init__(self, message: str, **kw: Any) -> None:
|
|
83
|
+
super().__init__(message, **kw)
|
|
84
|
+
self.plan = self.response.get("plan")
|
|
85
|
+
self.used = self.response.get("used")
|
|
86
|
+
self.limit = self.response.get("limit")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class InsufficientCreditsError(AutoLectureError):
|
|
90
|
+
"""402 with `code == "insufficient_credits"`.
|
|
91
|
+
|
|
92
|
+
Extra attrs: `.balance`, `.needed`, `.shortfall` (in credits, ✦).
|
|
93
|
+
"""
|
|
94
|
+
|
|
95
|
+
balance: int | None
|
|
96
|
+
needed: int | None
|
|
97
|
+
shortfall: int | None
|
|
98
|
+
|
|
99
|
+
def __init__(self, message: str, **kw: Any) -> None:
|
|
100
|
+
super().__init__(message, **kw)
|
|
101
|
+
self.balance = self.response.get("balance")
|
|
102
|
+
self.needed = self.response.get("needed")
|
|
103
|
+
self.shortfall = self.response.get("shortfall")
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class RateLimitError(AutoLectureError):
|
|
107
|
+
"""429 — daily / monthly spend cap, or compile concurrency cap.
|
|
108
|
+
|
|
109
|
+
Extra attrs: `.window` (`"daily"` / `"monthly"` / concurrency),
|
|
110
|
+
`.used`, `.limit`.
|
|
111
|
+
"""
|
|
112
|
+
|
|
113
|
+
window: str | None
|
|
114
|
+
used: int | None
|
|
115
|
+
limit: int | None
|
|
116
|
+
|
|
117
|
+
def __init__(self, message: str, **kw: Any) -> None:
|
|
118
|
+
super().__init__(message, **kw)
|
|
119
|
+
self.window = self.response.get("window")
|
|
120
|
+
self.used = self.response.get("used")
|
|
121
|
+
self.limit = self.response.get("limit")
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class ServerError(AutoLectureError):
|
|
125
|
+
"""5xx — something failed on the AutoLecture side. Retry policy is
|
|
126
|
+
caller's choice; the SDK does NOT auto-retry by default because most
|
|
127
|
+
of our 5xx are deterministic engine failures, not flakes."""
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class CompileFailedError(AutoLectureError):
|
|
131
|
+
"""Polling finished and the compile job's `.status == "failed"`.
|
|
132
|
+
|
|
133
|
+
Extra attrs: `.job` (the final CompileJob TypedDict), `.error_log`
|
|
134
|
+
(raw stderr tail, may be empty)."""
|
|
135
|
+
|
|
136
|
+
job: dict[str, Any]
|
|
137
|
+
error_log: str | None
|
|
138
|
+
|
|
139
|
+
def __init__(self, message: str, *, job: dict[str, Any]) -> None:
|
|
140
|
+
super().__init__(
|
|
141
|
+
message,
|
|
142
|
+
code="compile_failed",
|
|
143
|
+
status_code=None,
|
|
144
|
+
response={"job": job},
|
|
145
|
+
)
|
|
146
|
+
self.job = job
|
|
147
|
+
self.error_log = job.get("error_log")
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class CompileCancelledError(AutoLectureError):
|
|
151
|
+
"""Polling finished and the compile job's `.status == "cancelled"`."""
|
|
152
|
+
|
|
153
|
+
job: dict[str, Any]
|
|
154
|
+
|
|
155
|
+
def __init__(self, message: str, *, job: dict[str, Any]) -> None:
|
|
156
|
+
super().__init__(
|
|
157
|
+
message,
|
|
158
|
+
code="compile_cancelled",
|
|
159
|
+
status_code=None,
|
|
160
|
+
response={"job": job},
|
|
161
|
+
)
|
|
162
|
+
self.job = job
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
class APIError(AutoLectureError):
|
|
166
|
+
"""Catch-all for HTTP failures we don't have a more specific class
|
|
167
|
+
for (400 generic validation, 409 conflict, 410 gone, 413 too large,
|
|
168
|
+
503 service unavailable, ...). Inspect `.status_code` + `.code` to
|
|
169
|
+
branch on the specifics."""
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
__all__ = [
|
|
173
|
+
"APIError",
|
|
174
|
+
"AuthenticationError",
|
|
175
|
+
"AutoLectureError",
|
|
176
|
+
"CompileCancelledError",
|
|
177
|
+
"CompileFailedError",
|
|
178
|
+
"InsufficientCreditsError",
|
|
179
|
+
"NotFoundError",
|
|
180
|
+
"PermissionError",
|
|
181
|
+
"QuotaExceededError",
|
|
182
|
+
"RateLimitError",
|
|
183
|
+
"ServerError",
|
|
184
|
+
]
|
autolecture/types.py
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""Typed shapes for backend responses.
|
|
2
|
+
|
|
3
|
+
These mirror the v2 API's JSON shapes 1:1, kept as TypedDicts (not
|
|
4
|
+
dataclasses) so they're zero-runtime-cost — what the server emits is
|
|
5
|
+
exactly what the SDK hands back, no copying / no conversion. IDEs +
|
|
6
|
+
mypy still get full typing.
|
|
7
|
+
|
|
8
|
+
The server keeps these shapes stable as part of the public API
|
|
9
|
+
contract; if a field starts coming back missing or renamed, that's a
|
|
10
|
+
breaking change on the server side, not here.
|
|
11
|
+
|
|
12
|
+
Datetimes come through as ISO-8601 strings (no parsing on our side —
|
|
13
|
+
caller chooses `datetime.fromisoformat` vs leaving as string vs
|
|
14
|
+
something else). UUIDs come through as plain strings.
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from typing import Any, Literal, NotRequired, TypedDict
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# ── Projects ───────────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
class Project(TypedDict):
|
|
24
|
+
"""Summary returned by `/projects` list + create endpoints."""
|
|
25
|
+
|
|
26
|
+
id: str
|
|
27
|
+
name: str
|
|
28
|
+
created_at: str
|
|
29
|
+
updated_at: str
|
|
30
|
+
has_compiled_video: bool
|
|
31
|
+
last_compile_hash: NotRequired[str | None]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class ProjectFull(Project):
|
|
35
|
+
"""Full project — the only extra field over `Project` is `main_tex`.
|
|
36
|
+
Returned by `GET /projects/{id}`."""
|
|
37
|
+
|
|
38
|
+
main_tex: str
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class ProjectQuota(TypedDict):
|
|
42
|
+
"""Mirror of backend ProjectQuota — surfaces in list_projects()."""
|
|
43
|
+
|
|
44
|
+
plan: str
|
|
45
|
+
used: int
|
|
46
|
+
limit: int | None # None = unlimited (paid plans)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# ── Compile ────────────────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
CompileStatus = Literal["pending", "running", "succeeded", "failed", "cancelled"]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class CompileBlockInfo(TypedDict, total=False):
|
|
55
|
+
"""Live "current block" info while a compile is mid-run. All
|
|
56
|
+
optional because the server omits this between blocks."""
|
|
57
|
+
|
|
58
|
+
index: int
|
|
59
|
+
type: str
|
|
60
|
+
hash: str
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class CompileJob(TypedDict):
|
|
64
|
+
"""Polling shape returned by `/compile/jobs/{job_id}`."""
|
|
65
|
+
|
|
66
|
+
id: str
|
|
67
|
+
project_id: str
|
|
68
|
+
status: CompileStatus
|
|
69
|
+
blocks_total: NotRequired[int | None]
|
|
70
|
+
blocks_done: int
|
|
71
|
+
blocks_failed: int
|
|
72
|
+
blocks_cached: int
|
|
73
|
+
progress_pct: NotRequired[int]
|
|
74
|
+
current_block: NotRequired[CompileBlockInfo | None]
|
|
75
|
+
queue_position: NotRequired[int | None]
|
|
76
|
+
est_cost_credits: int
|
|
77
|
+
actual_cost_credits: int
|
|
78
|
+
error_log: NotRequired[str | None]
|
|
79
|
+
elapsed_ms: NotRequired[int | None]
|
|
80
|
+
created_at: str
|
|
81
|
+
started_at: NotRequired[str | None]
|
|
82
|
+
finished_at: NotRequired[str | None]
|
|
83
|
+
has_archive: NotRequired[bool]
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
# ── Assets ─────────────────────────────────────────────────────────
|
|
87
|
+
|
|
88
|
+
class Asset(TypedDict):
|
|
89
|
+
"""One file under a project's assets/ directory."""
|
|
90
|
+
|
|
91
|
+
rel_path: str
|
|
92
|
+
mime: str | None
|
|
93
|
+
sha1: str
|
|
94
|
+
size_bytes: int
|
|
95
|
+
duration_sec: NotRequired[float | None]
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
# ── Voice clone ────────────────────────────────────────────────────
|
|
99
|
+
|
|
100
|
+
class VoiceSampleStatus(TypedDict, total=False):
|
|
101
|
+
"""Status returned by GET /me/voice-sample."""
|
|
102
|
+
|
|
103
|
+
registered: bool
|
|
104
|
+
uploaded_at: str | None
|
|
105
|
+
ref_text: str | None
|
|
106
|
+
size_bytes: int
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
# ── Account / billing-adjacent ─────────────────────────────────────
|
|
110
|
+
|
|
111
|
+
class CreditTopup(TypedDict, total=False):
|
|
112
|
+
id: str
|
|
113
|
+
package_id: str
|
|
114
|
+
amount_paid_cents: int
|
|
115
|
+
credits_added: int
|
|
116
|
+
status: str
|
|
117
|
+
created_at: str
|
|
118
|
+
completed_at: str | None
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class Balance(TypedDict, total=False):
|
|
122
|
+
"""Returned by GET /me/balance."""
|
|
123
|
+
|
|
124
|
+
credits_balance: int
|
|
125
|
+
plan: str
|
|
126
|
+
is_admin: bool
|
|
127
|
+
recent_topups: list[CreditTopup]
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class Quota(TypedDict, total=False):
|
|
131
|
+
"""Mirror of backend QuotaResponse (loosely typed — fields differ
|
|
132
|
+
across plans; treat as advisory)."""
|
|
133
|
+
|
|
134
|
+
plan: str
|
|
135
|
+
is_admin: bool
|
|
136
|
+
daily_used: int
|
|
137
|
+
daily_limit: int
|
|
138
|
+
monthly_used: int
|
|
139
|
+
monthly_limit: int
|
|
140
|
+
can_spend_more: bool
|
|
141
|
+
blocking_window: str | None
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class Usage(TypedDict, total=False):
|
|
145
|
+
"""Aggregated spend returned by GET /me/usage."""
|
|
146
|
+
|
|
147
|
+
range: str
|
|
148
|
+
range_start: str
|
|
149
|
+
range_end: str
|
|
150
|
+
total_credits: int
|
|
151
|
+
by_kind: dict[str, int]
|
|
152
|
+
by_provider: dict[str, int]
|
|
153
|
+
by_day: list[dict[str, Any]]
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
# ── API key (mint endpoint returns the only place we ever surface
|
|
157
|
+
# the plaintext secret) ────────────────────────────────────────────
|
|
158
|
+
|
|
159
|
+
class ApiKeyMintResult(TypedDict):
|
|
160
|
+
"""Response from POST /me/api-key — the ONLY place the plaintext
|
|
161
|
+
secret appears. Store it immediately; subsequent GETs return only
|
|
162
|
+
`last4` + `created_at`."""
|
|
163
|
+
|
|
164
|
+
api_key: str # full `al_live_<32 url-safe bytes>` — show once, never again
|
|
165
|
+
created_at: str
|
|
166
|
+
warning: str # human-readable "store this now"
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
class ApiKeyStatus(TypedDict):
|
|
170
|
+
"""GET /me/api-key — never returns the plaintext."""
|
|
171
|
+
|
|
172
|
+
registered: bool
|
|
173
|
+
last4: str | None
|
|
174
|
+
created_at: str | None
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
__all__ = [
|
|
178
|
+
"ApiKeyMintResult",
|
|
179
|
+
"ApiKeyStatus",
|
|
180
|
+
"Asset",
|
|
181
|
+
"Balance",
|
|
182
|
+
"CompileBlockInfo",
|
|
183
|
+
"CompileJob",
|
|
184
|
+
"CompileStatus",
|
|
185
|
+
"CreditTopup",
|
|
186
|
+
"Project",
|
|
187
|
+
"ProjectFull",
|
|
188
|
+
"ProjectQuota",
|
|
189
|
+
"Quota",
|
|
190
|
+
"Usage",
|
|
191
|
+
"VoiceSampleStatus",
|
|
192
|
+
]
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: autolecture
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Official Python SDK for AutoLecture (https://autolecture.ai). Generate explainer videos from script or audio via a clean HTTP client.
|
|
5
|
+
Project-URL: Homepage, https://autolecture.ai
|
|
6
|
+
Project-URL: Repository, https://github.com/scao7/autolecture-python
|
|
7
|
+
Project-URL: Documentation, https://autolecture.ai/docs/dsl
|
|
8
|
+
Project-URL: Issues, https://github.com/scao7/autolecture-python/issues
|
|
9
|
+
Author-email: scao7 <codescao7@gmail.com>
|
|
10
|
+
License: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: ai,autolecture,explainer,lecturetex,manim,remotion,tts,video,videotex
|
|
13
|
+
Classifier: Development Status :: 3 - Alpha
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Topic :: Multimedia :: Video
|
|
21
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
22
|
+
Classifier: Typing :: Typed
|
|
23
|
+
Requires-Python: >=3.10
|
|
24
|
+
Requires-Dist: httpx>=0.27
|
|
25
|
+
Provides-Extra: dev
|
|
26
|
+
Requires-Dist: mypy>=1.10; extra == 'dev'
|
|
27
|
+
Requires-Dist: pytest-cov>=5; extra == 'dev'
|
|
28
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
29
|
+
Requires-Dist: respx>=0.21; extra == 'dev'
|
|
30
|
+
Requires-Dist: ruff>=0.6; extra == 'dev'
|
|
31
|
+
Description-Content-Type: text/markdown
|
|
32
|
+
|
|
33
|
+
# autolecture — Python SDK
|
|
34
|
+
|
|
35
|
+
Official Python SDK for [AutoLecture](https://autolecture.ai) — author
|
|
36
|
+
explainer videos as a `.tex` source, render them on the server,
|
|
37
|
+
download an mp4. The SDK wraps the v2 HTTP API end-to-end: projects,
|
|
38
|
+
assets, tex source, compile jobs (with polling), final-video download,
|
|
39
|
+
voice clone, billing-adjacent reads.
|
|
40
|
+
|
|
41
|
+
The SDK is the supported way to drive AutoLecture from outside the web
|
|
42
|
+
UI — built for CLIs, Claude Code skills, and any service-to-service
|
|
43
|
+
use case where a JWT login flow doesn't fit.
|
|
44
|
+
|
|
45
|
+
## Install
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
pip install autolecture
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Python 3.10+. The SDK depends only on `httpx>=0.27`.
|
|
52
|
+
|
|
53
|
+
## Auth
|
|
54
|
+
|
|
55
|
+
Every call needs an API key. To mint one:
|
|
56
|
+
|
|
57
|
+
1. Sign in at <https://autolecture.ai>.
|
|
58
|
+
2. Open <https://autolecture.ai/account>, find the **API Keys** section.
|
|
59
|
+
3. Click **Generate** — copy the `al_live_…` string **immediately**, it's
|
|
60
|
+
only shown once.
|
|
61
|
+
|
|
62
|
+
Pass it to the `Client` constructor (or read from `AUTOLECTURE_API_KEY`
|
|
63
|
+
env var in your own code — the SDK doesn't read env vars itself, that's
|
|
64
|
+
caller-side).
|
|
65
|
+
|
|
66
|
+
## Quickstart
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
from pathlib import Path
|
|
70
|
+
from autolecture import Client
|
|
71
|
+
|
|
72
|
+
with Client(api_key="al_live_…") as al:
|
|
73
|
+
# 1. Create a project.
|
|
74
|
+
project = al.create_project(name="My first AutoLecture video")
|
|
75
|
+
pid = project["id"]
|
|
76
|
+
|
|
77
|
+
# 2. Upload a script + any assets you want to reference.
|
|
78
|
+
al.upload_asset(pid, Path("./recording.mp3"))
|
|
79
|
+
|
|
80
|
+
# 3. Write the VideoTeX source. See https://autolecture.ai/docs/dsl.
|
|
81
|
+
al.put_tex(pid, "main.tex", r"""
|
|
82
|
+
\title{Demo}
|
|
83
|
+
\aspect{16:9}
|
|
84
|
+
\begin{videotex}
|
|
85
|
+
\begin{view}
|
|
86
|
+
\say{Hello, AutoLecture!}
|
|
87
|
+
\image[engine=gemini]{a duck explaining a concept on a chalkboard}
|
|
88
|
+
\end{view}
|
|
89
|
+
\end{videotex}
|
|
90
|
+
""".strip())
|
|
91
|
+
|
|
92
|
+
# 4. Compile. `compile()` blocks until the job terminates;
|
|
93
|
+
# `on_progress` fires after each poll for progress UI.
|
|
94
|
+
job = al.compile(
|
|
95
|
+
pid,
|
|
96
|
+
on_progress=lambda j: print(f" [{j['blocks_done']}/{j['blocks_total'] or '?'}] {j['status']}"),
|
|
97
|
+
)
|
|
98
|
+
print(f"Compile finished — {job['actual_cost_credits']} ✦ spent")
|
|
99
|
+
|
|
100
|
+
# 5. Download the final mp4.
|
|
101
|
+
al.download_preview(pid, dest="./out.mp4")
|
|
102
|
+
print("Saved to ./out.mp4")
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
## Errors
|
|
106
|
+
|
|
107
|
+
Every SDK exception inherits from `AutoLectureError`. The common
|
|
108
|
+
subclasses carry the backend's structured-error fields as attributes:
|
|
109
|
+
|
|
110
|
+
```python
|
|
111
|
+
from autolecture import (
|
|
112
|
+
AutoLectureError, AuthenticationError, PermissionError,
|
|
113
|
+
QuotaExceededError, InsufficientCreditsError, RateLimitError,
|
|
114
|
+
CompileFailedError, NotFoundError, APIError,
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
try:
|
|
118
|
+
al.compile(pid)
|
|
119
|
+
except InsufficientCreditsError as e:
|
|
120
|
+
print(f"Need {e.shortfall} more ✦. Balance: {e.balance}, needed: {e.needed}")
|
|
121
|
+
except QuotaExceededError as e:
|
|
122
|
+
print(f"Plan {e.plan} caps at {e.limit} projects (you have {e.used}).")
|
|
123
|
+
except CompileFailedError as e:
|
|
124
|
+
print(f"Render failed:\n{e.error_log}")
|
|
125
|
+
except AutoLectureError as e:
|
|
126
|
+
# Catch-all — every SDK error inherits from this.
|
|
127
|
+
print(f"[{e.code}] {e.message}")
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
For a less specific error (e.g. an HTTP status we don't have a subclass
|
|
131
|
+
for yet), you get a generic `APIError` with `.status_code` and `.code`.
|
|
132
|
+
|
|
133
|
+
## What's in `Client`
|
|
134
|
+
|
|
135
|
+
| Area | Methods |
|
|
136
|
+
|---|---|
|
|
137
|
+
| Projects | `list_projects` / `create_project` / `create_project_from_zip` / `get_project` / `update_project` / `delete_project` / `archive_project` / `unarchive_project` / `duplicate_project` |
|
|
138
|
+
| Tex source | `get_tex` / `put_tex` |
|
|
139
|
+
| Assets | `upload_asset` / `list_assets` / `delete_asset` |
|
|
140
|
+
| Compile | `compile` (high-level + polling) / `start_compile` / `get_compile_job` / `cancel_compile` |
|
|
141
|
+
| Preview | `download_preview` / `download_block_preview` |
|
|
142
|
+
| Voice clone | `upload_voice_sample` / `get_voice_sample` / `delete_voice_sample` |
|
|
143
|
+
| Account | `get_balance` / `get_quota` / `get_usage` |
|
|
144
|
+
| API key | `mint_api_key` / `get_api_key_status` / `revoke_api_key` |
|
|
145
|
+
|
|
146
|
+
See the docstrings — every public method maps 1:1 to a documented
|
|
147
|
+
HTTP endpoint at <https://autolecture.ai/api/v2/...>.
|
|
148
|
+
|
|
149
|
+
## Pointing at a different server
|
|
150
|
+
|
|
151
|
+
Most users won't need this. For local development or staging:
|
|
152
|
+
|
|
153
|
+
```python
|
|
154
|
+
al = Client(api_key="al_live_…", base_url="https://dev.autolecture.ai")
|
|
155
|
+
# or
|
|
156
|
+
al = Client(api_key="al_live_…", base_url="http://localhost:8000")
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
## Async?
|
|
160
|
+
|
|
161
|
+
v0.1 is sync-only — `httpx.Client`, no `await`. An async variant
|
|
162
|
+
(`AsyncClient`) is on the roadmap for v0.2 if there's demand; the
|
|
163
|
+
typical "script in a Claude Code skill" use case is sync-friendly,
|
|
164
|
+
so async hasn't been a priority.
|
|
165
|
+
|
|
166
|
+
## Development
|
|
167
|
+
|
|
168
|
+
```bash
|
|
169
|
+
git clone https://github.com/scao7/autolecture-python
|
|
170
|
+
cd autolecture-python
|
|
171
|
+
pip install -e ".[dev]"
|
|
172
|
+
pytest -q
|
|
173
|
+
ruff check src tests
|
|
174
|
+
mypy src
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
Tests use `respx` to mock the backend HTTP — nothing reaches the network.
|
|
178
|
+
|
|
179
|
+
## License
|
|
180
|
+
|
|
181
|
+
MIT — see [LICENSE](LICENSE).
|
|
182
|
+
|
|
183
|
+
## Links
|
|
184
|
+
|
|
185
|
+
- AutoLecture web app — <https://autolecture.ai>
|
|
186
|
+
- DSL reference — <https://autolecture.ai/docs/dsl>
|
|
187
|
+
- Companion Claude Code skill — <https://github.com/scao7/autolecture-claude-skill>
|
|
188
|
+
- Backend repo — <https://github.com/scao7/autolecture>
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
autolecture/__init__.py,sha256=EXO_4PrEPgeXQW7yKu5one8pBZ6ZxkqOnXlN7DpugkA,2144
|
|
2
|
+
autolecture/_http.py,sha256=BX5JxA912J8lwnqq0oKc4YVmbKgOzy4N99CkeuAj9aQ,8102
|
|
3
|
+
autolecture/_polling.py,sha256=lOKWPL7xd4RnBipRhmneKjcRXpDe-92Rz_Hxse0Cc8E,2753
|
|
4
|
+
autolecture/client.py,sha256=ZACfqdfPhB85P9_H43P_zm3i4KcQcwGlzll_iiFd_tU,15407
|
|
5
|
+
autolecture/exceptions.py,sha256=TNmPcjunEUH18SAuJoNQWMdt5pzBN9hWbyNyFXdTFO0,5878
|
|
6
|
+
autolecture/types.py,sha256=3ARv-Qwwavv3hzcGItyZvrs3VY87StiBubCvFJv3JgU,5588
|
|
7
|
+
autolecture-0.1.0.dist-info/METADATA,sha256=NXv-N6SgxpBEv2HyWct-VnCAESVOMAcRAda5spolO4M,6547
|
|
8
|
+
autolecture-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
9
|
+
autolecture-0.1.0.dist-info/licenses/LICENSE,sha256=qI6QMnWQp1r3t77w1mbVghM0GFwYxEubRnAcLkC-Snw,1062
|
|
10
|
+
autolecture-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 scao7
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|