#!/usr/bin/env python3 """Learnt API helper for AI agents (the Learnt skill). Standard library only. Reads the key from LEARNT_API_KEY and only ever talks to learnt.tech. python learnt_api.py GET /course python learnt_api.py PATCH /course --json '{"published": true}' python learnt_api.py POST /modules --json-file module.json python learnt_api.py import-folder ./course [--dry-run] python learnt_api.py upload-image ./diagram.png python learnt_api.py upload-logo ./logo.png python learnt_api.py upload-background ./backdrop.jpg (then PATCH /branding page_background=picture) python learnt_api.py upload-cover ./cover.jpg python learnt_api.py GET /progress.csv --out progress.csv Docs: https://learnt.tech/docs/api """ import argparse import json import os import re import sys import urllib.error import urllib.request import uuid from pathlib import Path BASE = "https://learnt.tech/api/v1" EXTRA_HEADERS = {} # Learnt's own tests set these in-process; never read from the environment. METHODS = ("GET", "POST", "PATCH", "DELETE") IMAGE_TYPES = {".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png", ".webp": "image/webp", ".gif": "image/gif"} def api_url(path): """The full URL for an API path. Refuses anything that isn't a plain path under /api/v1.""" path = "/" + str(path).strip().lstrip("/") if path.startswith("/api/v1/"): path = path[len("/api/v1"):] if not re.fullmatch(r"/[A-Za-z0-9._/-]+(\?[A-Za-z0-9._=&%-]*)?", path) or ".." in path or "//" in path: raise SystemExit(f"Not an API path: {path!r}. Use a path like /course or /modules.") return BASE + path class NoRedirects(urllib.request.HTTPRedirectHandler): """A redirect would carry the key to wherever it points, so none are followed.""" def redirect_request(self, *args, **kwargs): return None OPENER = urllib.request.build_opener(NoRedirects) def key(): k = os.environ.get("LEARNT_API_KEY", "").strip() if not k.startswith("lt_live_"): raise SystemExit("LEARNT_API_KEY isn't set. Create a key in the Course Editor under Developer access, " "then set it in your environment (don't paste it into a file or chat).") return k def call(method, path, body=None, data=None, content_type=None, out=None): headers = {"Authorization": f"Bearer {key()}", "User-Agent": "learnt-skill/1", "Accept": "application/json"} if body is not None: data = json.dumps(body).encode("utf-8") content_type = "application/json" if content_type: headers["Content-Type"] = content_type headers.update(EXTRA_HEADERS) req = urllib.request.Request(api_url(path), data=data, method=method, headers=headers) try: with OPENER.open(req, timeout=120) as r: raw, status, ctype = r.read(), r.status, r.headers.get("Content-Type", "") except urllib.error.HTTPError as e: raw, status, ctype = e.read(), e.code, e.headers.get("Content-Type", "") except urllib.error.URLError as e: raise SystemExit(f"Couldn't reach learnt.tech: {e.reason}") if 300 <= status < 400: raise SystemExit(f"Learnt answered with a redirect ({status}); not following it with your key. Check the path.") if status >= 400: try: err = json.loads(raw) except ValueError: err = {"error": raw.decode("utf-8", "replace")[:300] or f"HTTP {status}"} print(f"Refused ({status}): {err.get('error')}", file=sys.stderr) for field in ("code", "fix", "docs"): if err.get(field): print(f" {field}: {err[field]}", file=sys.stderr) sys.exit(1) if out: Path(out).write_bytes(raw) print(f"Saved {len(raw)} bytes to {out}") return None if "json" in ctype: return json.loads(raw) if raw else {} return raw.decode("utf-8", "replace") def multipart(fields, file_path): p = Path(file_path) ctype = IMAGE_TYPES.get(p.suffix.lower()) if not ctype: raise SystemExit(f"{p.name}: use a JPG, PNG, WEBP or GIF picture.") boundary = uuid.uuid4().hex parts = [] for name, value in fields.items(): parts.append(f'--{boundary}\r\nContent-Disposition: form-data; name="{name}"\r\n\r\n{value}\r\n'.encode()) parts.append(f'--{boundary}\r\nContent-Disposition: form-data; name="file"; filename="{p.name}"\r\n' f"Content-Type: {ctype}\r\n\r\n".encode() + p.read_bytes() + b"\r\n") parts.append(f"--{boundary}--\r\n".encode()) return b"".join(parts), f"multipart/form-data; boundary={boundary}" def title_from_name(name): t = re.sub(r"^[\d\s._-]+", "", Path(name).stem).replace("_", " ").replace("-", " ").strip() return (t[:1].upper() + t[1:]) if t else Path(name).stem def module_from_file(text, name): """One markdown file -> (title, markdown). The first '# ' heading is the title; any later '# ' headings become '## ' so the module stays one module.""" lines = text.lstrip("").replace("\r\n", "\n").split("\n") title, out, fence = None, [], None for line in lines: f = re.match(r"^\s*(```|~~~)", line) if f and fence is None: fence = f.group(1) elif f and line.strip().startswith(fence): fence = None elif fence is None and re.match(r"^# ", line): if title is None: title = re.match(r"^# +(.+?)\s*#*\s*$", line).group(1).strip() continue line = "#" + line out.append(line) return title or title_from_name(name), "\n".join(out).strip() def import_folder(folder, dry_run=False): """Every .md file in the folder, in file-name order, as one /import/markdown request: the modules are added after the existing ones, in this order.""" files = sorted(p for p in Path(folder).iterdir() if p.suffix.lower() in (".md", ".markdown") and p.is_file()) if not files: raise SystemExit(f"No .md files in {folder}.") plan = [(p.name, *module_from_file(p.read_text(encoding="utf-8-sig"), p.name)) for p in files] for i, (name, title, body) in enumerate(plan, 1): print(f"{i}. {title} ({name}, {len(body)} characters)") if dry_run: print("Dry run: nothing was sent. File-name order is plain text order (10-x.md comes before 2-x.md).") return doc = "\n\n".join(f"# {title}\n\n{body}" for _, title, body in plan) made = call("POST", "/import/markdown", {"markdown": doc}) for m in made.get("created", []): print(f"Created: {m.get('title')} id={m.get('id')}") def main(argv=None): if hasattr(sys.stdout, "reconfigure"): sys.stdout.reconfigure(encoding="utf-8") # Windows consoles and pipes default to cp1252 ap = argparse.ArgumentParser(description="Call the Learnt API (https://learnt.tech/docs/api).") ap.add_argument("command", help="GET, POST, PATCH, DELETE, import-folder, upload-image, upload-logo, upload-background or upload-cover") ap.add_argument("target", help="API path (e.g. /modules), folder, or picture file") ap.add_argument("--json", help="JSON request body") ap.add_argument("--json-file", help="file holding the JSON request body") ap.add_argument("--out", help="save the response to this file (for /progress.csv)") ap.add_argument("--dry-run", action="store_true", help="import-folder: list the modules without creating them") a = ap.parse_args(argv) cmd = a.command.upper() if a.command.upper() in METHODS else a.command.lower() if cmd in METHODS: body = None if a.json_file: body = json.loads(Path(a.json_file).read_text(encoding="utf-8-sig")) elif a.json: body = json.loads(a.json) result = call(cmd, a.target, body, out=a.out) elif cmd == "import-folder": return import_folder(a.target, a.dry_run) elif cmd == "upload-image": course = call("GET", "/course") data, ctype = multipart({"course_id": course["id"]}, a.target) result = call("POST", "/images", data=data, content_type=ctype) elif cmd in ("upload-logo", "upload-background", "upload-cover"): data, ctype = multipart({}, a.target) result = call("POST", "/branding/" + cmd.split("-", 1)[1], data=data, content_type=ctype) else: raise SystemExit(f"Unknown command {a.command!r}. Use GET, POST, PATCH, DELETE, import-folder, upload-image, upload-logo, upload-background or upload-cover.") if result is not None: print(result if isinstance(result, str) else json.dumps(result, indent=2, ensure_ascii=False)) if __name__ == "__main__": main()