api.py python
137 lines 5.1 KB
Raw
sha256:2313b6a6353294a0ca08ff579624198da250b99163a86215aed31a905912e360 Land Scooling Lab T0/T2 training contract and seven-tier tests Human minor ⚠ breaking 45 days ago
1 """Dependency-free HTTP API for the Scooling Lab T2 contract."""
2
3 from __future__ import annotations
4
5 import json
6 from http import HTTPStatus
7 from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
8 from pathlib import Path
9 from typing import Callable
10 from urllib.parse import urlparse
11
12 from scooling_lab.errors import ApiError, ErrorCode, error_payload
13 from scooling_lab.service import TrainingApiService
14 from scooling_lab.store import TrainingJobStore
15
16
17 MAX_BODY_BYTES = 16_384
18
19
20 def parse_job_route(path: str, suffix: str = "") -> str | None:
21 """Extract a safe job id from supported job subresource routes."""
22
23 prefix = "/training/jobs/"
24 if not path.startswith(prefix):
25 return None
26 remainder = path.removeprefix(prefix)
27 if suffix:
28 ending = f"/{suffix}"
29 if not remainder.endswith(ending):
30 return None
31 remainder = remainder[: -len(ending)]
32 if "/" in remainder or not remainder.startswith("job_"):
33 return None
34 return remainder
35
36
37 def make_handler(service: TrainingApiService) -> type[BaseHTTPRequestHandler]:
38 """Create a request handler bound to the supplied service."""
39
40 class ScoolingLabRequestHandler(BaseHTTPRequestHandler):
41 """HTTP handler exposing create/get/cancel/list artifact endpoints."""
42
43 server_version = "ScoolingLab/0.1"
44
45 def do_POST(self) -> None:
46 """Handle createTrainingJob and cancelTrainingJob."""
47
48 path = urlparse(self.path).path
49 if path == "/training/jobs":
50 self._handle_json(lambda: service.create_training_job(self._read_json()))
51 return
52 cancel_job_id = parse_job_route(path, "cancel")
53 if cancel_job_id is not None:
54 self._handle_json(lambda: service.cancel_training_job(cancel_job_id))
55 return
56 self._send_error(ApiError(ErrorCode.NOT_FOUND, 404))
57
58 def do_GET(self) -> None:
59 """Handle getTrainingJob and listArtifacts."""
60
61 path = urlparse(self.path).path
62 artifacts_job_id = parse_job_route(path, "artifacts")
63 if artifacts_job_id is not None:
64 self._handle_json(lambda: service.list_artifacts(artifacts_job_id))
65 return
66 job_id = parse_job_route(path)
67 if job_id is not None:
68 self._handle_json(lambda: service.get_training_job(job_id))
69 return
70 self._send_error(ApiError(ErrorCode.NOT_FOUND, 404))
71
72 def do_PUT(self) -> None:
73 """Reject unsupported mutation routes with a stable error."""
74
75 self._send_error(ApiError(ErrorCode.METHOD_NOT_ALLOWED, 405))
76
77 def do_DELETE(self) -> None:
78 """Reject unsupported deletion routes with a stable error."""
79
80 self._send_error(ApiError(ErrorCode.METHOD_NOT_ALLOWED, 405))
81
82 def log_message(self, format: str, *args: object) -> None:
83 """Suppress default request logging to avoid payload/path leakage."""
84
85 return
86
87 def _read_json(self) -> dict[str, object]:
88 content_length = self.headers.get("Content-Length")
89 if content_length is None:
90 raise ApiError(ErrorCode.MALFORMED_JSON, 400)
91 length = int(content_length)
92 if length <= 0 or length > MAX_BODY_BYTES:
93 raise ApiError(ErrorCode.VALIDATION_ERROR, 400)
94 try:
95 payload = json.loads(self.rfile.read(length).decode("utf-8"))
96 except (UnicodeDecodeError, json.JSONDecodeError) as exc:
97 raise ApiError(ErrorCode.MALFORMED_JSON, 400) from exc
98 if not isinstance(payload, dict):
99 raise ApiError(ErrorCode.VALIDATION_ERROR, 400)
100 return payload
101
102 def _handle_json(self, action: Callable[[], object]) -> None:
103 try:
104 result = action()
105 except ApiError as error:
106 self._send_error(error)
107 return
108 except Exception:
109 self._send_error(ApiError(ErrorCode.INTERNAL_ERROR, 500))
110 return
111 self._send_json(result, HTTPStatus.OK)
112
113 def _send_error(self, error: ApiError) -> None:
114 self._send_json(error_payload(error), HTTPStatus(error.status))
115
116 def _send_json(self, payload: object, status: HTTPStatus) -> None:
117 body = json.dumps(payload, sort_keys=True).encode("utf-8")
118 self.send_response(status.value)
119 self.send_header("Content-Type", "application/json")
120 self.send_header("Content-Length", str(len(body)))
121 self.send_header("Cache-Control", "no-store")
122 self.end_headers()
123 self.wfile.write(body)
124
125 return ScoolingLabRequestHandler
126
127
128 def run_server(host: str, port: int, persistence_path: Path | None = None) -> None:
129 """Run the Scooling Lab API server until interrupted."""
130
131 service = TrainingApiService(TrainingJobStore(persistence_path=persistence_path))
132 server = ThreadingHTTPServer((host, port), make_handler(service))
133 server.serve_forever()
134
135
136 if __name__ == "__main__":
137 run_server("127.0.0.1", 8080)
File History 1 commit
sha256:2313b6a6353294a0ca08ff579624198da250b99163a86215aed31a905912e360 Land Scooling Lab T0/T2 training contract and seven-tier tests Human minor 45 days ago