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