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.
@@ -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()
@@ -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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.